aboutsummaryrefslogtreecommitdiff
path: root/packages/ui/memory-graph
diff options
context:
space:
mode:
authornexxeln <[email protected]>2025-12-04 18:54:40 +0000
committernexxeln <[email protected]>2025-12-04 18:54:40 +0000
commit7a2f2cb99c50038e932f898838ab60715c4e47d6 (patch)
tree3bc23f5810349ec848a4c8cf71fd0a310efbd4e2 /packages/ui/memory-graph
parentchore(@supermemory/tools): fix the documentation of withSupermemory (#601) (diff)
downloadsupermemory-use-memory-graph-package.tar.xz
supermemory-use-memory-graph-package.zip
use latest graph and remove old graph (#604)use-memory-graph-package
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, 0 insertions, 4000 deletions
diff --git a/packages/ui/memory-graph/constants.ts b/packages/ui/memory-graph/constants.ts
deleted file mode 100644
index 23193601..00000000
--- a/packages/ui/memory-graph/constants.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-// 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
deleted file mode 100644
index 899d239a..00000000
--- a/packages/ui/memory-graph/controls.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-"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
deleted file mode 100644
index c4623c85..00000000
--- a/packages/ui/memory-graph/graph-canvas.tsx
+++ /dev/null
@@ -1,762 +0,0 @@
-"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
deleted file mode 100644
index af13eefc..00000000
--- a/packages/ui/memory-graph/graph-webgl-canvas.tsx
+++ /dev/null
@@ -1,794 +0,0 @@
-"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
deleted file mode 100644
index 3e9fa5cc..00000000
--- a/packages/ui/memory-graph/hooks/use-graph-data.ts
+++ /dev/null
@@ -1,304 +0,0 @@
-"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
deleted file mode 100644
index ec44e83e..00000000
--- a/packages/ui/memory-graph/hooks/use-graph-interactions.ts
+++ /dev/null
@@ -1,564 +0,0 @@
-"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
deleted file mode 100644
index ddc3bec1..00000000
--- a/packages/ui/memory-graph/index.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-// 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
deleted file mode 100644
index db2495cc..00000000
--- a/packages/ui/memory-graph/legend.tsx
+++ /dev/null
@@ -1,311 +0,0 @@
-"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
deleted file mode 100644
index f4a1930a..00000000
--- a/packages/ui/memory-graph/loading-indicator.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-"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
deleted file mode 100644
index 8c1ad3c2..00000000
--- a/packages/ui/memory-graph/memory-graph.tsx
+++ /dev/null
@@ -1,458 +0,0 @@
-"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
deleted file mode 100644
index b2abd67f..00000000
--- a/packages/ui/memory-graph/navigation-controls.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-"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
deleted file mode 100644
index 0fdc4801..00000000
--- a/packages/ui/memory-graph/node-detail-panel.tsx
+++ /dev/null
@@ -1,268 +0,0 @@
-"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
deleted file mode 100644
index 72d5f261..00000000
--- a/packages/ui/memory-graph/spaces-dropdown.tsx
+++ /dev/null
@@ -1,120 +0,0 @@
-"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
deleted file mode 100644
index a939c619..00000000
--- a/packages/ui/memory-graph/types.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-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";
-}