aboutsummaryrefslogtreecommitdiff
path: root/packages/ui/memory-graph
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ui/memory-graph')
-rw-r--r--packages/ui/memory-graph/constants.ts100
-rw-r--r--packages/ui/memory-graph/controls.tsx67
-rw-r--r--packages/ui/memory-graph/graph-canvas.tsx762
-rw-r--r--packages/ui/memory-graph/graph-webgl-canvas.tsx794
-rw-r--r--packages/ui/memory-graph/hooks/use-graph-data.ts304
-rw-r--r--packages/ui/memory-graph/hooks/use-graph-interactions.ts564
-rw-r--r--packages/ui/memory-graph/index.ts19
-rw-r--r--packages/ui/memory-graph/legend.tsx311
-rw-r--r--packages/ui/memory-graph/loading-indicator.tsx44
-rw-r--r--packages/ui/memory-graph/memory-graph.tsx458
-rw-r--r--packages/ui/memory-graph/navigation-controls.tsx67
-rw-r--r--packages/ui/memory-graph/node-detail-panel.tsx268
-rw-r--r--packages/ui/memory-graph/spaces-dropdown.tsx120
-rw-r--r--packages/ui/memory-graph/types.ts122
14 files changed, 4000 insertions, 0 deletions
diff --git a/packages/ui/memory-graph/constants.ts b/packages/ui/memory-graph/constants.ts
new file mode 100644
index 00000000..23193601
--- /dev/null
+++ b/packages/ui/memory-graph/constants.ts
@@ -0,0 +1,100 @@
+// Enhanced glass-morphism color palette
+export const colors = {
+ background: {
+ primary: "#0f1419", // Deep dark blue-gray
+ secondary: "#1a1f29", // Slightly lighter
+ accent: "#252a35", // Card backgrounds
+ },
+ document: {
+ primary: "rgba(255, 255, 255, 0.06)", // Subtle glass white
+ secondary: "rgba(255, 255, 255, 0.12)", // More visible
+ accent: "rgba(255, 255, 255, 0.18)", // Hover state
+ border: "rgba(255, 255, 255, 0.25)", // Sharp borders
+ glow: "rgba(147, 197, 253, 0.4)", // Blue glow for interaction
+ },
+ memory: {
+ primary: "rgba(147, 197, 253, 0.08)", // Subtle glass blue
+ secondary: "rgba(147, 197, 253, 0.16)", // More visible
+ accent: "rgba(147, 197, 253, 0.24)", // Hover state
+ border: "rgba(147, 197, 253, 0.35)", // Sharp borders
+ glow: "rgba(147, 197, 253, 0.5)", // Blue glow for interaction
+ },
+ connection: {
+ weak: "rgba(148, 163, 184, 0)", // Very subtle
+ memory: "rgba(148, 163, 184, 0.3)", // Very subtle
+ medium: "rgba(148, 163, 184, 0.125)", // Medium visibility
+ strong: "rgba(148, 163, 184, 0.4)", // Strong connection
+ },
+ text: {
+ primary: "#ffffff", // Pure white
+ secondary: "#e2e8f0", // Light gray
+ muted: "#94a3b8", // Medium gray
+ },
+ accent: {
+ primary: "rgba(59, 130, 246, 0.7)", // Clean blue
+ secondary: "rgba(99, 102, 241, 0.6)", // Clean purple
+ glow: "rgba(147, 197, 253, 0.6)", // Subtle glow
+ amber: "rgba(251, 165, 36, 0.8)", // Amber for expiring
+ emerald: "rgba(16, 185, 129, 0.4)", // Emerald for new
+ },
+ status: {
+ forgotten: "rgba(220, 38, 38, 0.15)", // Red for forgotten
+ expiring: "rgba(251, 165, 36, 0.8)", // Amber for expiring soon
+ new: "rgba(16, 185, 129, 0.4)", // Emerald for new memories
+ },
+ relations: {
+ updates: "rgba(147, 77, 253, 0.5)", // purple
+ extends: "rgba(16, 185, 129, 0.5)", // green
+ derives: "rgba(147, 197, 253, 0.5)", // blue
+ },
+};
+
+export const LAYOUT_CONSTANTS = {
+ centerX: 400,
+ centerY: 300,
+ clusterRadius: 300, // Memory "bubble" size around a doc - smaller bubble
+ spaceSpacing: 1600, // How far apart the *spaces* (groups of docs) sit - push spaces way out
+ documentSpacing: 1000, // How far the first doc in a space sits from its space-centre - push docs way out
+ minDocDist: 900, // Minimum distance two documents in the **same space** are allowed to be - sets repulsion radius
+ memoryClusterRadius: 300,
+};
+
+// Graph view settings
+export const GRAPH_SETTINGS = {
+ console: {
+ initialZoom: 0.8, // Higher zoom for console - better overview
+ initialPanX: 0,
+ initialPanY: 0,
+ },
+ consumer: {
+ initialZoom: 0.5, // Changed from 0.1 to 0.5 for better initial visibility
+ initialPanX: 400, // Pan towards center to compensate for larger layout
+ initialPanY: 300, // Pan towards center to compensate for larger layout
+ },
+};
+
+// Responsive positioning for different app variants
+export const POSITIONING = {
+ console: {
+ legend: {
+ desktop: "bottom-4 right-4",
+ mobile: "bottom-4 right-4",
+ },
+ loadingIndicator: "top-20 right-4",
+
+ spacesSelector: "top-4 left-4",
+ viewToggle: "", // Not used in console
+ nodeDetail: "top-4 right-4",
+ },
+ consumer: {
+ legend: {
+ desktop: "top-18 right-4",
+ mobile: "bottom-[180px] left-4",
+ },
+ loadingIndicator: "top-20 right-4",
+
+ spacesSelector: "", // Hidden in consumer
+ viewToggle: "top-4 right-4", // Consumer has view toggle
+ nodeDetail: "top-4 right-4",
+ },
+};
diff --git a/packages/ui/memory-graph/controls.tsx b/packages/ui/memory-graph/controls.tsx
new file mode 100644
index 00000000..899d239a
--- /dev/null
+++ b/packages/ui/memory-graph/controls.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { cn } from "@repo/lib/utils";
+import { Button } from "@repo/ui/components/button";
+import { GlassMenuEffect } from "@repo/ui/other/glass-effect";
+import { Move, ZoomIn, ZoomOut } from "lucide-react";
+import { memo } from "react";
+import type { ControlsProps } from "./types";
+
+export const Controls = memo<ControlsProps>(
+ ({ onZoomIn, onZoomOut, onResetView, variant = "console" }) => {
+ // Use explicit classes - controls positioning not defined in constants
+ // Using a reasonable default position
+ const getPositioningClasses = () => {
+ if (variant === "console") {
+ return "bottom-4 left-4";
+ }
+ if (variant === "consumer") {
+ return "bottom-20 right-4";
+ }
+ return "";
+ };
+
+ return (
+ <div
+ className={cn(
+ "absolute z-10 rounded-xl overflow-hidden",
+ getPositioningClasses(),
+ )}
+ >
+ {/* Glass effect background */}
+ <GlassMenuEffect rounded="rounded-xl" />
+
+ <div className="relative z-10 px-4 py-3">
+ <div className="flex items-center gap-2">
+ <Button
+ className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors"
+ onClick={onZoomIn}
+ size="sm"
+ variant="ghost"
+ >
+ <ZoomIn className="w-4 h-4" />
+ </Button>
+ <Button
+ className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors"
+ onClick={onZoomOut}
+ size="sm"
+ variant="ghost"
+ >
+ <ZoomOut className="w-4 h-4" />
+ </Button>
+ <Button
+ className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors"
+ onClick={onResetView}
+ size="sm"
+ variant="ghost"
+ >
+ <Move className="w-4 h-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ );
+ },
+);
+
+Controls.displayName = "Controls";
diff --git a/packages/ui/memory-graph/graph-canvas.tsx b/packages/ui/memory-graph/graph-canvas.tsx
new file mode 100644
index 00000000..c4623c85
--- /dev/null
+++ b/packages/ui/memory-graph/graph-canvas.tsx
@@ -0,0 +1,762 @@
+"use client";
+
+import {
+ memo,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+} from "react";
+import { colors } from "./constants";
+import type {
+ DocumentWithMemories,
+ GraphCanvasProps,
+ GraphNode,
+ MemoryEntry,
+} from "./types";
+
+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
+ 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="absolute inset-0"
+ 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/ui/memory-graph/graph-webgl-canvas.tsx b/packages/ui/memory-graph/graph-webgl-canvas.tsx
new file mode 100644
index 00000000..af13eefc
--- /dev/null
+++ b/packages/ui/memory-graph/graph-webgl-canvas.tsx
@@ -0,0 +1,794 @@
+"use client";
+
+import { Application, extend } from "@pixi/react";
+import { Container as PixiContainer, Graphics as PixiGraphics } from "pixi.js";
+import { memo, useCallback, useEffect, useMemo, useRef } from "react";
+import { colors } from "./constants";
+import type { GraphCanvasProps, MemoryEntry } from "./types";
+
+// Register Pixi Graphics and Container so they can be used as JSX elements
+extend({ Graphics: PixiGraphics, Container: PixiContainer });
+
+export const GraphWebGLCanvas = memo<GraphCanvasProps>(
+ ({
+ nodes,
+ edges,
+ panX,
+ panY,
+ zoom,
+ width,
+ height,
+ onNodeHover,
+ onNodeClick,
+ onNodeDragStart,
+ onNodeDragMove,
+ onNodeDragEnd,
+ onPanStart,
+ onPanMove,
+ onPanEnd,
+ onWheel,
+ onDoubleClick,
+ onTouchStart,
+ onTouchMove,
+ onTouchEnd,
+ draggingNodeId,
+ }) => {
+ const containerRef = useRef<HTMLDivElement>(null);
+ const isPanningRef = useRef(false);
+ const currentHoveredRef = useRef<string | null>(null);
+ const pointerDownPosRef = useRef<{ x: number; y: number } | null>(null);
+ const pointerMovedRef = useRef(false);
+ // World container that is transformed instead of redrawing every pan/zoom
+ const worldContainerRef = useRef<PixiContainer | null>(null);
+
+ // Throttled wheel handling -------------------------------------------
+ const pendingWheelDeltaRef = useRef<{ dx: number; dy: number }>({
+ dx: 0,
+ dy: 0,
+ });
+ const wheelRafRef = useRef<number | null>(null);
+ // Removed bitmap caching due to black-screen issues – throttle already boosts zoom performance
+
+ // Persistent graphics refs
+ const gridG = useRef<PixiGraphics | null>(null);
+ const edgesG = useRef<PixiGraphics | null>(null);
+ const docsG = useRef<PixiGraphics | null>(null);
+ const memsG = useRef<PixiGraphics | null>(null);
+
+ // ---------- Zoom bucket (reduces redraw frequency) ----------
+ const zoomBucket = useMemo(() => Math.round(zoom * 4) / 4, [zoom]);
+
+ // Redraw layers only when their data changes ----------------------
+ useEffect(() => {
+ if (gridG.current) drawGrid(gridG.current);
+ }, [panX, panY, zoom, width, height]);
+
+ useEffect(() => {
+ if (edgesG.current) drawEdges(edgesG.current);
+ }, [edgesG.current, edges, nodes, zoomBucket]);
+
+ useEffect(() => {
+ if (docsG.current) drawDocuments(docsG.current);
+ }, [docsG.current, nodes, zoomBucket]);
+
+ useEffect(() => {
+ if (memsG.current) drawMemories(memsG.current);
+ }, [memsG.current, nodes, zoomBucket]);
+
+ // Apply pan & zoom via world transform instead of geometry rebuilds
+ useEffect(() => {
+ if (worldContainerRef.current) {
+ worldContainerRef.current.position.set(panX, panY);
+ worldContainerRef.current.scale.set(zoom);
+ }
+ }, [panX, panY, zoom]);
+
+ // No bitmap caching – nothing to clean up
+
+ /* ---------- Helpers ---------- */
+ const getNodeAtPosition = useCallback(
+ (clientX: number, clientY: number): string | null => {
+ const rect = containerRef.current?.getBoundingClientRect();
+ if (!rect) return null;
+
+ const localX = clientX - rect.left;
+ const localY = clientY - rect.top;
+
+ const worldX = (localX - panX) / zoom;
+ const worldY = (localY - panY) / zoom;
+
+ for (const node of nodes) {
+ if (node.type === "document") {
+ const halfW = (node.size * 1.4) / 2;
+ const halfH = (node.size * 0.9) / 2;
+ if (
+ worldX >= node.x - halfW &&
+ worldX <= node.x + halfW &&
+ worldY >= node.y - halfH &&
+ worldY <= node.y + halfH
+ ) {
+ return node.id;
+ }
+ } else if (node.type === "memory") {
+ const r = node.size / 2;
+ const dx = worldX - node.x;
+ const dy = worldY - node.y;
+ if (dx * dx + dy * dy <= r * r) {
+ return node.id;
+ }
+ }
+ }
+ return null;
+ },
+ [nodes, panX, panY, zoom],
+ );
+
+ /* ---------- Grid drawing ---------- */
+ const drawGrid = useCallback(
+ (g: PixiGraphics) => {
+ g.clear();
+
+ const gridColor = 0x94a3b8; // rgb(148,163,184)
+ const gridAlpha = 0.03;
+ const gridSpacing = 100 * zoom;
+
+ // panning offsets
+ const offsetX = panX % gridSpacing;
+ const offsetY = panY % gridSpacing;
+
+ g.lineStyle(1, gridColor, gridAlpha);
+
+ // vertical lines
+ for (let x = offsetX; x < width; x += gridSpacing) {
+ g.moveTo(x, 0);
+ g.lineTo(x, height);
+ }
+
+ // horizontal lines
+ for (let y = offsetY; y < height; y += gridSpacing) {
+ g.moveTo(0, y);
+ g.lineTo(width, y);
+ }
+
+ // Stroke to render grid lines
+ g.stroke();
+ },
+ [panX, panY, zoom, width, height],
+ );
+
+ /* ---------- Color parsing ---------- */
+ const toHexAlpha = (input: string): { hex: number; alpha: number } => {
+ if (!input) return { hex: 0xffffff, alpha: 1 };
+ const str = input.trim().toLowerCase();
+ // rgba() or rgb()
+ const rgbaMatch = str
+ .replace(/\s+/g, "")
+ .match(/rgba?\((\d+),(\d+),(\d+)(?:,(\d*\.?\d+))?\)/i);
+ if (rgbaMatch) {
+ const r = Number.parseInt(rgbaMatch[1] || "0");
+ const g = Number.parseInt(rgbaMatch[2] || "0");
+ const b = Number.parseInt(rgbaMatch[3] || "0");
+ const a =
+ rgbaMatch[4] !== undefined ? Number.parseFloat(rgbaMatch[4]) : 1;
+ return { hex: (r << 16) + (g << 8) + b, alpha: a };
+ }
+ // #rrggbb or #rrggbbaa
+ if (str.startsWith("#")) {
+ const hexBody = str.slice(1);
+ if (hexBody.length === 6) {
+ return { hex: Number.parseInt(hexBody, 16), alpha: 1 };
+ }
+ if (hexBody.length === 8) {
+ const rgb = Number.parseInt(hexBody.slice(0, 6), 16);
+ const aByte = Number.parseInt(hexBody.slice(6, 8), 16);
+ return { hex: rgb, alpha: aByte / 255 };
+ }
+ }
+ // 0xRRGGBB
+ if (str.startsWith("0x")) {
+ return { hex: Number.parseInt(str, 16), alpha: 1 };
+ }
+ return { hex: 0xffffff, alpha: 1 };
+ };
+
+ const drawDocuments = useCallback(
+ (g: PixiGraphics) => {
+ g.clear();
+
+ nodes.forEach((node) => {
+ if (node.type !== "document") return;
+
+ // World-space coordinates – container transform handles pan/zoom
+ const screenX = node.x;
+ const screenY = node.y;
+ const nodeSize = node.size;
+
+ const docWidth = nodeSize * 1.4;
+ const docHeight = nodeSize * 0.9;
+
+ // Choose colors similar to canvas version
+ const fill = node.isDragging
+ ? colors.document.accent
+ : node.isHovered
+ ? colors.document.secondary
+ : colors.document.primary;
+
+ const strokeCol = node.isDragging
+ ? colors.document.glow
+ : node.isHovered
+ ? colors.document.accent
+ : colors.document.border;
+
+ const { hex: fillHex, alpha: fillAlpha } = toHexAlpha(fill);
+ const { hex: strokeHex, alpha: strokeAlpha } = toHexAlpha(strokeCol);
+
+ // Stroke first then fill for proper shape borders
+ const docStrokeWidth =
+ (node.isDragging ? 3 : node.isHovered ? 2 : 1) / zoom;
+ g.lineStyle(docStrokeWidth, strokeHex, strokeAlpha);
+ g.beginFill(fillHex, fillAlpha);
+
+ const radius = zoom < 0.3 ? 6 : 12;
+ g.drawRoundedRect(
+ screenX - docWidth / 2,
+ screenY - docHeight / 2,
+ docWidth,
+ docHeight,
+ radius,
+ );
+ g.endFill();
+
+ // Inner highlight for glass effect (match GraphCanvas)
+ if (zoom >= 0.3 && (node.isHovered || node.isDragging)) {
+ const { hex: hlHex } = toHexAlpha("#ffffff");
+ // Inner highlight stroke width constant
+ const innerStroke = 1 / zoom;
+ g.lineStyle(innerStroke, hlHex, 0.1);
+ g.drawRoundedRect(
+ screenX - docWidth / 2 + 1,
+ screenY - docHeight / 2 + 1,
+ docWidth - 2,
+ docHeight - 2,
+ radius - 1,
+ );
+ g.stroke();
+ }
+ });
+ },
+ [nodes, zoom],
+ );
+
+ /* ---------- Memories layer ---------- */
+ const drawMemories = useCallback(
+ (g: PixiGraphics) => {
+ g.clear();
+
+ nodes.forEach((node) => {
+ if (node.type !== "memory") return;
+
+ const mem = node.data as MemoryEntry;
+ const screenX = node.x;
+ const screenY = node.y;
+ const nodeSize = node.size;
+
+ const radius = nodeSize / 2;
+
+ // status checks
+ const isForgotten =
+ mem?.isForgotten ||
+ (mem?.forgetAfter &&
+ new Date(mem.forgetAfter).getTime() < Date.now());
+ const isLatest = mem?.isLatest;
+ const expiringSoon =
+ mem?.forgetAfter &&
+ !isForgotten &&
+ new Date(mem.forgetAfter).getTime() - Date.now() <
+ 1000 * 60 * 60 * 24 * 7;
+ const isNew =
+ !isForgotten &&
+ new Date(mem?.createdAt).getTime() >
+ Date.now() - 1000 * 60 * 60 * 24;
+
+ // colours
+ 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 (node.isDragging) {
+ fillColor = colors.memory.accent;
+ borderColor = glowColor;
+ } else if (node.isHovered) {
+ fillColor = colors.memory.secondary;
+ }
+
+ const { hex: fillHex, alpha: fillAlpha } = toHexAlpha(fillColor);
+ const { hex: borderHex, alpha: borderAlpha } =
+ toHexAlpha(borderColor);
+
+ // Match canvas behavior: multiply by isLatest global alpha
+ const globalAlpha = isLatest ? 1 : 0.4;
+ const finalFillAlpha = globalAlpha * fillAlpha;
+ const finalStrokeAlpha = globalAlpha * borderAlpha;
+ // Stroke first then fill for visible border
+ const memStrokeW =
+ (node.isDragging ? 3 : node.isHovered ? 2 : 1.5) / zoom;
+ g.lineStyle(memStrokeW, borderHex, finalStrokeAlpha);
+ g.beginFill(fillHex, finalFillAlpha);
+
+ if (zoom < 0.3) {
+ // simplified circle when zoomed out
+ g.drawCircle(screenX, screenY, radius);
+ } else {
+ // hexagon
+ const sides = 6;
+ const points: number[] = [];
+ for (let i = 0; i < sides; i++) {
+ const angle = (i * 2 * Math.PI) / sides - Math.PI / 2;
+ points.push(screenX + radius * Math.cos(angle));
+ points.push(screenY + radius * Math.sin(angle));
+ }
+ g.drawPolygon(points);
+ }
+
+ g.endFill();
+
+ // Status overlays (forgotten / new) – match GraphCanvas visuals
+ if (isForgotten) {
+ const { hex: crossHex, alpha: crossAlpha } = toHexAlpha(
+ "rgba(220,38,38,0.4)",
+ );
+ // Cross/ dot overlay stroke widths constant
+ const overlayStroke = 2 / zoom;
+ g.lineStyle(overlayStroke, crossHex, globalAlpha * crossAlpha);
+ const rCross = nodeSize * 0.25;
+ g.moveTo(screenX - rCross, screenY - rCross);
+ g.lineTo(screenX + rCross, screenY + rCross);
+ g.moveTo(screenX + rCross, screenY - rCross);
+ g.lineTo(screenX - rCross, screenY + rCross);
+ g.stroke();
+ } else if (isNew) {
+ const { hex: dotHex, alpha: dotAlpha } = toHexAlpha(
+ colors.status.new,
+ );
+ // Dot scales with node (GraphCanvas behaviour)
+ const dotRadius = Math.max(2, nodeSize * 0.15);
+ g.beginFill(dotHex, globalAlpha * dotAlpha);
+ g.drawCircle(
+ screenX + nodeSize * 0.25,
+ screenY - nodeSize * 0.25,
+ dotRadius,
+ );
+ g.endFill();
+ }
+ });
+ },
+ [nodes, zoom],
+ );
+
+ /* ---------- Edges layer ---------- */
+ // Helper: draw dashed quadratic curve to approximate canvas setLineDash
+ const drawDashedQuadratic = useCallback(
+ (
+ g: PixiGraphics,
+ sx: number,
+ sy: number,
+ cx: number,
+ cy: number,
+ tx: number,
+ ty: number,
+ dash = 10,
+ gap = 5,
+ ) => {
+ // Sample the curve and accumulate lines per dash to avoid overdraw
+ const curveLength = Math.sqrt((sx - tx) ** 2 + (sy - ty) ** 2);
+ const totalSamples = Math.max(
+ 20,
+ Math.min(120, Math.floor(curveLength / 10)),
+ );
+ let prevX = sx;
+ let prevY = sy;
+ let distanceSinceToggle = 0;
+ let drawSegment = true;
+ let hasActiveDash = false;
+ let dashStartX = sx;
+ let dashStartY = sy;
+
+ for (let i = 1; i <= totalSamples; i++) {
+ const t = i / totalSamples;
+ const mt = 1 - t;
+ const x = mt * mt * sx + 2 * mt * t * cx + t * t * tx;
+ const y = mt * mt * sy + 2 * mt * t * cy + t * t * ty;
+
+ const dx = x - prevX;
+ const dy = y - prevY;
+ const segLen = Math.sqrt(dx * dx + dy * dy);
+ distanceSinceToggle += segLen;
+
+ if (drawSegment) {
+ if (!hasActiveDash) {
+ dashStartX = prevX;
+ dashStartY = prevY;
+ hasActiveDash = true;
+ }
+ }
+
+ const threshold = drawSegment ? dash : gap;
+ if (distanceSinceToggle >= threshold) {
+ // end of current phase
+ if (drawSegment && hasActiveDash) {
+ g.moveTo(dashStartX, dashStartY);
+ g.lineTo(prevX, prevY);
+ g.stroke();
+ hasActiveDash = false;
+ }
+ distanceSinceToggle = 0;
+ drawSegment = !drawSegment;
+ // If we transition into draw mode, start a new dash at current segment start
+ if (drawSegment) {
+ dashStartX = prevX;
+ dashStartY = prevY;
+ hasActiveDash = true;
+ }
+ }
+
+ prevX = x;
+ prevY = y;
+ }
+
+ // Flush any active dash at the end
+ if (drawSegment && hasActiveDash) {
+ g.moveTo(dashStartX, dashStartY);
+ g.lineTo(prevX, prevY);
+ g.stroke();
+ }
+ },
+ [],
+ );
+ const drawEdges = useCallback(
+ (g: PixiGraphics) => {
+ g.clear();
+
+ // Match GraphCanvas LOD behaviour
+ const useSimplified = zoom < 0.3;
+
+ // quick node lookup
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
+
+ edges.forEach((edge) => {
+ // Skip very weak doc-memory edges when zoomed out – behaviour copied from GraphCanvas
+ if (
+ useSimplified &&
+ edge.edgeType === "doc-memory" &&
+ (edge.visualProps?.opacity ?? 1) < 0.3
+ ) {
+ return;
+ }
+ const source = nodeMap.get(edge.source);
+ const target = nodeMap.get(edge.target);
+ if (!source || !target) return;
+
+ const sx = source.x;
+ const sy = source.y;
+ const tx = target.x;
+ const ty = target.y;
+
+ // No viewport culling here because container transform handles visibility
+
+ let lineWidth = Math.max(1, edge.visualProps?.thickness ?? 1);
+ // Use opacity exactly as provided to match GraphCanvas behaviour
+ let opacity = edge.visualProps.opacity;
+ let col = edge.color || colors.connection.weak;
+
+ if (edge.edgeType === "doc-memory") {
+ lineWidth = 1;
+ opacity = 0.9;
+ col = colors.connection.memory;
+
+ if (useSimplified && opacity < 0.3) return;
+ } else if (edge.edgeType === "doc-doc") {
+ opacity = Math.max(0, edge.similarity * 0.5);
+ lineWidth = Math.max(1, edge.similarity * 2);
+ col = colors.connection.medium;
+ if (edge.similarity > 0.85) col = colors.connection.strong;
+ } else if (edge.edgeType === "version") {
+ col = edge.color || colors.relations.updates;
+ opacity = 0.8;
+ lineWidth = 2;
+ }
+
+ const { hex: strokeHex, alpha: colorAlpha } = toHexAlpha(col);
+ const finalEdgeAlpha = Math.max(0, Math.min(1, opacity * colorAlpha));
+
+ // Always use round line caps (same as Canvas 2D)
+ const screenLineWidth = lineWidth / zoom;
+ g.lineStyle(screenLineWidth, strokeHex, finalEdgeAlpha);
+
+ if (edge.edgeType === "version") {
+ // double line effect to match canvas (outer thicker, faint + inner thin)
+ g.lineStyle(3 / zoom, strokeHex, finalEdgeAlpha * 0.3);
+ g.moveTo(sx, sy);
+ g.lineTo(tx, ty);
+ g.stroke();
+
+ g.lineStyle(1 / zoom, strokeHex, finalEdgeAlpha);
+ g.moveTo(sx, sy);
+ g.lineTo(tx, ty);
+ g.stroke();
+
+ // arrow head
+ const angle = Math.atan2(ty - sy, tx - sx);
+ const arrowLen = Math.max(6 / zoom, 8);
+ const nodeRadius = target.size / 2;
+ const ax = tx - Math.cos(angle) * (nodeRadius + 2);
+ const ay = ty - Math.sin(angle) * (nodeRadius + 2);
+
+ g.moveTo(ax, ay);
+ g.lineTo(
+ ax - arrowLen * Math.cos(angle - Math.PI / 6),
+ ay - arrowLen * Math.sin(angle - Math.PI / 6),
+ );
+ g.moveTo(ax, ay);
+ g.lineTo(
+ ax - arrowLen * Math.cos(angle + Math.PI / 6),
+ ay - arrowLen * Math.sin(angle + Math.PI / 6),
+ );
+ g.stroke();
+ } else {
+ // straight line when zoomed out; dashed curved when zoomed in for doc-doc
+ if (useSimplified) {
+ g.moveTo(sx, sy);
+ g.lineTo(tx, ty);
+ g.stroke();
+ } else {
+ const midX = (sx + tx) / 2;
+ const midY = (sy + ty) / 2;
+ const dx = tx - sx;
+ const dy = ty - sy;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ const ctrlOffset =
+ edge.edgeType === "doc-memory" ? 15 : Math.min(30, dist * 0.2);
+
+ const cx = midX + ctrlOffset * (dy / dist);
+ const cy = midY - ctrlOffset * (dx / dist);
+
+ if (edge.edgeType === "doc-doc") {
+ if (useSimplified) {
+ // Straight line when zoomed out (no dash)
+ g.moveTo(sx, sy);
+ g.quadraticCurveTo(cx, cy, tx, ty);
+ g.stroke();
+ } else {
+ // Dash lengths scale with zoom to keep screen size constant
+ const dash = 10 / zoom;
+ const gap = 5 / zoom;
+ drawDashedQuadratic(g, sx, sy, cx, cy, tx, ty, dash, gap);
+ }
+ } else {
+ g.moveTo(sx, sy);
+ g.quadraticCurveTo(cx, cy, tx, ty);
+ g.stroke();
+ }
+ }
+ }
+ });
+ },
+ [edges, nodes, zoom, width, drawDashedQuadratic],
+ );
+
+ /* ---------- pointer handlers (unchanged) ---------- */
+ // Pointer move (pan or drag)
+ const handlePointerMove = useCallback(
+ (e: React.PointerEvent<HTMLDivElement>) => {
+ const mouseEvent = {
+ clientX: e.clientX,
+ clientY: e.clientY,
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ } as React.MouseEvent;
+
+ if (draggingNodeId) {
+ // Node dragging handled elsewhere (future steps)
+ onNodeDragMove(mouseEvent);
+ } else if (isPanningRef.current) {
+ onPanMove(mouseEvent);
+ }
+
+ // Track movement for distinguishing click vs drag/pan
+ if (pointerDownPosRef.current) {
+ const dx = e.clientX - pointerDownPosRef.current.x;
+ const dy = e.clientY - pointerDownPosRef.current.y;
+ if (Math.sqrt(dx * dx + dy * dy) > 3) pointerMovedRef.current = true;
+ }
+
+ // Hover detection
+ const nodeId = getNodeAtPosition(e.clientX, e.clientY);
+ if (nodeId !== currentHoveredRef.current) {
+ currentHoveredRef.current = nodeId;
+ onNodeHover(nodeId);
+ }
+ },
+ [
+ draggingNodeId,
+ onNodeDragMove,
+ onPanMove,
+ onNodeHover,
+ getNodeAtPosition,
+ ],
+ );
+
+ const handlePointerDown = useCallback(
+ (e: React.PointerEvent<HTMLDivElement>) => {
+ const mouseEvent = {
+ clientX: e.clientX,
+ clientY: e.clientY,
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ } as React.MouseEvent;
+
+ const nodeId = getNodeAtPosition(e.clientX, e.clientY);
+ if (nodeId) {
+ onNodeDragStart(nodeId, mouseEvent);
+ // drag handled externally
+ } else {
+ onPanStart(mouseEvent);
+ isPanningRef.current = true;
+ }
+ pointerDownPosRef.current = { x: e.clientX, y: e.clientY };
+ pointerMovedRef.current = false;
+ },
+ [onPanStart, onNodeDragStart, getNodeAtPosition],
+ );
+
+ const handlePointerUp = useCallback(
+ (e: React.PointerEvent<HTMLDivElement>) => {
+ const wasPanning = isPanningRef.current;
+ if (draggingNodeId) onNodeDragEnd();
+ else if (wasPanning) onPanEnd();
+
+ // Consider it a click if not panning and movement was minimal
+ if (!wasPanning && !pointerMovedRef.current) {
+ const nodeId = getNodeAtPosition(e.clientX, e.clientY);
+ if (nodeId) onNodeClick(nodeId);
+ }
+
+ isPanningRef.current = false;
+ pointerDownPosRef.current = null;
+ pointerMovedRef.current = false;
+ },
+ [draggingNodeId, onNodeDragEnd, onPanEnd, getNodeAtPosition, onNodeClick],
+ );
+
+ // Click handler – opens detail panel
+ const handleClick = useCallback(
+ (e: React.MouseEvent<HTMLDivElement>) => {
+ if (isPanningRef.current) return;
+ const nodeId = getNodeAtPosition(e.clientX, e.clientY);
+ if (nodeId) onNodeClick(nodeId);
+ },
+ [getNodeAtPosition, onNodeClick],
+ );
+
+ // Click handled in pointer up to avoid duplicate events
+
+ const handleWheel = useCallback(
+ (e: React.WheelEvent<HTMLDivElement>) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Accumulate deltas
+ pendingWheelDeltaRef.current.dx += e.deltaX;
+ pendingWheelDeltaRef.current.dy += e.deltaY;
+
+ // Schedule a single update per frame
+ if (wheelRafRef.current === null) {
+ wheelRafRef.current = requestAnimationFrame(() => {
+ const { dx, dy } = pendingWheelDeltaRef.current;
+ pendingWheelDeltaRef.current = { dx: 0, dy: 0 };
+
+ // @ts-expect-error
+ onWheel({
+ deltaY: dy,
+ deltaX: dx,
+ clientX: e.clientX,
+ clientY: e.clientY,
+ currentTarget: containerRef.current,
+ nativeEvent: e.nativeEvent,
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ } as React.WheelEvent);
+
+ wheelRafRef.current = null;
+
+ // nothing else – caching removed
+ });
+ }
+ },
+ [onWheel],
+ );
+
+ // Cleanup any pending RAF on unmount
+ useEffect(() => {
+ return () => {
+ if (wheelRafRef.current !== null) {
+ cancelAnimationFrame(wheelRafRef.current);
+ }
+ };
+ }, []);
+
+ return (
+ <div
+ className="absolute inset-0"
+ onDoubleClick={(ev) =>
+ onDoubleClick?.(ev as unknown as React.MouseEvent)
+ }
+ onKeyDown={(ev) => {
+ if (ev.key === "Enter")
+ handleClick(ev as unknown as React.MouseEvent<HTMLDivElement>);
+ }}
+ onPointerDown={handlePointerDown}
+ onPointerLeave={() => {
+ if (draggingNodeId) onNodeDragEnd();
+ if (isPanningRef.current) onPanEnd();
+ isPanningRef.current = false;
+ pointerDownPosRef.current = null;
+ pointerMovedRef.current = false;
+ }}
+ onPointerMove={handlePointerMove}
+ onPointerUp={handlePointerUp}
+ onTouchStart={onTouchStart}
+ onTouchMove={onTouchMove}
+ onTouchEnd={onTouchEnd}
+ onWheel={handleWheel}
+ ref={containerRef}
+ role="application"
+ style={{
+ cursor: draggingNodeId ? "grabbing" : "move",
+ touchAction: "none",
+ userSelect: "none",
+ WebkitUserSelect: "none",
+ }}
+ >
+ <Application
+ preference="webgl"
+ antialias
+ autoDensity
+ backgroundColor={0x0f1419}
+ height={height}
+ resolution={
+ typeof window !== "undefined" ? window.devicePixelRatio : 1
+ }
+ width={width}
+ >
+ {/* Grid background (not affected by world transform) */}
+ <pixiGraphics ref={gridG} draw={() => {}} />
+
+ {/* World container that pans/zooms as a single transform */}
+ <pixiContainer ref={worldContainerRef}>
+ {/* Edges */}
+ <pixiGraphics ref={edgesG} draw={() => {}} />
+
+ {/* Documents */}
+ <pixiGraphics ref={docsG} draw={() => {}} />
+
+ {/* Memories */}
+ <pixiGraphics ref={memsG} draw={() => {}} />
+ </pixiContainer>
+ </Application>
+ </div>
+ );
+ },
+);
+
+GraphWebGLCanvas.displayName = "GraphWebGLCanvas";
diff --git a/packages/ui/memory-graph/hooks/use-graph-data.ts b/packages/ui/memory-graph/hooks/use-graph-data.ts
new file mode 100644
index 00000000..3e9fa5cc
--- /dev/null
+++ b/packages/ui/memory-graph/hooks/use-graph-data.ts
@@ -0,0 +1,304 @@
+"use client";
+
+import {
+ calculateSemanticSimilarity,
+ getConnectionVisualProps,
+ getMagicalConnectionColor,
+} from "@repo/lib/similarity";
+import { useMemo } from "react";
+import { colors, LAYOUT_CONSTANTS } from "../constants";
+import type {
+ DocumentsResponse,
+ DocumentWithMemories,
+ GraphEdge,
+ GraphNode,
+ MemoryEntry,
+ MemoryRelation,
+} from "../types";
+
+export function useGraphData(
+ data: DocumentsResponse | null,
+ selectedSpace: string,
+ nodePositions: Map<string, { x: number; y: number }>,
+ draggingNodeId: string | null,
+) {
+ return useMemo(() => {
+ if (!data?.documents) return { nodes: [], edges: [] };
+
+ const allNodes: GraphNode[] = [];
+ const allEdges: GraphEdge[] = [];
+
+ // Filter documents that have memories in selected space
+ const filteredDocuments = data.documents
+ .map((doc) => ({
+ ...doc,
+ memoryEntries:
+ selectedSpace === "all"
+ ? doc.memoryEntries
+ : doc.memoryEntries.filter(
+ (memory) =>
+ (memory.spaceContainerTag ?? memory.spaceId ?? "default") ===
+ selectedSpace,
+ ),
+ }))
+ .filter((doc) => doc.memoryEntries.length > 0);
+
+ // Group documents by space for better clustering
+ const documentsBySpace = new Map<string, typeof filteredDocuments>();
+ filteredDocuments.forEach((doc) => {
+ const docSpace =
+ doc.memoryEntries[0]?.spaceContainerTag ??
+ doc.memoryEntries[0]?.spaceId ??
+ "default";
+ if (!documentsBySpace.has(docSpace)) {
+ documentsBySpace.set(docSpace, []);
+ }
+ const spaceDocsArr = documentsBySpace.get(docSpace);
+ if (spaceDocsArr) {
+ spaceDocsArr.push(doc);
+ }
+ });
+
+ // Enhanced Layout with Space Separation
+ const { centerX, centerY, clusterRadius, spaceSpacing, documentSpacing } =
+ LAYOUT_CONSTANTS;
+
+ /* 1. Build DOCUMENT nodes with space-aware clustering */
+ const documentNodes: GraphNode[] = [];
+ let spaceIndex = 0;
+
+ documentsBySpace.forEach((spaceDocs) => {
+ const spaceAngle = (spaceIndex / documentsBySpace.size) * Math.PI * 2;
+ const spaceOffsetX = Math.cos(spaceAngle) * spaceSpacing;
+ const spaceOffsetY = Math.sin(spaceAngle) * spaceSpacing;
+ const spaceCenterX = centerX + spaceOffsetX;
+ const spaceCenterY = centerY + spaceOffsetY;
+
+ spaceDocs.forEach((doc, docIndex) => {
+ // Create proper circular layout with concentric rings
+ const docsPerRing = 6; // Start with 6 docs in inner ring
+ let currentRing = 0;
+ let docsInCurrentRing = docsPerRing;
+ let totalDocsInPreviousRings = 0;
+
+ // Find which ring this document belongs to
+ while (totalDocsInPreviousRings + docsInCurrentRing <= docIndex) {
+ totalDocsInPreviousRings += docsInCurrentRing;
+ currentRing++;
+ docsInCurrentRing = docsPerRing + currentRing * 4; // Each ring has more docs
+ }
+
+ // Position within the ring
+ const positionInRing = docIndex - totalDocsInPreviousRings;
+ const angleInRing = (positionInRing / docsInCurrentRing) * Math.PI * 2;
+
+ // Radius increases significantly with each ring
+ const baseRadius = documentSpacing * 0.8;
+ const radius =
+ currentRing === 0
+ ? baseRadius
+ : baseRadius + currentRing * documentSpacing * 1.2;
+
+ const defaultX = spaceCenterX + Math.cos(angleInRing) * radius;
+ const defaultY = spaceCenterY + Math.sin(angleInRing) * radius;
+
+ const customPos = nodePositions.get(doc.id);
+
+ documentNodes.push({
+ id: doc.id,
+ type: "document",
+ x: customPos?.x ?? defaultX,
+ y: customPos?.y ?? defaultY,
+ data: doc,
+ size: 58,
+ color: colors.document.primary,
+ isHovered: false,
+ isDragging: draggingNodeId === doc.id,
+ } satisfies GraphNode);
+ });
+
+ spaceIndex++;
+ });
+
+ /* 2. Gentle document collision avoidance with dampening */
+ const minDocDist = LAYOUT_CONSTANTS.minDocDist;
+
+ // Reduced iterations and gentler repulsion for smoother movement
+ for (let iter = 0; iter < 2; iter++) {
+ documentNodes.forEach((nodeA) => {
+ documentNodes.forEach((nodeB) => {
+ if (nodeA.id >= nodeB.id) return;
+
+ // Only repel documents in the same space
+ const spaceA =
+ (nodeA.data as DocumentWithMemories).memoryEntries[0]
+ ?.spaceContainerTag ??
+ (nodeA.data as DocumentWithMemories).memoryEntries[0]?.spaceId ??
+ "default";
+ const spaceB =
+ (nodeB.data as DocumentWithMemories).memoryEntries[0]
+ ?.spaceContainerTag ??
+ (nodeB.data as DocumentWithMemories).memoryEntries[0]?.spaceId ??
+ "default";
+
+ if (spaceA !== spaceB) return;
+
+ const dx = nodeB.x - nodeA.x;
+ const dy = nodeB.y - nodeA.y;
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
+
+ if (dist < minDocDist) {
+ // Much gentler push with dampening
+ const push = (minDocDist - dist) / 8;
+ const dampening = Math.max(0.1, Math.min(1, dist / minDocDist));
+ const smoothPush = push * dampening * 0.5;
+
+ const nx = dx / dist;
+ const ny = dy / dist;
+ nodeA.x -= nx * smoothPush;
+ nodeA.y -= ny * smoothPush;
+ nodeB.x += nx * smoothPush;
+ nodeB.y += ny * smoothPush;
+ }
+ });
+ });
+ }
+
+ allNodes.push(...documentNodes);
+
+ /* 3. Add memories around documents WITH doc-memory connections */
+ documentNodes.forEach((docNode) => {
+ const memoryNodeMap = new Map<string, GraphNode>();
+ const doc = docNode.data as DocumentWithMemories;
+
+ doc.memoryEntries.forEach((memory, memIndex) => {
+ const memoryId = `${memory.id}`;
+ const customMemPos = nodePositions.get(memoryId);
+
+ const clusterAngle =
+ (memIndex / doc.memoryEntries.length) * Math.PI * 2;
+ const variation = Math.sin(memIndex * 2.5) * 0.3 + 0.7;
+ const distance = clusterRadius * variation;
+
+ const seed =
+ memIndex * 12345 + Number.parseInt(docNode.id.slice(0, 6), 36);
+ const offsetX = Math.sin(seed) * 0.5 * 40;
+ const offsetY = Math.cos(seed) * 0.5 * 40;
+
+ const defaultMemX =
+ docNode.x + Math.cos(clusterAngle) * distance + offsetX;
+ const defaultMemY =
+ docNode.y + Math.sin(clusterAngle) * distance + offsetY;
+
+ if (!memoryNodeMap.has(memoryId)) {
+ const memoryNode: GraphNode = {
+ id: memoryId,
+ type: "memory",
+ x: customMemPos?.x ?? defaultMemX,
+ y: customMemPos?.y ?? defaultMemY,
+ data: memory,
+ size: Math.max(
+ 32,
+ Math.min(48, (memory.memory?.length || 50) * 0.5),
+ ),
+ color: colors.memory.primary,
+ isHovered: false,
+ isDragging: draggingNodeId === memoryId,
+ };
+ memoryNodeMap.set(memoryId, memoryNode);
+ allNodes.push(memoryNode);
+ }
+
+ // Create doc-memory edge with similarity
+ allEdges.push({
+ id: `edge-${docNode.id}-${memory.id}`,
+ source: docNode.id,
+ target: memoryId,
+ similarity: 1,
+ visualProps: getConnectionVisualProps(1),
+ color: colors.connection.memory,
+ edgeType: "doc-memory",
+ });
+ });
+ });
+
+ // Build mapping of memoryId -> nodeId for version chains
+ const memNodeIdMap = new Map<string, string>();
+ allNodes.forEach((n) => {
+ if (n.type === "memory") {
+ memNodeIdMap.set((n.data as MemoryEntry).id, n.id);
+ }
+ });
+
+ // Add version-chain edges (old -> new)
+ data.documents.forEach((doc) => {
+ doc.memoryEntries.forEach((mem: MemoryEntry) => {
+ // Support both new object structure and legacy array/single parent fields
+ let parentRelations: Record<string, MemoryRelation> = {};
+
+ if (
+ mem.memoryRelations &&
+ typeof mem.memoryRelations === "object" &&
+ Object.keys(mem.memoryRelations).length > 0
+ ) {
+ parentRelations = mem.memoryRelations;
+ } else if (mem.parentMemoryId) {
+ parentRelations = {
+ [mem.parentMemoryId]: "updates" as MemoryRelation,
+ };
+ }
+ Object.entries(parentRelations).forEach(([pid, relationType]) => {
+ const fromId = memNodeIdMap.get(pid);
+ const toId = memNodeIdMap.get(mem.id);
+ if (fromId && toId) {
+ allEdges.push({
+ id: `version-${fromId}-${toId}`,
+ source: fromId,
+ target: toId,
+ similarity: 1,
+ visualProps: {
+ opacity: 0.8,
+ thickness: 1,
+ glow: 0,
+ pulseDuration: 3000,
+ },
+ // choose color based on relation type
+ color: colors.relations[relationType] ?? colors.relations.updates,
+ edgeType: "version",
+ relationType: relationType as MemoryRelation,
+ });
+ }
+ });
+ });
+ });
+
+ // Document-to-document similarity edges
+ for (let i = 0; i < filteredDocuments.length; i++) {
+ const docI = filteredDocuments[i];
+ if (!docI) continue;
+
+ for (let j = i + 1; j < filteredDocuments.length; j++) {
+ const docJ = filteredDocuments[j];
+ if (!docJ) continue;
+
+ const sim = calculateSemanticSimilarity(
+ docI.summaryEmbedding ? Array.from(docI.summaryEmbedding) : null,
+ docJ.summaryEmbedding ? Array.from(docJ.summaryEmbedding) : null,
+ );
+ if (sim > 0.725) {
+ allEdges.push({
+ id: `doc-doc-${docI.id}-${docJ.id}`,
+ source: docI.id,
+ target: docJ.id,
+ similarity: sim,
+ visualProps: getConnectionVisualProps(sim),
+ color: getMagicalConnectionColor(sim, 200),
+ edgeType: "doc-doc",
+ });
+ }
+ }
+ }
+
+ return { nodes: allNodes, edges: allEdges };
+ }, [data, selectedSpace, nodePositions, draggingNodeId]);
+}
diff --git a/packages/ui/memory-graph/hooks/use-graph-interactions.ts b/packages/ui/memory-graph/hooks/use-graph-interactions.ts
new file mode 100644
index 00000000..ec44e83e
--- /dev/null
+++ b/packages/ui/memory-graph/hooks/use-graph-interactions.ts
@@ -0,0 +1,564 @@
+"use client";
+
+import { useCallback, useRef, useState } from "react";
+import { GRAPH_SETTINGS } from "../constants";
+import type { GraphNode } from "../types";
+
+export function useGraphInteractions(
+ variant: "console" | "consumer" = "console",
+) {
+ const settings = GRAPH_SETTINGS[variant];
+
+ const [panX, setPanX] = useState(settings.initialPanX);
+ const [panY, setPanY] = useState(settings.initialPanY);
+ const [zoom, setZoom] = useState(settings.initialZoom);
+ const [isPanning, setIsPanning] = useState(false);
+ const [panStart, setPanStart] = useState({ x: 0, y: 0 });
+ const [hoveredNode, setHoveredNode] = useState<string | null>(null);
+ const [selectedNode, setSelectedNode] = useState<string | null>(null);
+ const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null);
+ const [dragStart, setDragStart] = useState({
+ x: 0,
+ y: 0,
+ nodeX: 0,
+ nodeY: 0,
+ });
+ const [nodePositions, setNodePositions] = useState<
+ Map<string, { x: number; y: number }>
+ >(new Map());
+
+ // Touch gesture state
+ const [touchState, setTouchState] = useState<{
+ touches: { id: number; x: number; y: number }[];
+ lastDistance: number;
+ lastCenter: { x: number; y: number };
+ isGesturing: boolean;
+ }>({
+ touches: [],
+ lastDistance: 0,
+ lastCenter: { x: 0, y: 0 },
+ isGesturing: false,
+ });
+
+ // Animation state for smooth transitions
+ const animationRef = useRef<number | null>(null);
+ const [isAnimating, setIsAnimating] = useState(false);
+
+ // Smooth animation helper
+ const animateToViewState = useCallback(
+ (
+ targetPanX: number,
+ targetPanY: number,
+ targetZoom: number,
+ duration = 300,
+ ) => {
+ if (animationRef.current) {
+ cancelAnimationFrame(animationRef.current);
+ }
+
+ const startPanX = panX;
+ const startPanY = panY;
+ const startZoom = zoom;
+ const startTime = Date.now();
+
+ setIsAnimating(true);
+
+ const animate = () => {
+ const elapsed = Date.now() - startTime;
+ const progress = Math.min(elapsed / duration, 1);
+
+ // Ease out cubic function for smooth transitions
+ const easeOut = 1 - (1 - progress) ** 3;
+
+ const currentPanX = startPanX + (targetPanX - startPanX) * easeOut;
+ const currentPanY = startPanY + (targetPanY - startPanY) * easeOut;
+ const currentZoom = startZoom + (targetZoom - startZoom) * easeOut;
+
+ setPanX(currentPanX);
+ setPanY(currentPanY);
+ setZoom(currentZoom);
+
+ if (progress < 1) {
+ animationRef.current = requestAnimationFrame(animate);
+ } else {
+ setIsAnimating(false);
+ animationRef.current = null;
+ }
+ };
+
+ animate();
+ },
+ [panX, panY, zoom],
+ );
+
+ // Node drag handlers
+ const handleNodeDragStart = useCallback(
+ (nodeId: string, e: React.MouseEvent, nodes?: GraphNode[]) => {
+ const node = nodes?.find((n) => n.id === nodeId);
+ if (!node) return;
+
+ setDraggingNodeId(nodeId);
+ setDragStart({
+ x: e.clientX,
+ y: e.clientY,
+ nodeX: node.x,
+ nodeY: node.y,
+ });
+ },
+ [],
+ );
+
+ const handleNodeDragMove = useCallback(
+ (e: React.MouseEvent) => {
+ if (!draggingNodeId) return;
+
+ const deltaX = (e.clientX - dragStart.x) / zoom;
+ const deltaY = (e.clientY - dragStart.y) / zoom;
+
+ const newX = dragStart.nodeX + deltaX;
+ const newY = dragStart.nodeY + deltaY;
+
+ setNodePositions((prev) =>
+ new Map(prev).set(draggingNodeId, { x: newX, y: newY }),
+ );
+ },
+ [draggingNodeId, dragStart, zoom],
+ );
+
+ const handleNodeDragEnd = useCallback(() => {
+ setDraggingNodeId(null);
+ }, []);
+
+ // Pan handlers
+ const handlePanStart = useCallback(
+ (e: React.MouseEvent) => {
+ setIsPanning(true);
+ setPanStart({ x: e.clientX - panX, y: e.clientY - panY });
+ },
+ [panX, panY],
+ );
+
+ const handlePanMove = useCallback(
+ (e: React.MouseEvent) => {
+ if (!isPanning || draggingNodeId) return;
+
+ const newPanX = e.clientX - panStart.x;
+ const newPanY = e.clientY - panStart.y;
+ setPanX(newPanX);
+ setPanY(newPanY);
+ },
+ [isPanning, panStart, draggingNodeId],
+ );
+
+ const handlePanEnd = useCallback(() => {
+ setIsPanning(false);
+ }, []);
+
+ // Zoom handlers
+ const handleWheel = useCallback(
+ (e: React.WheelEvent) => {
+ // Always prevent default to stop browser navigation
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Handle horizontal scrolling (trackpad swipe) by converting to pan
+ if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
+ // Horizontal scroll - pan the graph instead of zooming
+ const panDelta = e.deltaX * 0.5;
+ setPanX((prev) => prev - panDelta);
+ return;
+ }
+
+ // Vertical scroll - zoom behavior
+ const delta = e.deltaY > 0 ? 0.97 : 1.03;
+ const newZoom = Math.max(0.05, Math.min(3, zoom * delta));
+
+ // Get mouse position relative to the viewport
+ let mouseX = e.clientX;
+ let mouseY = e.clientY;
+
+ // Try to get the container bounds to make coordinates relative to the graph container
+ const target = e.currentTarget;
+ if (target && "getBoundingClientRect" in target) {
+ const rect = target.getBoundingClientRect();
+ mouseX = e.clientX - rect.left;
+ mouseY = e.clientY - rect.top;
+ }
+
+ // Calculate the world position of the mouse cursor
+ const worldX = (mouseX - panX) / zoom;
+ const worldY = (mouseY - panY) / zoom;
+
+ // Calculate new pan to keep the mouse position stationary
+ const newPanX = mouseX - worldX * newZoom;
+ const newPanY = mouseY - worldY * newZoom;
+
+ setZoom(newZoom);
+ setPanX(newPanX);
+ setPanY(newPanY);
+ },
+ [zoom, panX, panY],
+ );
+
+ const zoomIn = useCallback(
+ (centerX?: number, centerY?: number, animate = true) => {
+ const zoomFactor = 1.2;
+ const newZoom = Math.min(3, zoom * zoomFactor); // Increased max zoom to 3x
+
+ if (centerX !== undefined && centerY !== undefined) {
+ // Mouse-centered zoom for programmatic zoom in
+ const worldX = (centerX - panX) / zoom;
+ const worldY = (centerY - panY) / zoom;
+ const newPanX = centerX - worldX * newZoom;
+ const newPanY = centerY - worldY * newZoom;
+
+ if (animate && !isAnimating) {
+ animateToViewState(newPanX, newPanY, newZoom, 200);
+ } else {
+ setZoom(newZoom);
+ setPanX(newPanX);
+ setPanY(newPanY);
+ }
+ } else {
+ if (animate && !isAnimating) {
+ animateToViewState(panX, panY, newZoom, 200);
+ } else {
+ setZoom(newZoom);
+ }
+ }
+ },
+ [zoom, panX, panY, isAnimating, animateToViewState],
+ );
+
+ const zoomOut = useCallback(
+ (centerX?: number, centerY?: number, animate = true) => {
+ const zoomFactor = 0.8;
+ const newZoom = Math.max(0.05, zoom * zoomFactor); // Decreased min zoom to 0.05x
+
+ if (centerX !== undefined && centerY !== undefined) {
+ // Mouse-centered zoom for programmatic zoom out
+ const worldX = (centerX - panX) / zoom;
+ const worldY = (centerY - panY) / zoom;
+ const newPanX = centerX - worldX * newZoom;
+ const newPanY = centerY - worldY * newZoom;
+
+ if (animate && !isAnimating) {
+ animateToViewState(newPanX, newPanY, newZoom, 200);
+ } else {
+ setZoom(newZoom);
+ setPanX(newPanX);
+ setPanY(newPanY);
+ }
+ } else {
+ if (animate && !isAnimating) {
+ animateToViewState(panX, panY, newZoom, 200);
+ } else {
+ setZoom(newZoom);
+ }
+ }
+ },
+ [zoom, panX, panY, isAnimating, animateToViewState],
+ );
+
+ const resetView = useCallback(() => {
+ setPanX(settings.initialPanX);
+ setPanY(settings.initialPanY);
+ setZoom(settings.initialZoom);
+ setNodePositions(new Map());
+ }, [settings]);
+
+ // Auto-fit graph to viewport
+ const autoFitToViewport = useCallback(
+ (
+ nodes: GraphNode[],
+ viewportWidth: number,
+ viewportHeight: number,
+ options?: { occludedRightPx?: number; animate?: boolean },
+ ) => {
+ if (nodes.length === 0) return;
+
+ // Find the bounds of all nodes
+ let minX = Number.POSITIVE_INFINITY;
+ let maxX = Number.NEGATIVE_INFINITY;
+ let minY = Number.POSITIVE_INFINITY;
+ let maxY = Number.NEGATIVE_INFINITY;
+
+ nodes.forEach((node) => {
+ minX = Math.min(minX, node.x - node.size / 2);
+ maxX = Math.max(maxX, node.x + node.size / 2);
+ minY = Math.min(minY, node.y - node.size / 2);
+ maxY = Math.max(maxY, node.y + node.size / 2);
+ });
+
+ // Calculate the center of the content
+ const contentCenterX = (minX + maxX) / 2;
+ const contentCenterY = (minY + maxY) / 2;
+
+ // Calculate the size of the content
+ const contentWidth = maxX - minX;
+ const contentHeight = maxY - minY;
+
+ // Add padding (20% on each side)
+ const paddingFactor = 1.4;
+ const paddedWidth = contentWidth * paddingFactor;
+ const paddedHeight = contentHeight * paddingFactor;
+
+ // Account for occluded area on the right (e.g., chat panel)
+ const occludedRightPx = Math.max(0, options?.occludedRightPx ?? 0);
+ const availableWidth = Math.max(1, viewportWidth - occludedRightPx);
+
+ // Calculate the zoom needed to fit the content within available width
+ const zoomX = availableWidth / paddedWidth;
+ const zoomY = viewportHeight / paddedHeight;
+ const newZoom = Math.min(Math.max(0.05, Math.min(zoomX, zoomY)), 3);
+
+ // Calculate pan to center the content within available area
+ const availableCenterX = availableWidth / 2;
+ const newPanX = availableCenterX - contentCenterX * newZoom;
+ const newPanY = viewportHeight / 2 - contentCenterY * newZoom;
+
+ // Apply the new view (optional animation)
+ if (options?.animate) {
+ const steps = 8;
+ const durationMs = 160; // snappy
+ const intervalMs = Math.max(1, Math.floor(durationMs / steps));
+ const startZoom = zoom;
+ const startPanX = panX;
+ const startPanY = panY;
+ let i = 0;
+ const ease = (t: number) => 1 - (1 - t) ** 2; // ease-out quad
+ const timer = setInterval(() => {
+ i++;
+ const t = ease(i / steps);
+ setZoom(startZoom + (newZoom - startZoom) * t);
+ setPanX(startPanX + (newPanX - startPanX) * t);
+ setPanY(startPanY + (newPanY - startPanY) * t);
+ if (i >= steps) clearInterval(timer);
+ }, intervalMs);
+ } else {
+ setZoom(newZoom);
+ setPanX(newPanX);
+ setPanY(newPanY);
+ }
+ },
+ [zoom, panX, panY],
+ );
+
+ // Touch gesture handlers for mobile pinch-to-zoom
+ const handleTouchStart = useCallback((e: React.TouchEvent) => {
+ const touches = Array.from(e.touches).map((touch) => ({
+ id: touch.identifier,
+ x: touch.clientX,
+ y: touch.clientY,
+ }));
+
+ if (touches.length >= 2) {
+ // Start gesture with two or more fingers
+ const touch1 = touches[0]!;
+ const touch2 = touches[1]!;
+
+ const distance = Math.sqrt(
+ (touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2,
+ );
+
+ const center = {
+ x: (touch1.x + touch2.x) / 2,
+ y: (touch1.y + touch2.y) / 2,
+ };
+
+ setTouchState({
+ touches,
+ lastDistance: distance,
+ lastCenter: center,
+ isGesturing: true,
+ });
+ } else {
+ setTouchState((prev) => ({ ...prev, touches, isGesturing: false }));
+ }
+ }, []);
+
+ const handleTouchMove = useCallback(
+ (e: React.TouchEvent) => {
+ e.preventDefault();
+
+ const touches = Array.from(e.touches).map((touch) => ({
+ id: touch.identifier,
+ x: touch.clientX,
+ y: touch.clientY,
+ }));
+
+ if (touches.length >= 2 && touchState.isGesturing) {
+ const touch1 = touches[0]!;
+ const touch2 = touches[1]!;
+
+ const distance = Math.sqrt(
+ (touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2,
+ );
+
+ const center = {
+ x: (touch1.x + touch2.x) / 2,
+ y: (touch1.y + touch2.y) / 2,
+ };
+
+ // Calculate zoom change based on pinch distance change
+ const distanceChange = distance / touchState.lastDistance;
+ const newZoom = Math.max(0.05, Math.min(3, zoom * distanceChange));
+
+ // Get canvas bounds for center calculation
+ const canvas = e.currentTarget as HTMLElement;
+ const rect = canvas.getBoundingClientRect();
+ const centerX = center.x - rect.left;
+ const centerY = center.y - rect.top;
+
+ // Calculate the world position of the pinch center
+ const worldX = (centerX - panX) / zoom;
+ const worldY = (centerY - panY) / zoom;
+
+ // Calculate new pan to keep the pinch center stationary
+ const newPanX = centerX - worldX * newZoom;
+ const newPanY = centerY - worldY * newZoom;
+
+ // Calculate pan change based on center movement
+ const centerDx = center.x - touchState.lastCenter.x;
+ const centerDy = center.y - touchState.lastCenter.y;
+
+ setZoom(newZoom);
+ setPanX(newPanX + centerDx);
+ setPanY(newPanY + centerDy);
+
+ setTouchState({
+ touches,
+ lastDistance: distance,
+ lastCenter: center,
+ isGesturing: true,
+ });
+ } else if (touches.length === 1 && !touchState.isGesturing && isPanning) {
+ // Single finger pan (only if not in gesture mode)
+ const touch = touches[0]!;
+ const newPanX = touch.x - panStart.x;
+ const newPanY = touch.y - panStart.y;
+ setPanX(newPanX);
+ setPanY(newPanY);
+ }
+ },
+ [touchState, zoom, panX, panY, isPanning, panStart],
+ );
+
+ const handleTouchEnd = useCallback((e: React.TouchEvent) => {
+ const touches = Array.from(e.touches).map((touch) => ({
+ id: touch.identifier,
+ x: touch.clientX,
+ y: touch.clientY,
+ }));
+
+ if (touches.length < 2) {
+ setTouchState((prev) => ({ ...prev, touches, isGesturing: false }));
+ } else {
+ setTouchState((prev) => ({ ...prev, touches }));
+ }
+
+ if (touches.length === 0) {
+ setIsPanning(false);
+ }
+ }, []);
+
+ // Center viewport on a specific world position (with animation)
+ const centerViewportOn = useCallback(
+ (
+ worldX: number,
+ worldY: number,
+ viewportWidth: number,
+ viewportHeight: number,
+ animate = true,
+ ) => {
+ const newPanX = viewportWidth / 2 - worldX * zoom;
+ const newPanY = viewportHeight / 2 - worldY * zoom;
+
+ if (animate && !isAnimating) {
+ animateToViewState(newPanX, newPanY, zoom, 400);
+ } else {
+ setPanX(newPanX);
+ setPanY(newPanY);
+ }
+ },
+ [zoom, isAnimating, animateToViewState],
+ );
+
+ // Node interaction handlers
+ const handleNodeHover = useCallback((nodeId: string | null) => {
+ setHoveredNode(nodeId);
+ }, []);
+
+ const handleNodeClick = useCallback(
+ (nodeId: string) => {
+ setSelectedNode(selectedNode === nodeId ? null : nodeId);
+ },
+ [selectedNode],
+ );
+
+ const handleDoubleClick = useCallback(
+ (e: React.MouseEvent) => {
+ // Calculate new zoom (zoom in by 1.5x)
+ const zoomFactor = 1.5;
+ const newZoom = Math.min(3, zoom * zoomFactor);
+
+ // Get mouse position relative to the container
+ let mouseX = e.clientX;
+ let mouseY = e.clientY;
+
+ // Try to get the container bounds to make coordinates relative to the graph container
+ const target = e.currentTarget;
+ if (target && "getBoundingClientRect" in target) {
+ const rect = target.getBoundingClientRect();
+ mouseX = e.clientX - rect.left;
+ mouseY = e.clientY - rect.top;
+ }
+
+ // Calculate the world position of the clicked point
+ const worldX = (mouseX - panX) / zoom;
+ const worldY = (mouseY - panY) / zoom;
+
+ // Calculate new pan to keep the clicked point in the same screen position
+ const newPanX = mouseX - worldX * newZoom;
+ const newPanY = mouseY - worldY * newZoom;
+
+ setZoom(newZoom);
+ setPanX(newPanX);
+ setPanY(newPanY);
+ },
+ [zoom, panX, panY],
+ );
+
+ return {
+ // State
+ panX,
+ panY,
+ zoom,
+ hoveredNode,
+ selectedNode,
+ draggingNodeId,
+ nodePositions,
+ // Handlers
+ handlePanStart,
+ handlePanMove,
+ handlePanEnd,
+ handleWheel,
+ handleNodeHover,
+ handleNodeClick,
+ handleNodeDragStart,
+ handleNodeDragMove,
+ handleNodeDragEnd,
+ handleDoubleClick,
+ // Touch handlers
+ handleTouchStart,
+ handleTouchMove,
+ handleTouchEnd,
+ // Controls
+ zoomIn,
+ zoomOut,
+ resetView,
+ autoFitToViewport,
+ centerViewportOn,
+ setSelectedNode,
+ };
+}
diff --git a/packages/ui/memory-graph/index.ts b/packages/ui/memory-graph/index.ts
new file mode 100644
index 00000000..ddc3bec1
--- /dev/null
+++ b/packages/ui/memory-graph/index.ts
@@ -0,0 +1,19 @@
+// Components
+
+// Types and constants
+export {
+ colors,
+ GRAPH_SETTINGS,
+ LAYOUT_CONSTANTS,
+ POSITIONING,
+} from "./constants";
+export { GraphWebGLCanvas as GraphCanvas } from "./graph-webgl-canvas";
+// Hooks
+export { useGraphData } from "./hooks/use-graph-data";
+export { useGraphInteractions } from "./hooks/use-graph-interactions";
+export { Legend } from "./legend";
+export { LoadingIndicator } from "./loading-indicator";
+export { MemoryGraph } from "./memory-graph";
+export { NodeDetailPanel } from "./node-detail-panel";
+export { SpacesDropdown } from "./spaces-dropdown";
+export * from "./types";
diff --git a/packages/ui/memory-graph/legend.tsx b/packages/ui/memory-graph/legend.tsx
new file mode 100644
index 00000000..db2495cc
--- /dev/null
+++ b/packages/ui/memory-graph/legend.tsx
@@ -0,0 +1,311 @@
+"use client";
+
+import { useIsMobile } from "@hooks/use-mobile";
+import { cn } from "@repo/lib/utils";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@repo/ui/components/collapsible";
+import { GlassMenuEffect } from "@repo/ui/other/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";
+
+// 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");
+ };
+
+ // Use explicit classes that Tailwind can detect
+ const getPositioningClasses = () => {
+ if (variant === "console") {
+ // Both desktop and mobile use same positioning for console
+ return "bottom-4 right-4";
+ }
+ if (variant === "consumer") {
+ return isMobile ? "bottom-48 left-4" : "top-18 right-4";
+ }
+ return "";
+ };
+
+ const getMobileSize = () => {
+ if (!isMobile) return "";
+ return isExpanded ? "max-w-xs" : "w-16 h-12";
+ };
+
+ const hexagonClipPath =
+ "polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)";
+
+ // Calculate stats
+ const memoryCount = nodes.filter((n) => n.type === "memory").length;
+ const documentCount = nodes.filter((n) => n.type === "document").length;
+
+ return (
+ <div
+ className={cn(
+ "absolute z-10 rounded-xl overflow-hidden w-fit h-fit",
+ getPositioningClasses(),
+ getMobileSize(),
+ isMobile && "hidden md:block",
+ )}
+ id={id}
+ >
+ <Collapsible onOpenChange={handleToggleExpanded} open={isExpanded}>
+ {/* Glass effect background */}
+ <GlassMenuEffect rounded="rounded-xl" />
+
+ <div className="relative z-10">
+ {/* Mobile and Desktop collapsed state */}
+ {!isExpanded && (
+ <CollapsibleTrigger className="w-full h-full p-2 flex items-center justify-center hover:bg-white/5 transition-colors">
+ <div className="flex flex-col items-center gap-1">
+ <div className="text-xs text-slate-300 font-medium">?</div>
+ <ChevronUp className="w-3 h-3 text-slate-400" />
+ </div>
+ </CollapsibleTrigger>
+ )}
+
+ {/* Expanded state */}
+ {isExpanded && (
+ <>
+ {/* Header with toggle */}
+ <div className="flex items-center justify-between px-4 py-3 border-b border-slate-600/50">
+ <div className="text-sm font-medium text-slate-100">Legend</div>
+ <CollapsibleTrigger className="p-1 hover:bg-white/10 rounded">
+ <ChevronDown className="w-4 h-4 text-slate-400" />
+ </CollapsibleTrigger>
+ </div>
+
+ <CollapsibleContent>
+ <div className="text-xs text-slate-200 px-4 py-3 space-y-3">
+ {/* Stats Section */}
+ {!isLoading && (
+ <div className="space-y-2">
+ <div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
+ Statistics
+ </div>
+ <div className="space-y-1.5">
+ <div className="flex items-center gap-2">
+ <Brain className="w-3 h-3 text-blue-400" />
+ <span className="text-xs">
+ {memoryCount} memories
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ <FileText className="w-3 h-3 text-slate-300" />
+ <span className="text-xs">
+ {documentCount} documents
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ <div className="w-3 h-3 bg-gradient-to-r from-slate-400 to-blue-400 rounded-full" />
+ <span className="text-xs">
+ {edges.length} connections
+ </span>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Node Types */}
+ <div className="space-y-2">
+ <div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
+ Nodes
+ </div>
+ <div className="space-y-1.5">
+ <div className="flex items-center gap-2">
+ <div className="w-4 h-3 bg-white/8 border border-white/25 rounded-sm flex-shrink-0" />
+ <span className="text-xs">Document</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <div
+ className="w-3 h-3 bg-blue-400/10 border border-blue-400/35 flex-shrink-0"
+ style={{
+ clipPath: hexagonClipPath,
+ }}
+ />
+ <span className="text-xs">Memory (latest)</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <div
+ className="w-3 h-3 bg-blue-400/10 border border-blue-400/35 opacity-40 flex-shrink-0"
+ style={{
+ clipPath: hexagonClipPath,
+ }}
+ />
+ <span className="text-xs">Memory (older)</span>
+ </div>
+ </div>
+ </div>
+
+ {/* Status Indicators */}
+ <div className="space-y-2">
+ <div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
+ Status
+ </div>
+ <div className="space-y-1.5">
+ <div className="flex items-center gap-2">
+ <div
+ className="w-3 h-3 bg-red-500/30 border border-red-500/80 relative flex-shrink-0"
+ style={{
+ clipPath: hexagonClipPath,
+ }}
+ >
+ <div className="absolute inset-0 flex items-center justify-center text-red-400 text-xs leading-none">
+ ✕
+ </div>
+ </div>
+ <span className="text-xs">Forgotten</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <div
+ className="w-3 h-3 bg-blue-400/10 border-2 border-amber-500 flex-shrink-0"
+ style={{
+ clipPath: hexagonClipPath,
+ }}
+ />
+ <span className="text-xs">Expiring soon</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <div
+ className="w-3 h-3 bg-blue-400/10 border-2 border-emerald-500 relative flex-shrink-0"
+ style={{
+ clipPath: hexagonClipPath,
+ }}
+ >
+ <div className="absolute -top-1 -right-1 w-2 h-2 bg-emerald-500 rounded-full" />
+ </div>
+ <span className="text-xs">New memory</span>
+ </div>
+ </div>
+ </div>
+
+ {/* Connection Types */}
+ <div className="space-y-2">
+ <div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
+ Connections
+ </div>
+ <div className="space-y-1.5">
+ <div className="flex items-center gap-2">
+ <div className="w-4 h-0 border-t border-slate-400 flex-shrink-0" />
+ <span className="text-xs">Doc → Memory</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <div className="w-4 h-0 border-t-2 border-dashed border-slate-400 flex-shrink-0" />
+ <span className="text-xs">Doc similarity</span>
+ </div>
+ </div>
+ </div>
+
+ {/* Relation Types */}
+ <div className="space-y-2">
+ <div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
+ Relations
+ </div>
+ <div className="space-y-1.5">
+ {[
+ ["updates", colors.relations.updates],
+ ["extends", colors.relations.extends],
+ ["derives", colors.relations.derives],
+ ].map(([label, color]) => (
+ <div className="flex items-center gap-2" key={label}>
+ <div
+ className="w-4 h-0 border-t-2 flex-shrink-0"
+ style={{ borderColor: color }}
+ />
+ <span
+ className="text-xs capitalize"
+ style={{ color: color }}
+ >
+ {label}
+ </span>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ {/* Similarity Strength */}
+ <div className="space-y-2">
+ <div className="text-xs font-medium text-slate-300 uppercase tracking-wide">
+ Similarity
+ </div>
+ <div className="space-y-1.5">
+ <div className="flex items-center gap-2">
+ <div className="w-3 h-3 rounded-full bg-slate-400/20 flex-shrink-0" />
+ <span className="text-xs">Weak</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <div className="w-3 h-3 rounded-full bg-slate-400/60 flex-shrink-0" />
+ <span className="text-xs">Strong</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </CollapsibleContent>
+ </>
+ )}
+ </div>
+ </Collapsible>
+ </div>
+ );
+});
+
+Legend.displayName = "Legend";
diff --git a/packages/ui/memory-graph/loading-indicator.tsx b/packages/ui/memory-graph/loading-indicator.tsx
new file mode 100644
index 00000000..f4a1930a
--- /dev/null
+++ b/packages/ui/memory-graph/loading-indicator.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { cn } from "@repo/lib/utils";
+import { GlassMenuEffect } from "@repo/ui/other/glass-effect";
+import { Sparkles } from "lucide-react";
+import { memo } from "react";
+import type { LoadingIndicatorProps } from "./types";
+
+export const LoadingIndicator = memo<LoadingIndicatorProps>(
+ ({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => {
+ // Use explicit classes that Tailwind can detect
+ const getPositioningClasses = () => {
+ // Both variants use the same positioning for loadingIndicator
+ return "top-20 left-4";
+ };
+
+ if (!isLoading && !isLoadingMore) return null;
+
+ return (
+ <div
+ className={cn(
+ "absolute z-10 rounded-xl overflow-hidden",
+ getPositioningClasses(),
+ )}
+ >
+ {/* Glass effect background */}
+ <GlassMenuEffect rounded="rounded-xl" />
+
+ <div className="relative z-10 text-slate-200 px-4 py-3">
+ <div className="flex items-center gap-2">
+ <Sparkles className="w-4 h-4 animate-spin text-blue-400" />
+ <span className="text-sm">
+ {isLoading
+ ? "Loading memory graph..."
+ : `Loading more documents... (${totalLoaded})`}
+ </span>
+ </div>
+ </div>
+ </div>
+ );
+ },
+);
+
+LoadingIndicator.displayName = "LoadingIndicator";
diff --git a/packages/ui/memory-graph/memory-graph.tsx b/packages/ui/memory-graph/memory-graph.tsx
new file mode 100644
index 00000000..8c1ad3c2
--- /dev/null
+++ b/packages/ui/memory-graph/memory-graph.tsx
@@ -0,0 +1,458 @@
+"use client";
+
+import { GlassMenuEffect } from "@repo/ui/other/glass-effect";
+import { AnimatePresence } from "motion/react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { colors } from "./constants";
+import { GraphWebGLCanvas as GraphCanvas } from "./graph-webgl-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 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,
+}: 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 dummy 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="h-full flex items-center justify-center"
+ style={{ backgroundColor: colors.background.primary }}
+ >
+ <div className="rounded-xl overflow-hidden">
+ {/* Glass effect background */}
+ <GlassMenuEffect rounded="rounded-xl" />
+
+ <div className="relative z-10 text-slate-200 px-6 py-4">
+ Error loading documents: {error.message}
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div
+ className="h-full rounded-xl overflow-hidden"
+ style={{ backgroundColor: colors.background.primary }}
+ >
+ {/* Spaces selector - only shown for console */}
+ {finalShowSpacesSelector && availableSpaces.length > 0 && (
+ <div className="absolute top-4 left-4 z-10">
+ <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="w-full h-full relative overflow-hidden"
+ ref={containerRef}
+ style={{
+ touchAction: "none",
+ userSelect: "none",
+ WebkitUserSelect: "none",
+ }}
+ >
+ {(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="absolute bottom-4 left-4"
+ />
+ )}
+ </div>
+ </div>
+ );
+};
diff --git a/packages/ui/memory-graph/navigation-controls.tsx b/packages/ui/memory-graph/navigation-controls.tsx
new file mode 100644
index 00000000..b2abd67f
--- /dev/null
+++ b/packages/ui/memory-graph/navigation-controls.tsx
@@ -0,0 +1,67 @@
+"use client"
+
+import { memo } from "react"
+import type { GraphNode } from "./types"
+
+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
+ }
+
+ return (
+ <div className={`flex flex-col gap-1 ${className}`}>
+ <button
+ type="button"
+ onClick={onAutoFit}
+ className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16"
+ title="Auto-fit graph to viewport"
+ >
+ Fit
+ </button>
+ <button
+ type="button"
+ onClick={onCenter}
+ className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16"
+ title="Center view on graph"
+ >
+ Center
+ </button>
+ <div className="flex flex-col">
+ <button
+ type="button"
+ onClick={onZoomIn}
+ className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-t-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16 border-b-0"
+ title="Zoom in"
+ >
+ +
+ </button>
+ <button
+ type="button"
+ onClick={onZoomOut}
+ className="bg-black/20 backdrop-blur-sm hover:bg-black/30 border border-white/10 hover:border-white/20 rounded-b-lg p-2 text-white/70 hover:text-white transition-colors text-xs font-medium min-w-16"
+ title="Zoom out"
+ >
+ −
+ </button>
+ </div>
+ </div>
+ )
+})
+
+NavigationControls.displayName = "NavigationControls" \ No newline at end of file
diff --git a/packages/ui/memory-graph/node-detail-panel.tsx b/packages/ui/memory-graph/node-detail-panel.tsx
new file mode 100644
index 00000000..0fdc4801
--- /dev/null
+++ b/packages/ui/memory-graph/node-detail-panel.tsx
@@ -0,0 +1,268 @@
+"use client";
+
+import { cn } from "@repo/lib/utils";
+import { Badge } from "@repo/ui/components/badge";
+import { Button } from "@repo/ui/components/button";
+import { GlassMenuEffect } from "@repo/ui/other/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 "../text/heading/heading-h3-bold";
+import type {
+ DocumentWithMemories,
+ MemoryEntry,
+ NodeDetailPanelProps,
+} from "./types";
+
+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:
+ return <FileText {...iconProps} />;
+ }
+};
+
+export const NodeDetailPanel = memo<NodeDetailPanelProps>(
+ ({ node, onClose, variant = "console" }) => {
+ // Use explicit classes that Tailwind can detect
+ const getPositioningClasses = () => {
+ // Both variants use the same positioning for nodeDetail
+ return "top-4 right-4";
+ };
+
+ if (!node) return null;
+
+ const isDocument = node.type === "document";
+ const data = node.data;
+
+ return (
+ <motion.div
+ animate={{ opacity: 1 }}
+ className={cn(
+ "absolute w-80 rounded-xl overflow-hidden z-20 max-h-[80vh]",
+ getPositioningClasses(),
+ )}
+ exit={{ opacity: 0 }}
+ initial={{ opacity: 0 }}
+ transition={{
+ duration: 0.2,
+ ease: "easeInOut",
+ }}
+ >
+ {/* Glass effect background */}
+ <GlassMenuEffect rounded="rounded-xl" />
+
+ <motion.div
+ animate={{ opacity: 1 }}
+ className="relative z-10 p-4 overflow-y-auto max-h-[80vh]"
+ initial={{ opacity: 0 }}
+ transition={{ delay: 0.05, duration: 0.15 }}
+ >
+ <div className="flex items-center justify-between mb-3">
+ <div className="flex items-center gap-2">
+ {isDocument ? (
+ getDocumentIcon((data as DocumentWithMemories).type)
+ ) : (
+ <Brain className="w-5 h-5 text-blue-400" />
+ )}
+ <HeadingH3Bold className="text-slate-100">
+ {isDocument ? "Document" : "Memory"}
+ </HeadingH3Bold>
+ </div>
+ <motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
+ <Button
+ className="h-8 w-8 p-0 text-slate-300 hover:text-slate-100"
+ onClick={onClose}
+ size="sm"
+ variant="ghost"
+ >
+ <X className="w-4 h-4" />
+ </Button>
+ </motion.div>
+ </div>
+
+ <div className="space-y-3">
+ {isDocument ? (
+ <>
+ <div>
+ <span className="text-xs text-slate-400 uppercase tracking-wide">
+ Title
+ </span>
+ <p className="text-sm text-slate-200 mt-1">
+ {(data as DocumentWithMemories).title ||
+ "Untitled Document"}
+ </p>
+ </div>
+
+ {(data as DocumentWithMemories).summary && (
+ <div>
+ <span className="text-xs text-slate-400 uppercase tracking-wide">
+ Summary
+ </span>
+ <p className="text-sm text-slate-300 mt-1 line-clamp-3">
+ {(data as DocumentWithMemories).summary}
+ </p>
+ </div>
+ )}
+
+ <div>
+ <span className="text-xs text-slate-400 uppercase tracking-wide">
+ Type
+ </span>
+ <p className="text-sm text-slate-200 mt-1">
+ {formatDocumentType((data as DocumentWithMemories).type)}
+ </p>
+ </div>
+
+ <div>
+ <span className="text-xs text-slate-400 uppercase tracking-wide">
+ Memory Count
+ </span>
+ <p className="text-sm text-slate-200 mt-1">
+ {(data as DocumentWithMemories).memoryEntries.length}{" "}
+ memories
+ </p>
+ </div>
+
+ {((data as DocumentWithMemories).url ||
+ (data as DocumentWithMemories).customId) && (
+ <div>
+ <span className="text-xs text-slate-400 uppercase tracking-wide">
+ URL
+ </span>
+ <a
+ className="text-sm text-indigo-400 hover:text-indigo-300 mt-1 flex items-center gap-1"
+ 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"
+ >
+ <ExternalLink className="w-3 h-3" />
+ View Document
+ </a>
+ </div>
+ )}
+ </>
+ ) : (
+ <>
+ <div>
+ <span className="text-xs text-slate-400 uppercase tracking-wide">
+ Memory
+ </span>
+ <p className="text-sm text-slate-200 mt-1">
+ {(data as MemoryEntry).memory}
+ </p>
+ {(data as MemoryEntry).isForgotten && (
+ <Badge className="mt-2" variant="destructive">
+ Forgotten
+ </Badge>
+ )}
+ {(data as MemoryEntry).forgetAfter && (
+ <p className="text-xs text-slate-400 mt-1">
+ Expires:{" "}
+ {(data as MemoryEntry).forgetAfter
+ ? new Date(
+ (data as MemoryEntry).forgetAfter!,
+ ).toLocaleDateString()
+ : ""}{" "}
+ {"forgetReason" in data &&
+ data.forgetReason &&
+ `- ${data.forgetReason}`}
+ </p>
+ )}
+ </div>
+
+ <div>
+ <span className="text-xs text-slate-400 uppercase tracking-wide">
+ Space
+ </span>
+ <p className="text-sm text-slate-200 mt-1">
+ {(data as MemoryEntry).spaceId || "Default"}
+ </p>
+ </div>
+ </>
+ )}
+
+ <div className="pt-2 border-t border-slate-700/50">
+ <div className="flex items-center gap-4 text-xs text-slate-400">
+ <span className="flex items-center gap-1">
+ <Calendar className="w-3 h-3" />
+ {new Date(data.createdAt).toLocaleDateString()}
+ </span>
+ <span className="flex items-center gap-1">
+ <Hash className="w-3 h-3" />
+ {node.id}
+ </span>
+ </div>
+ </div>
+ </div>
+ </motion.div>
+ </motion.div>
+ );
+ },
+);
+
+NodeDetailPanel.displayName = "NodeDetailPanel";
diff --git a/packages/ui/memory-graph/spaces-dropdown.tsx b/packages/ui/memory-graph/spaces-dropdown.tsx
new file mode 100644
index 00000000..72d5f261
--- /dev/null
+++ b/packages/ui/memory-graph/spaces-dropdown.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { cn } from "@repo/lib/utils";
+import { Badge } from "@repo/ui/components/badge";
+import { ChevronDown, Eye } from "lucide-react";
+import { memo, useEffect, useRef, useState } from "react";
+import type { SpacesDropdownProps } from "./types";
+
+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="relative" ref={dropdownRef}>
+ <button
+ className={cn(
+ "flex items-center gap-3 px-4 py-3 rounded-xl border-2 border-solid border-transparent",
+ "[background-image:linear-gradient(#1a1f29,_#1a1f29),_linear-gradient(150.262deg,_#A4E8F5_0%,_#267FFA_26%,_#464646_49%,_#747474_70%,_#A4E8F5_100%)]",
+ "[background-origin:border-box] [background-clip:padding-box,_border-box]",
+ "shadow-[inset_0px_2px_1px_rgba(84,84,84,0.15)] backdrop-blur-md",
+ "transition-all duration-200 hover:shadow-[inset_0px_2px_1px_rgba(84,84,84,0.25)]",
+ "cursor-pointer min-w-60",
+ )}
+ onClick={() => setIsOpen(!isOpen)}
+ type="button"
+ >
+ <Eye className="w-4 h-4 text-slate-300" />
+ <div className="flex-1 text-left">
+ <span className="text-sm text-slate-200 font-medium">
+ {selectedSpace === "all"
+ ? "All Spaces"
+ : selectedSpace || "Select space"}
+ </span>
+ <div className="text-xs text-slate-400">
+ {selectedSpace === "all"
+ ? `${totalMemories} total memories`
+ : `${spaceMemoryCounts[selectedSpace] || 0} memories`}
+ </div>
+ </div>
+ <ChevronDown
+ className={cn(
+ "w-4 h-4 text-slate-300 transition-transform duration-200",
+ isOpen && "rotate-180",
+ )}
+ />
+ </button>
+
+ {isOpen && (
+ <div className="absolute top-full left-0 right-0 mt-2 bg-slate-900/95 backdrop-blur-md border border-slate-700/40 rounded-xl shadow-xl z-20 overflow-hidden">
+ <div className="p-1">
+ <button
+ className={cn(
+ "w-full flex items-center justify-between px-3 py-2 rounded-lg text-left transition-colors",
+ selectedSpace === "all"
+ ? "bg-blue-500/20 text-blue-300"
+ : "text-slate-200 hover:bg-slate-700/50",
+ )}
+ onClick={() => {
+ onSpaceChange("all");
+ setIsOpen(false);
+ }}
+ type="button"
+ >
+ <span className="text-sm">All Spaces</span>
+ <Badge className="bg-slate-700/50 text-slate-300 text-xs">
+ {totalMemories}
+ </Badge>
+ </button>
+ {availableSpaces.map((space) => (
+ <button
+ className={cn(
+ "w-full flex items-center justify-between px-3 py-2 rounded-lg text-left transition-colors",
+ selectedSpace === space
+ ? "bg-blue-500/20 text-blue-300"
+ : "text-slate-200 hover:bg-slate-700/50",
+ )}
+ key={space}
+ onClick={() => {
+ onSpaceChange(space);
+ setIsOpen(false);
+ }}
+ type="button"
+ >
+ <span className="text-sm truncate flex-1">{space}</span>
+ <Badge className="bg-slate-700/50 text-slate-300 text-xs ml-2">
+ {spaceMemoryCounts[space] || 0}
+ </Badge>
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ );
+ },
+);
+
+SpacesDropdown.displayName = "SpacesDropdown";
diff --git a/packages/ui/memory-graph/types.ts b/packages/ui/memory-graph/types.ts
new file mode 100644
index 00000000..a939c619
--- /dev/null
+++ b/packages/ui/memory-graph/types.ts
@@ -0,0 +1,122 @@
+import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api";
+import type { z } from "zod";
+
+export type DocumentsResponse = z.infer<
+ typeof DocumentsWithMemoriesResponseSchema
+>;
+export type DocumentWithMemories = DocumentsResponse["documents"][0];
+export type MemoryEntry = DocumentWithMemories["memoryEntries"][0];
+
+export interface GraphNode {
+ id: string;
+ type: "document" | "memory";
+ x: number;
+ y: number;
+ data: DocumentWithMemories | MemoryEntry;
+ size: number;
+ color: string;
+ isHovered: boolean;
+ isDragging: boolean;
+}
+
+export type MemoryRelation = "updates" | "extends" | "derives";
+
+export interface GraphEdge {
+ id: string;
+ source: string;
+ target: string;
+ similarity: number;
+ visualProps: {
+ opacity: number;
+ thickness: number;
+ glow: number;
+ pulseDuration: number;
+ };
+ color: string;
+ edgeType: "doc-memory" | "doc-doc" | "version";
+ relationType?: MemoryRelation;
+}
+
+export interface SpacesDropdownProps {
+ selectedSpace: string;
+ availableSpaces: string[];
+ spaceMemoryCounts: Record<string, number>;
+ onSpaceChange: (space: string) => void;
+}
+
+export interface NodeDetailPanelProps {
+ node: GraphNode | null;
+ onClose: () => void;
+ variant?: "console" | "consumer";
+}
+
+export interface GraphCanvasProps {
+ nodes: GraphNode[];
+ edges: GraphEdge[];
+ panX: number;
+ panY: number;
+ zoom: number;
+ width: number;
+ height: number;
+ onNodeHover: (nodeId: string | null) => void;
+ onNodeClick: (nodeId: string) => void;
+ onNodeDragStart: (nodeId: string, e: React.MouseEvent) => void;
+ onNodeDragMove: (e: React.MouseEvent) => void;
+ onNodeDragEnd: () => void;
+ onPanStart: (e: React.MouseEvent) => void;
+ onPanMove: (e: React.MouseEvent) => void;
+ onPanEnd: () => void;
+ onWheel: (e: React.WheelEvent) => void;
+ onDoubleClick: (e: React.MouseEvent) => void;
+ onTouchStart?: (e: React.TouchEvent) => void;
+ onTouchMove?: (e: React.TouchEvent) => void;
+ onTouchEnd?: (e: React.TouchEvent) => void;
+ draggingNodeId: string | null;
+ // Optional list of document IDs (customId or internal id) to highlight
+ highlightDocumentIds?: string[];
+}
+
+export interface MemoryGraphProps {
+ children?: React.ReactNode;
+ documents: DocumentWithMemories[];
+ isLoading: boolean;
+ isLoadingMore: boolean;
+ error: Error | null;
+ totalLoaded: number;
+ hasMore: boolean;
+ loadMoreDocuments: () => Promise<void>;
+ // App-specific props
+ showSpacesSelector?: boolean; // true for console, false for consumer
+ variant?: "console" | "consumer"; // for different positioning and styling
+ legendId?: string; // Optional ID for the legend component
+ // Optional document highlight list (document custom IDs)
+ highlightDocumentIds?: string[];
+ // Whether highlights are currently visible (e.g., chat open)
+ highlightsVisible?: boolean;
+ // Pixels occluded on the right side of the viewport (e.g., chat panel)
+ occludedRightPx?: number;
+ // Whether to auto-load more documents based on viewport visibility
+ autoLoadOnViewport?: boolean;
+}
+
+export interface LegendProps {
+ variant?: "console" | "consumer";
+ nodes?: GraphNode[];
+ edges?: GraphEdge[];
+ isLoading?: boolean;
+ hoveredNode?: string | null;
+}
+
+export interface LoadingIndicatorProps {
+ isLoading: boolean;
+ isLoadingMore: boolean;
+ totalLoaded: number;
+ variant?: "console" | "consumer";
+}
+
+export interface ControlsProps {
+ onZoomIn: () => void;
+ onZoomOut: () => void;
+ onResetView: () => void;
+ variant?: "console" | "consumer";
+}