diff options
| author | nexxeln <[email protected]> | 2025-11-19 18:57:55 +0000 |
|---|---|---|
| committer | nexxeln <[email protected]> | 2025-11-19 18:57:56 +0000 |
| commit | 5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb (patch) | |
| tree | 60336fd37b41e3597065729d098877483eba73b6 /packages/memory-graph/src/components | |
| parent | Fix: Prevent multiple prompts while AI response is generated (fixes #538) (#583) (diff) | |
| download | supermemory-5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb.tar.xz supermemory-5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb.zip | |
package the graph (#563)shoubhit/eng-358-packaging-graph-component
includes:
- a package that contains a MemoryGraph component which handles fetching data and rendering the graph
- a playground to test the package
problems:
- the bundle size is huge
- the styles are kinda broken? we are using [https://www.npmjs.com/package/vite-plugin-libgi-inject-css](https://www.npmjs.com/package/vite-plugin-lib-inject-css) to inject the styles

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