From dfb0c05ab33cb20537002eaeb896e6b2ab35af25 Mon Sep 17 00:00:00 2001 From: nexxeln <95541290+nexxeln@users.noreply.github.com> Date: Tue, 2 Dec 2025 18:37:24 +0000 Subject: add spaces selector with search (#600) relevant files to review: \- memory-graph.tsx \- spaces-dropdown.tsx \- spaces-dropdown.css.ts --- .../src/components/canvas-common.css.ts | 4 +- .../memory-graph/src/components/graph-canvas.tsx | 662 ++++++++++----------- packages/memory-graph/src/components/legend.css.ts | 75 +-- packages/memory-graph/src/components/legend.tsx | 169 +++--- .../src/components/loading-indicator.css.ts | 16 +- .../src/components/loading-indicator.tsx | 20 +- .../src/components/memory-graph.css.ts | 18 +- .../memory-graph/src/components/memory-graph.tsx | 284 +++++---- .../src/components/navigation-controls.css.ts | 16 +- .../src/components/navigation-controls.tsx | 30 +- .../src/components/node-detail-panel.css.ts | 49 +- .../src/components/node-detail-panel.tsx | 384 ++++++------ .../src/components/spaces-dropdown.css.ts | 148 ++++- .../src/components/spaces-dropdown.tsx | 235 ++++++-- 14 files changed, 1189 insertions(+), 921 deletions(-) (limited to 'packages/memory-graph/src/components') diff --git a/packages/memory-graph/src/components/canvas-common.css.ts b/packages/memory-graph/src/components/canvas-common.css.ts index 91005488..4f4a3504 100644 --- a/packages/memory-graph/src/components/canvas-common.css.ts +++ b/packages/memory-graph/src/components/canvas-common.css.ts @@ -1,4 +1,4 @@ -import { style } from "@vanilla-extract/css"; +import { style } from "@vanilla-extract/css" /** * Canvas wrapper/container that fills its parent @@ -7,4 +7,4 @@ import { style } from "@vanilla-extract/css"; 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 index 59efa74d..ee4f5885 100644 --- a/packages/memory-graph/src/components/graph-canvas.tsx +++ b/packages/memory-graph/src/components/graph-canvas.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client" import { memo, @@ -7,15 +7,15 @@ import { useLayoutEffect, useMemo, useRef, -} from "react"; -import { colors } from "@/constants"; +} from "react" +import { colors } from "@/constants" import type { DocumentWithMemories, GraphCanvasProps, GraphNode, MemoryEntry, -} from "@/types"; -import { canvasWrapper } from "./canvas-common.css"; +} from "@/types" +import { canvasWrapper } from "./canvas-common.css" export const GraphCanvas = memo( ({ @@ -42,160 +42,160 @@ export const GraphCanvas = memo( 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); + 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(); - }, []); + 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 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); + const dx = x - screenX + const dy = y - screenY + const distance = Math.sqrt(dx * dx + dy * dy) if (distance <= nodeSize / 2) { - return node.id; + return node.id } } - return null; + return null }, [nodes, panX, panY, zoom], - ); + ) // Handle mouse events const handleMouseMove = useCallback( (e: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top - mousePos.current = { x, y }; + mousePos.current = { x, y } - const nodeId = getNodeAtPosition(x, y); + const nodeId = getNodeAtPosition(x, y) if (nodeId !== currentHoveredNode.current) { - currentHoveredNode.current = nodeId; - onNodeHover(nodeId); + currentHoveredNode.current = nodeId + onNodeHover(nodeId) } // Handle node dragging if (draggingNodeId) { - onNodeDragMove(e); + onNodeDragMove(e) } }, [getNodeAtPosition, onNodeHover, draggingNodeId, onNodeDragMove], - ); + ) const handleMouseDown = useCallback( (e: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top - const nodeId = getNodeAtPosition(x, y); + const nodeId = getNodeAtPosition(x, y) if (nodeId) { // When starting a node drag, prevent initiating pan - e.stopPropagation(); - onNodeDragStart(nodeId, e); - return; + e.stopPropagation() + onNodeDragStart(nodeId, e) + return } - onPanStart(e); + onPanStart(e) }, [getNodeAtPosition, onNodeDragStart, onPanStart], - ); + ) const handleClick = useCallback( (e: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top - const nodeId = getNodeAtPosition(x, y); + const nodeId = getNodeAtPosition(x, y) if (nodeId) { - onNodeClick(nodeId); + onNodeClick(nodeId) } }, [getNodeAtPosition, onNodeClick], - ); + ) // Professional rendering function with LOD const render = useCallback(() => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return - const ctx = canvas.getContext("2d"); - if (!ctx) return; + const ctx = canvas.getContext("2d") + if (!ctx) return - const currentTime = Date.now(); - const _elapsed = currentTime - startTimeRef.current; + const currentTime = Date.now() + const _elapsed = currentTime - startTimeRef.current // Level-of-detail optimization based on zoom - const useSimplifiedRendering = zoom < 0.3; + const useSimplifiedRendering = zoom < 0.3 // Clear canvas - ctx.clearRect(0, 0, width, height); + ctx.clearRect(0, 0, width, height) // Set high quality rendering - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = "high"; + 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; + 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(); + 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(); + 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])); + const nodeMap = new Map(nodes.map((node) => [node.id, node])) // Draw enhanced edges with sophisticated styling - ctx.lineCap = "round"; + ctx.lineCap = "round" edges.forEach((edge) => { - const sourceNode = nodeMap.get(edge.source); - const targetNode = nodeMap.get(edge.target); + 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; + 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 ( @@ -204,7 +204,7 @@ export const GraphCanvas = memo( targetX < -100 || targetX > width + 100 ) { - return; + return } // Skip very weak connections when zoomed out for performance @@ -213,368 +213,365 @@ export const GraphCanvas = memo( edge.edgeType === "doc-memory" && edge.visualProps.opacity < 0.3 ) { - return; // Skip very weak doc-memory edges when zoomed out + 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); + 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; + 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 + 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; + connectionColor = colors.connection.strong else if (edge.similarity > 0.725) - connectionColor = colors.connection.medium; + 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; + dashPattern = [] + connectionColor = edge.color || colors.relations.updates + opacity = 0.8 + lineWidth = 2 } - ctx.strokeStyle = connectionColor; - ctx.lineWidth = lineWidth; - ctx.globalAlpha = opacity; - ctx.setLineDash(dashPattern); + 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(); + 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(); + 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(); + 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 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); + : Math.min(30, distance * 0.2) - ctx.beginPath(); - ctx.moveTo(sourceX, sourceY); + ctx.beginPath() + ctx.moveTo(sourceX, sourceY) ctx.quadraticCurveTo( midX + controlOffset * (dy / distance), midY - controlOffset * (dx / distance), targetX, targetY, - ); - ctx.stroke(); + ) + 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); + 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; + 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([]); + 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.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([]); + ctx.globalAlpha = 1 + ctx.setLineDash([]) // Prepare highlight set from provided document IDs (customId or internal) - const highlightSet = new Set(highlightDocumentIds ?? []); + 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; + const screenX = node.x * zoom + panX + const screenY = node.y * zoom + panY + const nodeSize = node.size * zoom // Enhanced viewport culling - const margin = nodeSize + 50; + const margin = nodeSize + 50 if ( screenX < -margin || screenX > width + margin || screenY < -margin || screenY > height + margin ) { - return; + return } - const isHovered = currentHoveredNode.current === node.id; - const isDragging = node.isDragging; + 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" || 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; + 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; + : 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; + : colors.document.border + ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1 // Rounded rectangle with enhanced styling - const radius = useSimplifiedRendering ? 6 : 12; - ctx.beginPath(); + const radius = useSimplifiedRendering ? 6 : 12 + ctx.beginPath() ctx.roundRect( screenX - docWidth / 2, screenY - docHeight / 2, docWidth, docHeight, radius, - ); - ctx.fill(); - ctx.stroke(); + ) + 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.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(); + ) + 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.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(); + ) + ctx.stroke() + ctx.setLineDash([]) + ctx.restore() } } else { // Enhanced memory styling with status indicators - const mem = node.data as MemoryEntry; + const mem = node.data as MemoryEntry const isForgotten = mem.isForgotten || (mem.forgetAfter && - new Date(mem.forgetAfter).getTime() < Date.now()); - const isLatest = mem.isLatest; + 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; + 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; + 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; + 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)"; + 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; + borderColor = colors.status.expiring + glowColor = colors.accent.amber } else if (isNew) { - borderColor = colors.status.new; - glowColor = colors.accent.emerald; + borderColor = colors.status.new + glowColor = colors.accent.emerald } if (isDragging) { - fillColor = colors.memory.accent; - borderColor = glowColor; + fillColor = colors.memory.accent + borderColor = glowColor } else if (isHovered) { - fillColor = colors.memory.secondary; + fillColor = colors.memory.secondary } - const radius = nodeSize / 2; + const radius = nodeSize / 2 - ctx.fillStyle = fillColor; - ctx.globalAlpha = isLatest ? 1 : 0.4; - ctx.strokeStyle = borderColor; - ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5; + 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(); + 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(); + 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); + 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); + ctx.moveTo(x, y) } else { - ctx.lineTo(x, y); + ctx.lineTo(x, y) } } - ctx.closePath(); - ctx.fill(); - ctx.stroke(); + 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(); + 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); + 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); + ctx.moveTo(x, y) } else { - ctx.lineTo(x, y); + ctx.lineTo(x, y) } } - ctx.closePath(); - ctx.stroke(); + 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(); + 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.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(); + ) + 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; + node.type === "document" ? colors.document.glow : colors.memory.glow - ctx.strokeStyle = glowColor; - ctx.lineWidth = 1; - ctx.setLineDash([3, 3]); - ctx.globalAlpha = 0.6; + ctx.strokeStyle = glowColor + ctx.lineWidth = 1 + ctx.setLineDash([3, 3]) + ctx.globalAlpha = 0.6 - ctx.beginPath(); - const glowSize = nodeSize * 0.7; + ctx.beginPath() + const glowSize = nodeSize * 0.7 if (node.type === "document") { ctx.roundRect( screenX - glowSize, @@ -582,33 +579,33 @@ export const GraphCanvas = memo( glowSize * 2, glowSize * 1.4, 15, - ); + ) } else { // Hexagonal glow for memory nodes - const glowRadius = glowSize; - const sides = 6; + 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); + 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); + ctx.moveTo(x, y) } else { - ctx.lineTo(x, y); + ctx.lineTo(x, y) } } - ctx.closePath(); + ctx.closePath() } - ctx.stroke(); - ctx.setLineDash([]); + ctx.stroke() + ctx.setLineDash([]) } - }); + }) - ctx.globalAlpha = 1; - }, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds]); + ctx.globalAlpha = 1 + }, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds]) // Change-based rendering instead of continuous animation - const lastRenderParams = useRef(""); + const lastRenderParams = useRef("") // Create a render key that changes when visual state changes const renderKey = useMemo(() => { @@ -617,9 +614,9 @@ export const GraphCanvas = memo( (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}`; + .join("|") + const highlightKey = (highlightDocumentIds ?? []).join("|") + return `${nodePositions}-${edges.length}-${panX}-${panY}-${zoom}-${width}-${height}-${highlightKey}` }, [ nodes, edges.length, @@ -629,33 +626,33 @@ export const GraphCanvas = memo( width, height, highlightDocumentIds, - ]); + ]) // Only render when something actually changed useEffect(() => { if (renderKey !== lastRenderParams.current) { - lastRenderParams.current = renderKey; - render(); + lastRenderParams.current = renderKey + render() } - }, [renderKey, render]); + }, [renderKey, render]) // Cleanup any existing animation frames useEffect(() => { return () => { if (animationRef.current) { - cancelAnimationFrame(animationRef.current); + cancelAnimationFrame(animationRef.current) } - }; - }, []); + } + }, []) // Add native wheel event listener to prevent browser zoom useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; + const canvas = canvasRef.current + if (!canvas) return const handleNativeWheel = (e: WheelEvent) => { - e.preventDefault(); - e.stopPropagation(); + e.preventDefault() + e.stopPropagation() // Call the onWheel handler with a synthetic-like event // @ts-expect-error - partial WheelEvent object @@ -668,50 +665,49 @@ export const GraphCanvas = memo( nativeEvent: e, preventDefault: () => {}, stopPropagation: () => {}, - } as React.WheelEvent); - }; + } as React.WheelEvent) + } // Add listener with passive: false to ensure preventDefault works - canvas.addEventListener("wheel", handleNativeWheel, { passive: false }); + canvas.addEventListener("wheel", handleNativeWheel, { passive: false }) // Also prevent gesture events for touch devices const handleGesture = (e: Event) => { - e.preventDefault(); - }; + e.preventDefault() + } canvas.addEventListener("gesturestart", handleGesture, { passive: false, - }); + }) canvas.addEventListener("gesturechange", handleGesture, { passive: false, - }); - canvas.addEventListener("gestureend", 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]); + 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; + const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1 useLayoutEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; + 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]); + 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 ( @@ -723,22 +719,22 @@ export const GraphCanvas = memo( onMouseDown={handleMouseDown} onMouseLeave={() => { if (draggingNodeId) { - onNodeDragEnd(); + onNodeDragEnd() } else { - onPanEnd(); + onPanEnd() } }} onMouseMove={(e) => { - handleMouseMove(e); + handleMouseMove(e) if (!draggingNodeId) { - onPanMove(e); + onPanMove(e) } }} onMouseUp={() => { if (draggingNodeId) { - onNodeDragEnd(); + onNodeDragEnd() } else { - onPanEnd(); + onPanEnd() } }} onTouchStart={onTouchStart} @@ -757,8 +753,8 @@ export const GraphCanvas = memo( }} width={width} /> - ); + ) }, -); +) -GraphCanvas.displayName = "GraphCanvas"; +GraphCanvas.displayName = "GraphCanvas" diff --git a/packages/memory-graph/src/components/legend.css.ts b/packages/memory-graph/src/components/legend.css.ts index b758cf9d..823edc75 100644 --- a/packages/memory-graph/src/components/legend.css.ts +++ b/packages/memory-graph/src/components/legend.css.ts @@ -1,5 +1,5 @@ -import { style, styleVariants, globalStyle } from "@vanilla-extract/css"; -import { themeContract } from "../styles/theme.css"; +import { style, styleVariants, globalStyle } from "@vanilla-extract/css" +import { themeContract } from "../styles/theme.css" /** * Legend container base @@ -12,7 +12,7 @@ const legendContainerBase = style({ width: "fit-content", height: "fit-content", maxHeight: "calc(100vh - 2rem)", // Prevent overflow -}); +}) /** * Legend container variants for positioning @@ -59,7 +59,7 @@ export const legendContainer = styleVariants({ }, }, ], -}); +}) /** * Mobile size variants @@ -72,7 +72,7 @@ export const mobileSize = styleVariants({ width: "4rem", // w-16 height: "3rem", // h-12 }, -}); +}) /** * Legend content wrapper @@ -80,7 +80,7 @@ export const mobileSize = styleVariants({ export const legendContent = style({ position: "relative", zIndex: 10, -}); +}) /** * Collapsed trigger button @@ -99,26 +99,26 @@ export const collapsedTrigger = style({ 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 @@ -132,13 +132,13 @@ export const legendHeader = style({ 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], @@ -150,13 +150,13 @@ export const headerTrigger = style({ backgroundColor: "rgba(255, 255, 255, 0.1)", }, }, -}); +}) export const headerIcon = style({ width: "1rem", height: "1rem", color: themeContract.colors.text.muted, -}); +}) /** * Content sections @@ -168,7 +168,7 @@ export const sectionsContainer = style({ paddingRight: themeContract.space[4], paddingTop: themeContract.space[3], paddingBottom: themeContract.space[3], -}); +}) export const sectionWrapper = style({ marginTop: themeContract.space[3], @@ -177,7 +177,7 @@ export const sectionWrapper = style({ marginTop: 0, }, }, -}); +}) export const sectionTitle = style({ fontSize: themeContract.typography.fontSize.xs, @@ -186,36 +186,36 @@ export const sectionTitle = style({ 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", @@ -224,7 +224,7 @@ export const documentNode = style({ border: "1px solid rgba(255, 255, 255, 0.25)", borderRadius: themeContract.radii.sm, flexShrink: 0, -}); +}) export const memoryNode = style([ hexagon, @@ -235,14 +235,14 @@ export const memoryNode = style([ border: "1px solid rgba(147, 197, 253, 0.35)", flexShrink: 0, }, -]); +]) export const memoryNodeOlder = style([ memoryNode, { opacity: 0.4, }, -]); +]) export const forgottenNode = style([ hexagon, @@ -254,7 +254,7 @@ export const forgottenNode = style([ position: "relative", flexShrink: 0, }, -]); +]) export const forgottenIcon = style({ position: "absolute", @@ -265,7 +265,7 @@ export const forgottenIcon = style({ color: "rgb(248, 113, 113)", fontSize: themeContract.typography.fontSize.xs, lineHeight: "1", -}); +}) export const expiringNode = style([ hexagon, @@ -276,7 +276,7 @@ export const expiringNode = style([ border: "2px solid rgb(245, 158, 11)", flexShrink: 0, }, -]); +]) export const newNode = style([ hexagon, @@ -288,7 +288,7 @@ export const newNode = style([ position: "relative", flexShrink: 0, }, -]); +]) export const newBadge = style({ position: "absolute", @@ -298,28 +298,28 @@ export const newBadge = style({ 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", @@ -327,7 +327,7 @@ export const weakSimilarity = style({ borderRadius: themeContract.radii.full, background: "rgba(148, 163, 184, 0.2)", flexShrink: 0, -}); +}) export const strongSimilarity = style({ width: "0.75rem", @@ -335,11 +335,12 @@ export const strongSimilarity = style({ 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))", + 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 index 16f588a9..db06da10 100644 --- a/packages/memory-graph/src/components/legend.tsx +++ b/packages/memory-graph/src/components/legend.tsx @@ -1,44 +1,44 @@ -"use client"; +"use client" -import { useIsMobile } from "@/hooks/use-mobile"; +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"; +} 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=/`; -}; + 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(";"); + 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); + 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; -}; + return null +} interface ExtendedLegendProps extends LegendProps { - id?: string; - nodes?: GraphNode[]; - edges?: GraphEdge[]; - isLoading?: boolean; + id?: string + nodes?: GraphNode[] + edges?: GraphEdge[] + isLoading?: boolean } export const Legend = memo(function Legend({ @@ -48,55 +48,57 @@ export const Legend = memo(function Legend({ edges = [], isLoading = false, }: ExtendedLegendProps) { - const isMobile = useIsMobile(); - const [isExpanded, setIsExpanded] = useState(true); - const [isInitialized, setIsInitialized] = useState(false); + 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"); + const savedState = getCookie("legendCollapsed") if (savedState === "true") { - setIsExpanded(false); + setIsExpanded(false) } else if (savedState === "false") { - setIsExpanded(true); + setIsExpanded(true) } else { // Default: collapsed on mobile, expanded on desktop - setIsExpanded(!isMobile); + setIsExpanded(!isMobile) } - setIsInitialized(true); + setIsInitialized(true) } - }, [isInitialized, isMobile]); + }, [isInitialized, isMobile]) // Save to cookie when state changes const handleToggleExpanded = (expanded: boolean) => { - setIsExpanded(expanded); - setCookie("legendCollapsed", expanded ? "false" : "true"); - }; + 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.consoleMobile + : styles.legendContainer.consoleDesktop } - return isMobile ? styles.legendContainer.consumerMobile : styles.legendContainer.consumerDesktop; - }; + 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 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(); + const containerClass = + isMobile && !isExpanded + ? `${getContainerClass()} ${styles.mobileSize.collapsed}` + : isMobile + ? `${getContainerClass()} ${styles.mobileSize.expanded}` + : getContainerClass() return ( -
+
{/* Glass effect background */} @@ -128,18 +130,22 @@ export const Legend = memo(function Legend({ {/* Stats Section */} {!isLoading && (
-
- Statistics -
+
Statistics
- + {memoryCount} memories
- + {documentCount} documents @@ -156,9 +162,7 @@ export const Legend = memo(function Legend({ {/* Node Types */}
-
- Nodes -
+
Nodes
@@ -166,26 +170,26 @@ export const Legend = memo(function Legend({
- Memory (latest) + + Memory (latest) +
- Memory (older) + + Memory (older) +
{/* Status Indicators */}
-
- Status -
+
Status
-
- ✕ -
+
Forgotten
@@ -204,9 +208,7 @@ export const Legend = memo(function Legend({ {/* Connection Types */}
-
- Connections -
+
Connections
@@ -214,16 +216,16 @@ export const Legend = memo(function Legend({
- Doc similarity + + Doc similarity +
{/* Relation Types */}
-
- Relations -
+
Relations
{[ ["updates", colors.relations.updates], @@ -237,7 +239,10 @@ export const Legend = memo(function Legend({ /> {label} @@ -248,9 +253,7 @@ export const Legend = memo(function Legend({ {/* Similarity Strength */}
-
- Similarity -
+
Similarity
@@ -269,7 +272,7 @@ export const Legend = memo(function Legend({
- ); -}); + ) +}) -Legend.displayName = "Legend"; +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 index 09010f28..4aac7cfd 100644 --- a/packages/memory-graph/src/components/loading-indicator.css.ts +++ b/packages/memory-graph/src/components/loading-indicator.css.ts @@ -1,6 +1,6 @@ -import { style } from "@vanilla-extract/css"; -import { themeContract } from "../styles/theme.css"; -import { animations } from "../styles"; +import { style } from "@vanilla-extract/css" +import { themeContract } from "../styles/theme.css" +import { animations } from "../styles" /** * Loading indicator container @@ -13,7 +13,7 @@ export const loadingContainer = style({ overflow: "hidden", top: "5.5rem", // Below spaces dropdown (~88px) left: themeContract.space[4], -}); +}) /** * Content wrapper @@ -26,7 +26,7 @@ export const loadingContent = style({ paddingRight: themeContract.space[4], paddingTop: themeContract.space[3], paddingBottom: themeContract.space[3], -}); +}) /** * Flex container for icon and text @@ -35,7 +35,7 @@ export const loadingFlex = style({ display: "flex", alignItems: "center", gap: themeContract.space[2], -}); +}) /** * Spinning icon @@ -45,11 +45,11 @@ export const loadingIcon = style({ 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 index be31430b..bbb2312c 100644 --- a/packages/memory-graph/src/components/loading-indicator.tsx +++ b/packages/memory-graph/src/components/loading-indicator.tsx @@ -1,20 +1,20 @@ -"use client"; +"use client" -import { GlassMenuEffect } from "@/ui/glass-effect"; -import { Sparkles } from "lucide-react"; -import { memo } from "react"; -import type { LoadingIndicatorProps } from "@/types"; +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"; +} from "./loading-indicator.css" export const LoadingIndicator = memo( ({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => { - if (!isLoading && !isLoadingMore) return null; + if (!isLoading && !isLoadingMore) return null return (
@@ -33,8 +33,8 @@ export const LoadingIndicator = memo(
- ); + ) }, -); +) -LoadingIndicator.displayName = "LoadingIndicator"; +LoadingIndicator.displayName = "LoadingIndicator" diff --git a/packages/memory-graph/src/components/memory-graph.css.ts b/packages/memory-graph/src/components/memory-graph.css.ts index f5b38273..1726b741 100644 --- a/packages/memory-graph/src/components/memory-graph.css.ts +++ b/packages/memory-graph/src/components/memory-graph.css.ts @@ -1,5 +1,5 @@ -import { style } from "@vanilla-extract/css"; -import { themeContract } from "../styles/theme.css"; +import { style } from "@vanilla-extract/css" +import { themeContract } from "../styles/theme.css" /** * Error state container @@ -10,12 +10,12 @@ export const errorContainer = style({ 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", @@ -25,7 +25,7 @@ export const errorContent = style({ paddingRight: themeContract.space[6], paddingTop: themeContract.space[4], paddingBottom: themeContract.space[4], -}); +}) /** * Main graph container @@ -37,7 +37,7 @@ export const mainContainer = style({ borderRadius: themeContract.radii.xl, overflow: "hidden", backgroundColor: themeContract.colors.background.primary, -}); +}) /** * Spaces selector positioning @@ -48,7 +48,7 @@ export const spacesSelectorContainer = style({ top: themeContract.space[4], left: themeContract.space[4], zIndex: 15, // Above base elements, below loading/panels -}); +}) /** * Graph canvas container @@ -61,7 +61,7 @@ export const graphContainer = style({ touchAction: "none", userSelect: "none", WebkitUserSelect: "none", -}); +}) /** * Navigation controls positioning @@ -72,4 +72,4 @@ export const navControlsContainer = style({ 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 index 21d4a08f..8f356d2f 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -1,21 +1,21 @@ -"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 { injectStyles } from "@/lib/inject-styles"; -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 { defaultTheme } from "@/styles/theme.css"; - -import type { MemoryGraphProps } from "@/types"; +"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 { injectStyles } from "@/lib/inject-styles" +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 { defaultTheme } from "@/styles/theme.css" + +import type { MemoryGraphProps } from "@/types" export const MemoryGraph = ({ children, @@ -34,23 +34,45 @@ export const MemoryGraph = ({ occludedRightPx = 0, autoLoadOnViewport = true, themeClassName, + selectedSpace: externalSelectedSpace, + onSpaceChange: externalOnSpaceChange, + memoryLimit, + isExperimental, }: MemoryGraphProps) => { // Inject styles on first render (client-side only) useEffect(() => { - injectStyles(); - }, []); + injectStyles() + }, []) // Derive totalLoaded from documents if not provided - const effectiveTotalLoaded = totalLoaded ?? documents.length; + const effectiveTotalLoaded = totalLoaded ?? documents.length // No-op for loadMoreDocuments if not provided - const effectiveLoadMoreDocuments = loadMoreDocuments ?? (async () => {}); + const effectiveLoadMoreDocuments = loadMoreDocuments ?? (async () => {}) // Derive showSpacesSelector from variant if not explicitly provided // console variant shows spaces selector, consumer variant hides it - const finalShowSpacesSelector = showSpacesSelector ?? (variant === "console"); + const finalShowSpacesSelector = showSpacesSelector ?? variant === "console" - const [selectedSpace, setSelectedSpace] = useState("all"); - const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); - const containerRef = useRef(null); + // Internal state for controlled/uncontrolled pattern + const [internalSelectedSpace, setInternalSelectedSpace] = + useState("all") + + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) + const containerRef = useRef(null) + + // Use external state if provided, otherwise use internal state + const selectedSpace = externalSelectedSpace ?? internalSelectedSpace + + // Handle space change + const handleSpaceChange = useCallback( + (spaceId: string) => { + if (externalOnSpaceChange) { + externalOnSpaceChange(spaceId) + } else { + setInternalSelectedSpace(spaceId) + } + }, + [externalOnSpaceChange], + ) // Create data object with pagination to satisfy type requirements const data = useMemo(() => { @@ -64,8 +86,8 @@ export const MemoryGraph = ({ totalPages: 1, }, } - : null; - }, [documents]); + : null + }, [documents]) // Graph interactions with variant-specific settings const { @@ -95,7 +117,7 @@ export const MemoryGraph = ({ centerViewportOn, zoomIn, zoomOut, - } = useGraphInteractions(variant); + } = useGraphInteractions(variant) // Graph data const { nodes, edges } = useGraphData( @@ -103,14 +125,13 @@ export const MemoryGraph = ({ selectedSpace, nodePositions, draggingNodeId, - ); + memoryLimit, + ) // Auto-fit once per unique highlight set to show the full graph for context - const lastFittedHighlightKeyRef = useRef(""); + const lastFittedHighlightKeyRef = useRef("") useEffect(() => { - const highlightKey = highlightsVisible - ? highlightDocumentIds.join("|") - : ""; + const highlightKey = highlightsVisible ? highlightDocumentIds.join("|") : "" if ( highlightKey && highlightKey !== lastFittedHighlightKeyRef.current && @@ -121,8 +142,8 @@ export const MemoryGraph = ({ autoFitToViewport(nodes, containerSize.width, containerSize.height, { occludedRightPx, animate: true, - }); - lastFittedHighlightKeyRef.current = highlightKey; + }) + lastFittedHighlightKeyRef.current = highlightKey } }, [ highlightsVisible, @@ -132,10 +153,10 @@ export const MemoryGraph = ({ nodes.length, occludedRightPx, autoFitToViewport, - ]); + ]) // Auto-fit graph when component mounts or nodes change significantly - const hasAutoFittedRef = useRef(false); + const hasAutoFittedRef = useRef(false) useEffect(() => { // Only auto-fit once when we have nodes and container size if ( @@ -147,90 +168,85 @@ export const MemoryGraph = ({ // 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); + autoFitToViewport(nodes, containerSize.width, containerSize.height) + hasAutoFittedRef.current = true + }, 100) + + return () => clearTimeout(timer) } - }, [ - nodes, - containerSize.width, - containerSize.height, - autoFitToViewport, - ]); + }, [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; + hasAutoFittedRef.current = false } - }, [nodes.length]); + }, [nodes.length]) // Extract unique spaces from memories and calculate counts const { availableSpaces, spaceMemoryCounts } = useMemo(() => { - if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} }; + if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} } - const spaceSet = new Set(); - const counts: Record = {}; + 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; - }); - }); + 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]); + } + }, [data]) // Handle container resize useEffect(() => { const updateSize = () => { if (containerRef.current) { - const newWidth = containerRef.current.clientWidth; - const newHeight = containerRef.current.clientHeight; - + 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 { width: newWidth, height: newHeight } } - return prev; - }); + 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); - + 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); + const resizeObserver = new ResizeObserver(updateSize) if (containerRef.current) { - resizeObserver.observe(containerRef.current); + resizeObserver.observe(containerRef.current) } - + return () => { - clearTimeout(timer); - window.removeEventListener("resize", updateSize); - resizeObserver.disconnect(); - }; - }, []); + 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(nodeId, e, nodes) }, [handleNodeDragStart, nodes], - ); + ) // Navigation callbacks const handleCenter = useCallback(() => { @@ -239,35 +255,50 @@ export const MemoryGraph = ({ 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) + 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) { + 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]) + }, [ + 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]); + 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(() => { @@ -277,7 +308,7 @@ export const MemoryGraph = ({ !data?.documents || data.documents.length === 0 ) - return; + return // Calculate viewport bounds const viewportBounds = { @@ -285,26 +316,26 @@ export const MemoryGraph = ({ 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; + const visibilityRatio = visibleDocuments.length / data.documents.length if (visibilityRatio >= 0.8) { - effectiveLoadMoreDocuments(); + effectiveLoadMoreDocuments() } }, [ isLoadingMore, @@ -317,35 +348,35 @@ export const MemoryGraph = ({ containerSize.height, nodes, effectiveLoadMoreDocuments, - ]); + ]) // Throttled version to avoid excessive checks - const lastLoadCheckRef = useRef(0); + const lastLoadCheckRef = useRef(0) const throttledCheckAndLoadMore = useCallback(() => { - const now = Date.now(); + const now = Date.now() if (now - lastLoadCheckRef.current > 1000) { // Check at most once per second - lastLoadCheckRef.current = now; - checkAndLoadMore(); + lastLoadCheckRef.current = now + checkAndLoadMore() } - }, [checkAndLoadMore]); + }, [checkAndLoadMore]) // Monitor viewport changes to trigger loading useEffect(() => { - if (!autoLoadOnViewport) return; - throttledCheckAndLoadMore(); - }, [throttledCheckAndLoadMore, autoLoadOnViewport]); + if (!autoLoadOnViewport) return + throttledCheckAndLoadMore() + }, [throttledCheckAndLoadMore, autoLoadOnViewport]) // Initial load trigger when graph is first rendered useEffect(() => { - if (!autoLoadOnViewport) return; + 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 + throttledCheckAndLoadMore() + }, 500) // Small delay to allow initial layout } - }, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]); + }, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]) if (error) { return ( @@ -359,17 +390,19 @@ export const MemoryGraph = ({
- ); + ) } return ( -
- {/* Spaces selector - only shown for console */} - {finalShowSpacesSelector && availableSpaces.length > 0 && ( +
+ {/* Spaces selector - only shown for console variant */} + {variant === "console" && availableSpaces.length > 0 && (
@@ -411,11 +444,8 @@ export const MemoryGraph = ({ )} {/* Graph container */} -
- {(containerSize.width > 0 && containerSize.height > 0) && ( +
+ {containerSize.width > 0 && containerSize.height > 0 && ( 0 && ( zoomIn(containerSize.width / 2, containerSize.height / 2)} - onZoomOut={() => zoomOut(containerSize.width / 2, containerSize.height / 2)} + onZoomIn={() => + zoomIn(containerSize.width / 2, containerSize.height / 2) + } + onZoomOut={() => + zoomOut(containerSize.width / 2, containerSize.height / 2) + } onAutoFit={handleAutoFit} nodes={nodes} className={styles.navControlsContainer} @@ -455,5 +489,5 @@ export const MemoryGraph = ({ )}
- ); -}; + ) +} diff --git a/packages/memory-graph/src/components/navigation-controls.css.ts b/packages/memory-graph/src/components/navigation-controls.css.ts index 3a4094bd..c17f09b4 100644 --- a/packages/memory-graph/src/components/navigation-controls.css.ts +++ b/packages/memory-graph/src/components/navigation-controls.css.ts @@ -1,5 +1,5 @@ -import { style } from "@vanilla-extract/css"; -import { themeContract } from "../styles/theme.css"; +import { style } from "@vanilla-extract/css" +import { themeContract } from "../styles/theme.css" /** * Navigation controls container @@ -8,7 +8,7 @@ export const navContainer = style({ display: "flex", flexDirection: "column", gap: themeContract.space[1], -}); +}) /** * Base button styles for navigation controls @@ -34,12 +34,12 @@ const navButtonBase = style({ color: "rgba(255, 255, 255, 1)", }, }, -}); +}) /** * Standard navigation button */ -export const navButton = navButtonBase; +export const navButton = navButtonBase /** * Zoom controls container @@ -47,7 +47,7 @@ export const navButton = navButtonBase; export const zoomContainer = style({ display: "flex", flexDirection: "column", -}); +}) /** * Zoom in button (top rounded) @@ -61,7 +61,7 @@ export const zoomInButton = style([ borderBottomRightRadius: 0, borderBottom: 0, }, -]); +]) /** * Zoom out button (bottom rounded) @@ -74,4 +74,4 @@ export const zoomOutButton = style([ 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 index 19caa888..ce25aa5b 100644 --- a/packages/memory-graph/src/components/navigation-controls.tsx +++ b/packages/memory-graph/src/components/navigation-controls.tsx @@ -1,33 +1,33 @@ -"use client"; +"use client" -import { memo } from "react"; -import type { GraphNode } from "@/types"; +import { memo } from "react" +import type { GraphNode } from "@/types" import { navContainer, navButton, zoomContainer, zoomInButton, zoomOutButton, -} from "./navigation-controls.css"; +} from "./navigation-controls.css" interface NavigationControlsProps { - onCenter: () => void; - onZoomIn: () => void; - onZoomOut: () => void; - onAutoFit: () => void; - nodes: GraphNode[]; - className?: string; + 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 null } const containerClassName = className ? `${navContainer} ${className}` - : navContainer; + : navContainer return (
@@ -66,8 +66,8 @@ export const NavigationControls = memo(
- ); + ) }, -); +) -NavigationControls.displayName = "NavigationControls"; +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 index a3c30e06..5429e2bd 100644 --- a/packages/memory-graph/src/components/node-detail-panel.css.ts +++ b/packages/memory-graph/src/components/node-detail-panel.css.ts @@ -1,5 +1,5 @@ -import { style } from "@vanilla-extract/css"; -import { themeContract } from "../styles/theme.css"; +import { style } from "@vanilla-extract/css" +import { themeContract } from "../styles/theme.css" /** * Main container (positioned absolutely) @@ -16,8 +16,9 @@ export const container = style({ 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)", -}); + 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 @@ -28,7 +29,7 @@ export const content = style({ padding: themeContract.space[4], overflowY: "auto", maxHeight: "80vh", -}); +}) /** * Header section @@ -38,25 +39,25 @@ export const header = style({ 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", @@ -69,12 +70,12 @@ export const closeButton = style({ color: themeContract.colors.text.primary, }, }, -}); +}) export const closeIcon = style({ width: "1rem", height: "1rem", -}); +}) /** * Content sections @@ -83,22 +84,22 @@ export const sections = style({ display: "flex", flexDirection: "column", gap: themeContract.space[3], -}); +}) -export const section = style({}); +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, @@ -108,7 +109,7 @@ export const sectionValueTruncated = style({ display: "-webkit-box", WebkitLineClamp: 3, WebkitBoxOrient: "vertical", -}); +}) export const link = style({ fontSize: themeContract.typography.fontSize.sm, @@ -125,22 +126,22 @@ export const link = style({ 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) @@ -148,7 +149,7 @@ export const expiryText = style({ 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", @@ -156,15 +157,15 @@ export const metadata = style({ 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 index e2ae0133..b022364d 100644 --- a/packages/memory-graph/src/components/node-detail-panel.tsx +++ b/packages/memory-graph/src/components/node-detail-panel.tsx @@ -1,11 +1,11 @@ -"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"; +"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, @@ -18,249 +18,233 @@ import { 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"; +} 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"; + 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(" "); -}; + .join(" ") +} const getDocumentIcon = (type: string) => { - const iconProps = { className: "w-5 h-5 text-slate-300" }; + const iconProps = { className: "w-5 h-5 text-slate-300" } switch (type) { case "google_doc": - return ; + return case "google_sheet": - return ; + return case "google_slide": - return ; + return case "google_drive": - return ; + return case "notion": case "notion_doc": - return ; + return case "word": case "microsoft_word": - return ; + return case "excel": case "microsoft_excel": - return ; + return case "powerpoint": case "microsoft_powerpoint": - return ; + return case "onenote": case "microsoft_onenote": - return ; + return case "onedrive": - return ; + return case "pdf": - return ; + return default: - {/*@ts-ignore */} - return ; + { + /*@ts-ignore */ + } + return } -}; - -export const NodeDetailPanel = memo( - function NodeDetailPanel({ node, onClose, variant = "console" }: NodeDetailPanelProps) { - if (!node) return null; - - const isDocument = node.type === "document"; - const data = node.data; +} + +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 ( + + {/* Glass effect background */} + - return ( - {/* Glass effect background */} - - - -
-
- {isDocument ? ( - getDocumentIcon((data as DocumentWithMemories).type ?? "") - ) : ( +
+
+ {isDocument ? ( + getDocumentIcon((data as DocumentWithMemories).type ?? "") + ) : ( // @ts-ignore - - )} - - {isDocument ? "Document" : "Memory"} - -
- - - + + )} + {isDocument ? "Document" : "Memory"}
+ + + +
+ +
+ {isDocument ? ( + <> +
+ Title +

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

+
-
- {isDocument ? ( - <> + {(data as DocumentWithMemories).summary && (
- - Title - -

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

+ {(data as DocumentWithMemories).summary}

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

- {(data as DocumentWithMemories).summary} -

-
- )} +
+ Type +

+ {formatDocumentType( + (data as DocumentWithMemories).type ?? "", + )} +

+
-
- - Type - -

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

-
+
+ Memory Count +

+ {(data as DocumentWithMemories).memoryEntries.length} memories +

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

+ {(data as MemoryEntry).memory} +

+ {(data as MemoryEntry).isForgotten && ( + + Forgotten + )} - - ) : ( - <> -
- - Memory - -

- {(data as MemoryEntry).memory} + {(data as MemoryEntry).forgetAfter && ( +

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

- {(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 as any).forgetReason - ? `- ${(data as any).forgetReason}` - : null)} -

- )} -
- -
- - Space - -

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

-
- - )} + )} +
-
-
- - {/* @ts-ignore */} - - {new Date(data.createdAt).toLocaleDateString()} - - - {/* @ts-ignore */} - - {node.id} - +
+ Space +

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

+ + )} + +
+
+ + {/* @ts-ignore */} + + {new Date(data.createdAt).toLocaleDateString()} + + + {/* @ts-ignore */} + + {node.id} +
- +
- ); - }, -); + + ) +}) -NodeDetailPanel.displayName = "NodeDetailPanel"; +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 index d7af2258..58fa73e4 100644 --- a/packages/memory-graph/src/components/spaces-dropdown.css.ts +++ b/packages/memory-graph/src/components/spaces-dropdown.css.ts @@ -1,12 +1,17 @@ -import { style } from "@vanilla-extract/css"; -import { themeContract } from "../styles/theme.css"; +import { style, keyframes } from "@vanilla-extract/css" +import { themeContract } from "../styles/theme.css" + +const spin = keyframes({ + "0%": { transform: "rotate(0deg)" }, + "100%": { transform: "rotate(360deg)" }, +}) /** * Dropdown container */ export const container = style({ position: "relative", -}); +}) /** * Main trigger button with gradient border effect @@ -37,40 +42,40 @@ export const trigger = style({ 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 @@ -90,11 +95,97 @@ export const dropdown = style({ "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], -}); +}) + +/** + * Search container and form + */ +export const searchContainer = style({ + display: "flex", + alignItems: "center", + gap: themeContract.space[2], + padding: themeContract.space[2], + borderBottom: "1px solid rgba(71, 85, 105, 0.4)", // slate-700/40 +}) + +export const searchForm = style({ + flex: 1, + display: "flex", + alignItems: "center", + gap: themeContract.space[2], +}) + +export const searchButton = style({ + color: themeContract.colors.text.muted, + padding: themeContract.space[1], + cursor: "pointer", + border: "none", + background: "transparent", + transition: themeContract.transitions.normal, + + selectors: { + "&:hover:not(:disabled)": { + color: themeContract.colors.text.secondary, + }, + "&:disabled": { + opacity: 0.5, + cursor: "not-allowed", + }, + }, +}) + +export const searchIcon = style({ + width: "1rem", + height: "1rem", +}) + +export const searchInput = style({ + flex: 1, + backgroundColor: "transparent", + fontSize: themeContract.typography.fontSize.sm, + color: themeContract.colors.text.secondary, + border: "none", + outline: "none", + + "::placeholder": { + color: themeContract.colors.text.muted, + }, +}) + +export const searchSpinner = style({ + width: "1rem", + height: "1rem", + borderRadius: "50%", + border: "2px solid rgba(148, 163, 184, 0.3)", // slate-400 with opacity + borderTopColor: "rgb(148, 163, 184)", // slate-400 + animation: `${spin} 1s linear infinite`, +}) + +export const searchClearButton = style({ + color: themeContract.colors.text.muted, + cursor: "pointer", + border: "none", + background: "transparent", + transition: themeContract.transitions.normal, + + selectors: { + "&:hover": { + color: themeContract.colors.text.secondary, + }, + }, +}) + +/** + * Dropdown list container + */ +export const dropdownList = style({ + maxHeight: "16rem", // max-h-64 + overflowY: "auto", +}) /** * Dropdown items @@ -114,7 +205,7 @@ const dropdownItemBase = style({ cursor: "pointer", border: "none", background: "transparent", -}); +}) export const dropdownItem = style([ dropdownItemBase, @@ -127,7 +218,7 @@ export const dropdownItem = style([ }, }, }, -]); +]) export const dropdownItemActive = style([ dropdownItemBase, @@ -135,12 +226,20 @@ export const dropdownItemActive = style([ backgroundColor: "rgba(59, 130, 246, 0.2)", // blue-500/20 color: "rgb(147, 197, 253)", // blue-300 }, -]); +]) + +export const dropdownItemHighlighted = style([ + dropdownItemBase, + { + backgroundColor: "rgba(51, 65, 85, 0.7)", // slate-700/70 + color: themeContract.colors.text.secondary, + }, +]) export const dropdownItemLabel = style({ fontSize: themeContract.typography.fontSize.sm, flex: 1, -}); +}) export const dropdownItemLabelTruncate = style({ fontSize: themeContract.typography.fontSize.sm, @@ -148,11 +247,24 @@ export const dropdownItemLabelTruncate = style({ 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], -}); +}) + +/** + * Empty state message + */ +export const emptyState = style({ + paddingLeft: themeContract.space[3], + paddingRight: themeContract.space[3], + paddingTop: themeContract.space[2], + paddingBottom: themeContract.space[2], + fontSize: themeContract.typography.fontSize.sm, + color: themeContract.colors.text.muted, + textAlign: "center", +}) diff --git a/packages/memory-graph/src/components/spaces-dropdown.tsx b/packages/memory-graph/src/components/spaces-dropdown.tsx index b70059f5..d8a56fe3 100644 --- a/packages/memory-graph/src/components/spaces-dropdown.tsx +++ b/packages/memory-graph/src/components/spaces-dropdown.tsx @@ -1,15 +1,19 @@ -"use client"; +"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"; +import { Badge } from "@/ui/badge" +import { ChevronDown, Eye, Search, X } 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( ({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => { - const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); + const [isOpen, setIsOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const dropdownRef = useRef(null) + const searchInputRef = useRef(null) + const itemRefs = useRef>(new Map()) // Close dropdown when clicking outside useEffect(() => { @@ -18,38 +22,115 @@ export const SpacesDropdown = memo( dropdownRef.current && !dropdownRef.current.contains(event.target as Node) ) { - setIsOpen(false); + setIsOpen(false) } - }; + } - document.addEventListener("mousedown", handleClickOutside); - return () => - document.removeEventListener("mousedown", handleClickOutside); - }, []); + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + // Focus search input when dropdown opens + useEffect(() => { + if (isOpen && searchInputRef.current) { + searchInputRef.current.focus() + } + }, [isOpen]) + + // Clear search query and reset highlighted index when dropdown closes + useEffect(() => { + if (!isOpen) { + setSearchQuery("") + setHighlightedIndex(-1) + } + }, [isOpen]) + + // Filter spaces based on search query (client-side) + const filteredSpaces = searchQuery + ? availableSpaces.filter((space) => + space.toLowerCase().includes(searchQuery.toLowerCase()), + ) + : availableSpaces const totalMemories = Object.values(spaceMemoryCounts).reduce( (sum, count) => sum + count, 0, - ); + ) + + // Total items including "Latest" option + const totalItems = filteredSpaces.length + 1 + + // Scroll highlighted item into view + useEffect(() => { + if (highlightedIndex >= 0 && highlightedIndex < totalItems) { + const element = itemRefs.current.get(highlightedIndex) + if (element) { + element.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }) + } + } + }, [highlightedIndex, totalItems]) + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) return + + switch (e.key) { + case "ArrowDown": + e.preventDefault() + setHighlightedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0)) + break + case "ArrowUp": + e.preventDefault() + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1)) + break + case "Enter": + e.preventDefault() + if (highlightedIndex === 0) { + onSpaceChange("all") + setIsOpen(false) + } else if ( + highlightedIndex > 0 && + highlightedIndex <= filteredSpaces.length + ) { + const selectedSpace = filteredSpaces[highlightedIndex - 1] + if (selectedSpace) { + onSpaceChange(selectedSpace) + setIsOpen(false) + } + } + break + case "Escape": + e.preventDefault() + setIsOpen(false) + break + } + } return ( -
+
- {availableSpaces.map((space) => ( + {/* Search Input - Always show for filtering */} +
+
+ {/*@ts-ignore */} + + setSearchQuery(e.target.value)} + placeholder="Search spaces..." + ref={searchInputRef} + type="text" + value={searchQuery} + /> + {searchQuery && ( + + )} +
+
+ + {/* Spaces List */} +
+ {/* Always show "Latest" option */} - ))} + + {/* Show all spaces, filtered by search query */} + {filteredSpaces.length > 0 + ? filteredSpaces.map((space, index) => { + const itemIndex = index + 1 + return ( + + ) + }) + : searchQuery && ( +
+ No spaces found matching "{searchQuery}" +
+ )} +
)}
- ); + ) }, -); +) -SpacesDropdown.displayName = "SpacesDropdown"; +SpacesDropdown.displayName = "SpacesDropdown" -- cgit v1.2.3