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 e60dee68..00000000 --- a/packages/ui/memory-graph/controls.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client"; - -import { cn } from "@lib/utils"; -import { Button } from "@ui/components/button"; -import { GlassMenuEffect } from "@ui/other/glass-effect"; -import { Move, ZoomIn, ZoomOut } from "lucide-react"; -import { memo } from "react"; -import type { ControlsProps } from "./types"; - -export const Controls = memo( - ({ 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 ( -
- {/* Glass effect background */} - - -
-
- - - -
-
-
- ); - }, -); - -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( - ({ - 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(null); - const animationRef = useRef(0); - const startTimeRef = useRef(Date.now()); - const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); - const currentHoveredNode = useRef(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(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(""); - - // 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 ( - { - 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( - ({ - nodes, - edges, - panX, - panY, - zoom, - width, - height, - onNodeHover, - onNodeClick, - onNodeDragStart, - onNodeDragMove, - onNodeDragEnd, - onPanStart, - onPanMove, - onPanEnd, - onWheel, - onDoubleClick, - onTouchStart, - onTouchMove, - onTouchEnd, - draggingNodeId, - }) => { - const containerRef = useRef(null); - const isPanningRef = useRef(false); - const currentHoveredRef = useRef(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(null); - - // Throttled wheel handling ------------------------------------------- - const pendingWheelDeltaRef = useRef<{ dx: number; dy: number }>({ - dx: 0, - dy: 0, - }); - const wheelRafRef = useRef(null); - // Removed bitmap caching due to black-screen issues – throttle already boosts zoom performance - - // Persistent graphics refs - const gridG = useRef(null); - const edgesG = useRef(null); - const docsG = useRef(null); - const memsG = useRef(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) => { - 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) => { - 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) => { - 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) => { - 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) => { - 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 ( -
- onDoubleClick?.(ev as unknown as React.MouseEvent) - } - onKeyDown={(ev) => { - if (ev.key === "Enter") - handleClick(ev as unknown as React.MouseEvent); - }} - 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", - }} - > - - {/* Grid background (not affected by world transform) */} - {}} /> - - {/* World container that pans/zooms as a single transform */} - - {/* Edges */} - {}} /> - - {/* Documents */} - {}} /> - - {/* Memories */} - {}} /> - - -
- ); - }, -); - -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 beb18ca6..00000000 --- a/packages/ui/memory-graph/hooks/use-graph-data.ts +++ /dev/null @@ -1,302 +0,0 @@ -"use client"; - -import { - calculateSemanticSimilarity, - getConnectionVisualProps, - getMagicalConnectionColor, -} from "@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, - 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, - ), - })); - - // Group documents by space for better clustering - const documentsBySpace = new Map(); - 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(); - 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(); - 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 = {}; - - 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(null); - const [selectedNode, setSelectedNode] = useState(null); - const [draggingNodeId, setDraggingNodeId] = useState(null); - const [dragStart, setDragStart] = useState({ - x: 0, - y: 0, - nodeX: 0, - nodeY: 0, - }); - const [nodePositions, setNodePositions] = useState< - Map - >(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(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 b8fd6628..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 "@lib/utils"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@ui/components/collapsible"; -import { GlassMenuEffect } from "@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 ( -
- - {/* Glass effect background */} - - -
- {/* Mobile and Desktop collapsed state */} - {!isExpanded && ( - -
-
?
- -
-
- )} - - {/* Expanded state */} - {isExpanded && ( - <> - {/* Header with toggle */} -
-
Legend
- - - -
- - -
- {/* Stats Section */} - {!isLoading && ( -
-
- Statistics -
-
-
- - - {memoryCount} memories - -
-
- - - {documentCount} documents - -
-
-
- - {edges.length} connections - -
-
-
- )} - - {/* Node Types */} -
-
- Nodes -
-
-
-
- Document -
-
-
- Memory (latest) -
-
-
- Memory (older) -
-
-
- - {/* Status Indicators */} -
-
- Status -
-
-
-
-
- ✕ -
-
- Forgotten -
-
-
- Expiring soon -
-
-
-
-
- New memory -
-
-
- - {/* Connection Types */} -
-
- Connections -
-
-
-
- Doc → Memory -
-
-
- Doc similarity -
-
-
- - {/* Relation Types */} -
-
- Relations -
-
- {[ - ["updates", colors.relations.updates], - ["extends", colors.relations.extends], - ["derives", colors.relations.derives], - ].map(([label, color]) => ( -
-
- - {label} - -
- ))} -
-
- - {/* Similarity Strength */} -
-
- Similarity -
-
-
-
- Weak -
-
-
- Strong -
-
-
-
- - - )} -
- -
- ); -}); - -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 884acdfd..00000000 --- a/packages/ui/memory-graph/loading-indicator.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client"; - -import { cn } from "@lib/utils"; -import { GlassMenuEffect } from "@ui/other/glass-effect"; -import { Sparkles } from "lucide-react"; -import { memo } from "react"; -import type { LoadingIndicatorProps } from "./types"; - -export const LoadingIndicator = memo( - ({ 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 ( -
- {/* Glass effect background */} - - -
-
- - - {isLoading - ? "Loading memory graph..." - : `Loading more documents... (${totalLoaded})`} - -
-
-
- ); - }, -); - -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 b0d0ae77..00000000 --- a/packages/ui/memory-graph/memory-graph.tsx +++ /dev/null @@ -1,471 +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("all"); - const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); - const containerRef = useRef(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(""); - 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(); - const counts: Record = {}; - - 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 ( -
-
- {/* Glass effect background */} - - -
- Error loading documents: {error.message} -
-
-
- ); - } - - return ( -
- {/* Spaces selector - only shown for console */} - {finalShowSpacesSelector && availableSpaces.length > 0 && ( -
- -
- )} - - {/* Loading indicator */} - - - {/* Legend */} - - - {/* Node detail panel */} - - {selectedNodeData && ( - setSelectedNode(null)} - variant={variant} - /> - )} - - - {/* Show welcome screen when no memories exist */} - {!isLoading && - (!data || !nodes.some((n) => n.type === "document")) && - children} - - {/* Graph container */} -
- {containerSize.width > 0 && containerSize.height > 0 && ( - - )} - - {/* Navigation controls */} - {containerSize.width > 0 && ( - - 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" - /> - )} -
-
- ); -}; diff --git a/packages/ui/memory-graph/navigation-controls.tsx b/packages/ui/memory-graph/navigation-controls.tsx deleted file mode 100644 index afd98a0f..00000000 --- a/packages/ui/memory-graph/navigation-controls.tsx +++ /dev/null @@ -1,62 +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( - ({ onCenter, onZoomIn, onZoomOut, onAutoFit, nodes, className = "" }) => { - if (nodes.length === 0) { - return null; - } - - return ( -
- - -
- - -
-
- ); - }, -); - -NavigationControls.displayName = "NavigationControls"; 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 b5f4aedb..00000000 --- a/packages/ui/memory-graph/node-detail-panel.tsx +++ /dev/null @@ -1,268 +0,0 @@ -"use client"; - -import { cn } from "@lib/utils"; -import { Badge } from "@ui/components/badge"; -import { Button } from "@ui/components/button"; -import { GlassMenuEffect } from "@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 ; - case "google_sheet": - return ; - case "google_slide": - return ; - case "google_drive": - return ; - case "notion": - case "notion_doc": - return ; - case "word": - case "microsoft_word": - return ; - case "excel": - case "microsoft_excel": - return ; - case "powerpoint": - case "microsoft_powerpoint": - return ; - case "onenote": - case "microsoft_onenote": - return ; - case "onedrive": - return ; - case "pdf": - return ; - default: - return ; - } -}; - -export const NodeDetailPanel = memo( - ({ 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 ( - - {/* Glass effect background */} - - - -
-
- {isDocument ? ( - getDocumentIcon((data as DocumentWithMemories).type) - ) : ( - - )} - - {isDocument ? "Document" : "Memory"} - -
- - - -
- -
- {isDocument ? ( - <> -
- - Title - -

- {(data as DocumentWithMemories).title || - "Untitled Document"} -

-
- - {(data as DocumentWithMemories).summary && ( -
- - Summary - -

- {(data as DocumentWithMemories).summary} -

-
- )} - -
- - Type - -

- {formatDocumentType((data as DocumentWithMemories).type)} -

-
- -
- - Memory Count - -

- {(data as DocumentWithMemories).memoryEntries.length}{" "} - memories -

-
- - {((data as DocumentWithMemories).url || - (data as DocumentWithMemories).customId) && ( - - )} - - ) : ( - <> -
- - Memory - -

- {(data as MemoryEntry).memory} -

- {(data as MemoryEntry).isForgotten && ( - - Forgotten - - )} - {(data as MemoryEntry).forgetAfter && ( -

- Expires:{" "} - {(data as MemoryEntry).forgetAfter - ? new Date( - (data as MemoryEntry).forgetAfter!, - ).toLocaleDateString() - : ""}{" "} - {"forgetReason" in data && - data.forgetReason && - `- ${data.forgetReason}`} -

- )} -
- -
- - Space - -

- {(data as MemoryEntry).spaceId || "Default"} -

-
- - )} - -
-
- - - {new Date(data.createdAt).toLocaleDateString()} - - - - {node.id} - -
-
-
-
-
- ); - }, -); - -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 a3052558..00000000 --- a/packages/ui/memory-graph/spaces-dropdown.tsx +++ /dev/null @@ -1,120 +0,0 @@ -"use client"; - -import { cn } from "@lib/utils"; -import { Badge } from "@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( - ({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => { - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(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 ( -
- - - {isOpen && ( -
-
- - {availableSpaces.map((space) => ( - - ))} -
-
- )} -
- ); - }, -); - -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; - 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; - // 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"; -} diff --git a/packages/ui/pages/login.tsx b/packages/ui/pages/login.tsx deleted file mode 100644 index e16071cd..00000000 --- a/packages/ui/pages/login.tsx +++ /dev/null @@ -1,483 +0,0 @@ -"use client"; - -import { signIn } from "@lib/auth"; -import { usePostHog } from "@lib/posthog"; -import { LogoFull } from "@repo/ui/assets/Logo"; -import { TextSeparator } from "@repo/ui/components/text-separator"; -import { ExternalAuthButton } from "@ui/button/external-auth"; -import { Badge } from "@ui/components/badge"; -import { Button } from "@ui/components/button"; -import { - Carousel, - CarouselContent, - CarouselItem, -} from "@ui/components/carousel"; -import { LabeledInput } from "@ui/input/labeled-input"; -import { HeadingH1Medium } from "@ui/text/heading/heading-h1-medium"; -import { HeadingH3Medium } from "@ui/text/heading/heading-h3-medium"; -import { Label1Regular } from "@ui/text/label/label-1-regular"; -import { Title1Bold } from "@ui/text/title/title-1-bold"; -import Autoplay from "embla-carousel-autoplay"; -import Image from "next/image"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; - -export function LoginPage({ - heroText = "The unified memory API for the AI era.", - texts = [ - "Stop building retrieval from scratch.", - "Trusted by Open Source, enterprise and developers.", - ], -}) { - const [email, setEmail] = useState(""); - const [submittedEmail, setSubmittedEmail] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [isLoadingEmail, setIsLoadingEmail] = useState(false); - const [error, setError] = useState(null); - const [lastUsedMethod, setLastUsedMethod] = useState(null); - const router = useRouter(); - - const posthog = usePostHog(); - - const params = useSearchParams(); - - // Get redirect URL from query params - const redirectUrl = params.get("redirect"); - - // Create callback URL that includes redirect parameter if provided - const getCallbackURL = () => { - const origin = window.location.origin; - let finalUrl: URL; - - if (redirectUrl) { - try { - finalUrl = new URL(redirectUrl, origin); - } catch { - finalUrl = new URL(origin); - } - } else { - finalUrl = new URL(origin); - } - - finalUrl.searchParams.set("extension-auth-success", "true"); - return finalUrl.toString(); - }; - - // Load last used method from localStorage on mount - useEffect(() => { - const savedMethod = localStorage.getItem("supermemory-last-login-method"); - setLastUsedMethod(savedMethod); - }, []); - - // Record the pending login method (will be committed after successful auth) - function setPendingLoginMethod(method: string) { - try { - localStorage.setItem("supermemory-pending-login-method", method); - localStorage.setItem( - "supermemory-pending-login-timestamp", - String(Date.now()), - ); - } catch {} - } - - function isNetworkError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - const message = error.message.toLowerCase(); - return ( - message.includes("load failed") || - message.includes("networkerror") || - message.includes("failed to fetch") || - message.includes("network request failed") - ); - } - - function getErrorMessage(error: unknown): string { - if (isNetworkError(error)) { - return "Network error. Please check your connection and try again."; - } - if (error instanceof Error) { - return error.message; - } - return "An unexpected error occurred. Please try again."; - } - - // If we land back on this page with an error, clear any pending marker - useEffect(() => { - if (params.get("error")) { - try { - localStorage.removeItem("supermemory-pending-login-method"); - localStorage.removeItem("supermemory-pending-login-timestamp"); - } catch {} - } - }, [params]); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setIsLoading(true); - setIsLoadingEmail(true); - setError(null); - - // Track login attempt - posthog.capture("login_attempt", { - method: "magic_link", - email_domain: email.split("@")[1] || "unknown", - }); - - try { - await signIn.magicLink({ - callbackURL: getCallbackURL(), - email, - }); - setSubmittedEmail(email); - setPendingLoginMethod("magic_link"); - // Track successful magic link send - posthog.capture("login_magic_link_sent", { - email_domain: email.split("@")[1] || "unknown", - }); - } catch (error) { - console.error(error); - - // Track login failure - posthog.capture("login_failed", { - method: "magic_link", - error: error instanceof Error ? error.message : "Unknown error", - email_domain: email.split("@")[1] || "unknown", - is_network_error: isNetworkError(error), - }); - - setError(getErrorMessage(error)); - setIsLoading(false); - setIsLoadingEmail(false); - return; - } - - setIsLoading(false); - setIsLoadingEmail(false); - }; - - const handleSubmitToken = async (event: React.FormEvent) => { - event.preventDefault(); - setIsLoading(true); - - const formData = new FormData(event.currentTarget); - const token = formData.get("token") as string; - const callbackURL = getCallbackURL(); - router.push( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`, - ); - }; - - return ( -
- - - - supermemory abstract 2d -
- - {texts[0]} - -
-
- - supermemory abstract 3d -
- - {texts[1]} - -
-
-
-
- - {submittedEmail ? ( -
-
- Almost there! - - Click the magic link we've sent to{" "} - {submittedEmail}. - -
- - - -
- - - - -
- ) : ( -
-
- - Welcome to {" "} - - - - {heroText} - -
- - {params.get("error") && ( -
- Error: {params.get("error")}. Please try again! -
- )} - -
-
- { - setEmail(e.target.value); - error && setError(null); - }, - required: true, - value: email, - }} - inputType="email" - label="Email" - /> - -
- - {lastUsedMethod === "magic_link" && ( -
- - Last used - -
- )} -
-
-
- - {process.env.NEXT_PUBLIC_HOST_ID === "supermemory" || - !process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED || - !process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? ( - - ) : null} - -
- {process.env.NEXT_PUBLIC_HOST_ID === "supermemory" || - !process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED ? ( -
- - Google - - - - - - } - authProvider="Google" - className="w-full" - disabled={isLoading} - onClick={() => { - if (isLoading) return; - setIsLoading(true); - setError(null); - posthog.capture("login_attempt", { - method: "social", - provider: "google", - }); - setPendingLoginMethod("google"); - signIn - .social({ - callbackURL: getCallbackURL(), - provider: "google", - }) - .catch((error) => { - console.error("Google login error:", error); - posthog.capture("login_failed", { - method: "social", - provider: "google", - error: - error instanceof Error - ? error.message - : "Unknown error", - is_network_error: isNetworkError(error), - }); - setError(getErrorMessage(error)); - }) - .finally(() => { - setIsLoading(false); - }); - }} - /> - {lastUsedMethod === "google" && ( -
- - Last used - -
- )} -
- ) : null} - {process.env.NEXT_PUBLIC_HOST_ID === "supermemory" || - !process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED ? ( -
- - Github - - - - - - - - - - } - authProvider="Github" - className="w-full" - disabled={isLoading} - onClick={() => { - if (isLoading) return; - setIsLoading(true); - setError(null); - posthog.capture("login_attempt", { - method: "social", - provider: "github", - }); - setPendingLoginMethod("github"); - signIn - .social({ - callbackURL: getCallbackURL(), - provider: "github", - }) - .catch((error) => { - console.error("GitHub login error:", error); - posthog.capture("login_failed", { - method: "social", - provider: "github", - error: - error instanceof Error - ? error.message - : "Unknown error", - is_network_error: isNetworkError(error), - }); - setError(getErrorMessage(error)); - }) - .finally(() => { - setIsLoading(false); - }); - }} - /> - {lastUsedMethod === "github" && ( -
- - Last used - -
- )} -
- ) : null} -
- - - By continuing, you agree to our{" "} - - - Terms - {" "} - and{" "} - - Privacy Policy - - . - - -
- )} -
- ); -} diff --git a/packages/validation/connection.ts b/packages/validation/connection.ts deleted file mode 100644 index e7bc8352..00000000 --- a/packages/validation/connection.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { z } from "zod" -import { ConnectionProviderEnum } from "./schemas" - -export const providers = ConnectionProviderEnum -export type Provider = z.infer - -const BaseMetadataSchema = (provider: T) => - z.object({ - provider, - }) - -export const NotionMetadataSchema = BaseMetadataSchema( - z.literal("notion"), -).extend({ - email: z.string().email().optional(), - ownerId: z.string(), - workspaceIcon: z.string().optional(), - workspaceId: z.string(), - workspaceName: z.string(), -}) -export type NotionMetadata = z.infer - -export const GoogleDriveMetadataSchema = BaseMetadataSchema( - z.literal("google-drive"), -).extend({ - pageToken: z.number(), - webhookChannelId: z.string().optional(), - webhookExpiration: z.number().optional(), - webhookResourceId: z.string().optional(), -}) -export type GoogleDriveMetadata = z.infer - -export const OneDriveMetadataSchema = BaseMetadataSchema( - z.literal("onedrive"), -).extend({ - deltaLink: z.string().optional(), - lastRenewalCheck: z.number().optional(), - webhookClientState: z.string().optional(), - webhookExpiration: z.number().optional(), - webhookSubscriptionId: z.string().optional(), -}) -export type OneDriveMetadata = z.infer - -export const ConnectionMetadataSchema = z.discriminatedUnion("provider", [ - NotionMetadataSchema, - GoogleDriveMetadataSchema, - OneDriveMetadataSchema, -]) -export type ConnectionMetadata = T extends "notion" - ? NotionMetadata - : T extends "google-drive" - ? GoogleDriveMetadata - : T extends "onedrive" - ? OneDriveMetadata - : never - -export function isNotionMetadata( - metadata: unknown, -): metadata is NotionMetadata { - return NotionMetadataSchema.safeParse(metadata).success -} - -export function isGoogleDriveMetadata( - metadata: unknown, -): metadata is GoogleDriveMetadata { - return GoogleDriveMetadataSchema.safeParse(metadata).success -} - -export function isOneDriveMetadata( - metadata: unknown, -): metadata is OneDriveMetadata { - return OneDriveMetadataSchema.safeParse(metadata).success -} - -export const ConnectionStateSchema = z.object({ - createdAt: z.number(), - org: z.string(), - provider: providers, - userId: z.string(), -}) -export type ConnectionState = z.infer - -export const TokenDataSchema = z.object({ - // Only used for Notion connections since they don't support refresh tokens - accessToken: z.string().optional(), - createdAt: z.number(), - documentLimit: z.number().optional(), - email: z.string().email().optional(), - expiresAt: z.number().optional(), - metadata: ConnectionMetadataSchema.optional(), - refreshToken: z.string().optional(), - userId: z.string().optional(), -}) -export type TokenData = z.infer - -export const NotionTokenResponseSchema = z.object({ - access_token: z.string(), - bot_id: z.string().optional(), - duplicated_template_id: z.string().nullable().optional(), - owner: z.union([ - z.object({ - type: z.literal("user"), - user: z.object({ - avatar_url: z.string().url().optional(), - id: z.string(), - name: z.string().optional(), - object: z.literal("user"), - person: z - .object({ - email: z.string().email().optional(), - }) - .optional(), - type: z.literal("person"), - }), - }), - z.object({ - type: z.literal("workspace"), - workspace: z.literal(true), - }), - ]), - request_id: z.string().optional(), - token_type: z.literal("bearer"), - workspace_icon: z.string().optional(), - workspace_id: z.string(), - workspace_name: z.string(), -}) -export type NotionTokenResponse = z.infer - -export const GoogleDriveTokenResponseSchema = z.object({ - access_token: z.string(), - expires_in: z.number(), - refresh_token: z.string().optional(), - scope: z.string(), - token_type: z.literal("Bearer"), -}) -export type GoogleDriveTokenResponse = z.infer< - typeof GoogleDriveTokenResponseSchema -> - -export const OneDriveTokenResponseSchema = z.object({ - access_token: z.string(), - expires_in: z.number(), - refresh_token: z.string().optional(), - scope: z.string(), - token_type: z.literal("Bearer"), -}) -export type OneDriveTokenResponse = z.infer - -export const NotionConfigSchema = z.object({ - clientId: z.string(), - clientSecret: z.string(), - endpoints: z.object({ - authorize: z.string().url(), - token: z.string().url(), - }), - scopes: z.array(z.string()), -}) -export type NotionConfig = z.infer - -export const ConnectionQuerySchema = z.object({ - id: z.string(), - redirectUrl: z.string().optional(), -}) - -export const GoogleDrivePageTokenResponseSchema = z.object({ - startPageToken: z.union([z.string(), z.number()]), -}) - -export const GoogleDriveWatchResponseSchema = z.object({ - expiration: z.string(), - id: z.string(), - resourceId: z.string(), -}) - -export const OneDriveSubscriptionResponseSchema = z.object({ - changeType: z.string(), - clientState: z.string(), - expirationDateTime: z.string(), - id: z.string(), - notificationUrl: z.string(), - resource: z.string(), -}) - -export const GoogleUserInfoResponseSchema = z.object({ - email: z.string().email(), -}) - -export const MicrosoftUserInfoResponseSchema = z.object({ - mail: z.string().optional(), - userPrincipalName: z.string().optional(), -})