aboutsummaryrefslogtreecommitdiff
path: root/packages/memory-graph/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/memory-graph/src')
-rw-r--r--packages/memory-graph/src/api-types.ts122
-rw-r--r--packages/memory-graph/src/assets/icons.tsx24
-rw-r--r--packages/memory-graph/src/components/canvas-common.css.ts4
-rw-r--r--packages/memory-graph/src/components/graph-canvas.tsx662
-rw-r--r--packages/memory-graph/src/components/legend.css.ts75
-rw-r--r--packages/memory-graph/src/components/legend.tsx169
-rw-r--r--packages/memory-graph/src/components/loading-indicator.css.ts16
-rw-r--r--packages/memory-graph/src/components/loading-indicator.tsx20
-rw-r--r--packages/memory-graph/src/components/memory-graph.css.ts18
-rw-r--r--packages/memory-graph/src/components/memory-graph.tsx284
-rw-r--r--packages/memory-graph/src/components/navigation-controls.css.ts16
-rw-r--r--packages/memory-graph/src/components/navigation-controls.tsx30
-rw-r--r--packages/memory-graph/src/components/node-detail-panel.css.ts49
-rw-r--r--packages/memory-graph/src/components/node-detail-panel.tsx384
-rw-r--r--packages/memory-graph/src/components/spaces-dropdown.css.ts148
-rw-r--r--packages/memory-graph/src/components/spaces-dropdown.tsx235
-rw-r--r--packages/memory-graph/src/constants.ts8
-rw-r--r--packages/memory-graph/src/hooks/use-graph-data.ts231
-rw-r--r--packages/memory-graph/src/hooks/use-graph-interactions.ts466
-rw-r--r--packages/memory-graph/src/index.tsx16
-rw-r--r--packages/memory-graph/src/lib/inject-styles.ts34
-rw-r--r--packages/memory-graph/src/styles/animations.css.ts26
-rw-r--r--packages/memory-graph/src/styles/effects.css.ts16
-rw-r--r--packages/memory-graph/src/styles/global.css.ts26
-rw-r--r--packages/memory-graph/src/styles/index.ts18
-rw-r--r--packages/memory-graph/src/styles/sprinkles.css.ts16
-rw-r--r--packages/memory-graph/src/styles/theme.css.ts6
-rw-r--r--packages/memory-graph/src/types.ts182
-rw-r--r--packages/memory-graph/src/ui/badge.css.ts16
-rw-r--r--packages/memory-graph/src/ui/badge.tsx23
-rw-r--r--packages/memory-graph/src/ui/button.css.ts12
-rw-r--r--packages/memory-graph/src/ui/button.tsx22
-rw-r--r--packages/memory-graph/src/ui/collapsible.tsx12
-rw-r--r--packages/memory-graph/src/ui/glass-effect.css.ts10
-rw-r--r--packages/memory-graph/src/ui/glass-effect.tsx11
-rw-r--r--packages/memory-graph/src/ui/heading.css.ts6
-rw-r--r--packages/memory-graph/src/ui/heading.tsx12
37 files changed, 1856 insertions, 1569 deletions
diff --git a/packages/memory-graph/src/api-types.ts b/packages/memory-graph/src/api-types.ts
index 7742e39f..0ebc86ee 100644
--- a/packages/memory-graph/src/api-types.ts
+++ b/packages/memory-graph/src/api-types.ts
@@ -2,78 +2,78 @@
// These mirror the API response types from @repo/validation/api
export interface MemoryEntry {
- id: string;
- customId?: string | null;
- documentId: string;
- content: string | null;
- summary?: string | null;
- title?: string | null;
- url?: string | null;
- type?: string | null;
- metadata?: Record<string, string | number | boolean> | null;
- embedding?: number[] | null;
- embeddingModel?: string | null;
- tokenCount?: number | null;
- createdAt: string | Date;
- updatedAt: string | Date;
+ id: string
+ customId?: string | null
+ documentId: string
+ content: string | null
+ summary?: string | null
+ title?: string | null
+ url?: string | null
+ type?: string | null
+ metadata?: Record<string, string | number | boolean> | null
+ embedding?: number[] | null
+ embeddingModel?: string | null
+ tokenCount?: number | null
+ createdAt: string | Date
+ updatedAt: string | Date
// Fields from join relationship
- sourceAddedAt?: Date | null;
- sourceRelevanceScore?: number | null;
- sourceMetadata?: Record<string, unknown> | null;
- spaceContainerTag?: string | null;
+ sourceAddedAt?: Date | null
+ sourceRelevanceScore?: number | null
+ sourceMetadata?: Record<string, unknown> | null
+ spaceContainerTag?: string | null
// Version chain fields
- updatesMemoryId?: string | null;
- nextVersionId?: string | null;
- relation?: "updates" | "extends" | "derives" | null;
+ updatesMemoryId?: string | null
+ nextVersionId?: string | null
+ relation?: "updates" | "extends" | "derives" | null
// Memory status fields
- isForgotten?: boolean;
- forgetAfter?: Date | string | null;
- isLatest?: boolean;
+ isForgotten?: boolean
+ forgetAfter?: Date | string | null
+ isLatest?: boolean
// Space/container fields
- spaceId?: string | null;
+ spaceId?: string | null
// Legacy fields
- memory?: string | null;
+ memory?: string | null
memoryRelations?: Array<{
- relationType: "updates" | "extends" | "derives";
- targetMemoryId: string;
- }> | null;
- parentMemoryId?: string | null;
+ relationType: "updates" | "extends" | "derives"
+ targetMemoryId: string
+ }> | null
+ parentMemoryId?: string | null
}
export interface DocumentWithMemories {
- id: string;
- customId?: string | null;
- contentHash: string | null;
- orgId: string;
- userId: string;
- connectionId?: string | null;
- title?: string | null;
- content?: string | null;
- summary?: string | null;
- url?: string | null;
- source?: string | null;
- type?: string | null;
- status: "pending" | "processing" | "done" | "failed";
- metadata?: Record<string, string | number | boolean> | null;
- processingMetadata?: Record<string, unknown> | null;
- raw?: string | null;
- tokenCount?: number | null;
- wordCount?: number | null;
- chunkCount?: number | null;
- averageChunkSize?: number | null;
- summaryEmbedding?: number[] | null;
- summaryEmbeddingModel?: string | null;
- createdAt: string | Date;
- updatedAt: string | Date;
- memoryEntries: MemoryEntry[];
+ id: string
+ customId?: string | null
+ contentHash: string | null
+ orgId: string
+ userId: string
+ connectionId?: string | null
+ title?: string | null
+ content?: string | null
+ summary?: string | null
+ url?: string | null
+ source?: string | null
+ type?: string | null
+ status: "pending" | "processing" | "done" | "failed"
+ metadata?: Record<string, string | number | boolean> | null
+ processingMetadata?: Record<string, unknown> | null
+ raw?: string | null
+ tokenCount?: number | null
+ wordCount?: number | null
+ chunkCount?: number | null
+ averageChunkSize?: number | null
+ summaryEmbedding?: number[] | null
+ summaryEmbeddingModel?: string | null
+ createdAt: string | Date
+ updatedAt: string | Date
+ memoryEntries: MemoryEntry[]
}
export interface DocumentsResponse {
- documents: DocumentWithMemories[];
+ documents: DocumentWithMemories[]
pagination: {
- currentPage: number;
- limit: number;
- totalItems: number;
- totalPages: number;
- };
+ currentPage: number
+ limit: number
+ totalItems: number
+ totalPages: number
+ }
}
diff --git a/packages/memory-graph/src/assets/icons.tsx b/packages/memory-graph/src/assets/icons.tsx
index 5383f690..5eb38b42 100644
--- a/packages/memory-graph/src/assets/icons.tsx
+++ b/packages/memory-graph/src/assets/icons.tsx
@@ -22,7 +22,7 @@ export const OneDrive = ({ className }: { className?: string }) => (
fill="#28A8EA"
/>
</svg>
-);
+)
export const GoogleDrive = ({ className }: { className?: string }) => (
<svg
@@ -56,7 +56,7 @@ export const GoogleDrive = ({ className }: { className?: string }) => (
fill="#FFBA00"
/>
</svg>
-);
+)
export const Notion = ({ className }: { className?: string }) => (
<svg
@@ -71,7 +71,7 @@ export const Notion = ({ className }: { className?: string }) => (
/>
<path d="M164.09.608L16.092 11.538C4.155 12.573 0 20.374 0 29.726v162.245c0 7.284 2.585 13.516 8.826 21.843l34.789 45.237c5.715 7.284 10.912 8.844 21.825 8.327l171.864-10.404c14.532-1.035 18.696-7.801 18.696-19.24V55.207c0-5.911-2.336-7.614-9.21-12.66l-1.185-.856L198.37 8.409C186.94.1 182.27-.952 164.09.608M69.327 52.22c-14.033.945-17.216 1.159-25.186-5.323L23.876 30.778c-2.06-2.086-1.026-4.69 4.163-5.207l142.274-10.395c11.947-1.043 18.17 3.12 22.842 6.758l24.401 17.68c1.043.525 3.638 3.637.517 3.637L71.146 52.095zm-16.36 183.954V81.222c0-6.767 2.077-9.887 8.3-10.413L230.02 60.93c5.724-.517 8.31 3.12 8.31 9.879v153.917c0 6.767-1.044 12.49-10.387 13.008l-161.487 9.361c-9.343.517-13.489-2.594-13.489-10.921M212.377 89.53c1.034 4.681 0 9.362-4.681 9.897l-7.783 1.542v114.404c-6.758 3.637-12.981 5.715-18.18 5.715c-8.308 0-10.386-2.604-16.609-10.396l-50.898-80.079v77.476l16.1 3.646s0 9.362-12.989 9.362l-35.814 2.077c-1.043-2.086 0-7.284 3.63-8.318l9.351-2.595V109.823l-12.98-1.052c-1.044-4.68 1.55-11.439 8.826-11.965l38.426-2.585l52.958 81.113v-71.76l-13.498-1.552c-1.043-5.733 3.111-9.896 8.3-10.404z" />
</svg>
-);
+)
export const GoogleDocs = ({ className }: { className?: string }) => (
<svg
@@ -85,7 +85,7 @@ export const GoogleDocs = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
-);
+)
export const GoogleSheets = ({ className }: { className?: string }) => (
<svg
@@ -99,7 +99,7 @@ export const GoogleSheets = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
-);
+)
export const GoogleSlides = ({ className }: { className?: string }) => (
<svg
@@ -113,7 +113,7 @@ export const GoogleSlides = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
-);
+)
export const NotionDoc = ({ className }: { className?: string }) => (
<svg
@@ -127,7 +127,7 @@ export const NotionDoc = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
-);
+)
export const MicrosoftWord = ({ className }: { className?: string }) => (
<svg
@@ -141,7 +141,7 @@ export const MicrosoftWord = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
-);
+)
export const MicrosoftExcel = ({ className }: { className?: string }) => (
<svg
@@ -155,7 +155,7 @@ export const MicrosoftExcel = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
-);
+)
export const MicrosoftPowerpoint = ({ className }: { className?: string }) => (
<svg
@@ -169,7 +169,7 @@ export const MicrosoftPowerpoint = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
-);
+)
export const MicrosoftOneNote = ({ className }: { className?: string }) => (
<svg
@@ -183,7 +183,7 @@ export const MicrosoftOneNote = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
-);
+)
export const PDF = ({ className }: { className?: string }) => (
<svg
@@ -205,4 +205,4 @@ export const PDF = ({ className }: { className?: string }) => (
fill="currentColor"
/>
</svg>
-);
+)
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<GraphCanvasProps>(
({
@@ -42,160 +42,160 @@ export const GraphCanvas = memo<GraphCanvasProps>(
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);
+ 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();
- }, []);
+ 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<GraphCanvasProps>(
targetX < -100 ||
targetX > width + 100
) {
- return;
+ return
}
// Skip very weak connections when zoomed out for performance
@@ -213,368 +213,365 @@ export const GraphCanvas = memo<GraphCanvasProps>(
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<string>(highlightDocumentIds ?? []);
+ 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;
+ 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<GraphCanvasProps>(
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<string>("");
+ const lastRenderParams = useRef<string>("")
// Create a render key that changes when visual state changes
const renderKey = useMemo(() => {
@@ -617,9 +614,9 @@ export const GraphCanvas = memo<GraphCanvasProps>(
(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<GraphCanvasProps>(
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<GraphCanvasProps>(
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<GraphCanvasProps>(
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<GraphCanvasProps>(
}}
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 (
- <div
- className={containerClass}
- id={id}
- >
+ <div className={containerClass} id={id}>
<Collapsible onOpenChange={handleToggleExpanded} open={isExpanded}>
{/* Glass effect background */}
<GlassMenuEffect rounded="xl" />
@@ -128,18 +130,22 @@ export const Legend = memo(function Legend({
{/* Stats Section */}
{!isLoading && (
<div className={styles.sectionWrapper}>
- <div className={styles.sectionTitle}>
- Statistics
- </div>
+ <div className={styles.sectionTitle}>Statistics</div>
<div className={styles.itemsList}>
<div className={styles.legendItem}>
- <Brain className={styles.legendIcon} style={{ color: "rgb(96, 165, 250)" }} />
+ <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)" }} />
+ <FileText
+ className={styles.legendIcon}
+ style={{ color: "rgb(203, 213, 225)" }}
+ />
<span className={styles.legendText}>
{documentCount} documents
</span>
@@ -156,9 +162,7 @@ export const Legend = memo(function Legend({
{/* Node Types */}
<div className={styles.sectionWrapper}>
- <div className={styles.sectionTitle}>
- Nodes
- </div>
+ <div className={styles.sectionTitle}>Nodes</div>
<div className={styles.itemsList}>
<div className={styles.legendItem}>
<div className={styles.documentNode} />
@@ -166,26 +170,26 @@ export const Legend = memo(function Legend({
</div>
<div className={styles.legendItem}>
<div className={styles.memoryNode} />
- <span className={styles.legendText}>Memory (latest)</span>
+ <span className={styles.legendText}>
+ Memory (latest)
+ </span>
</div>
<div className={styles.legendItem}>
<div className={styles.memoryNodeOlder} />
- <span className={styles.legendText}>Memory (older)</span>
+ <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.sectionTitle}>Status</div>
<div className={styles.itemsList}>
<div className={styles.legendItem}>
<div className={styles.forgottenNode}>
- <div className={styles.forgottenIcon}>
- ✕
- </div>
+ <div className={styles.forgottenIcon}>✕</div>
</div>
<span className={styles.legendText}>Forgotten</span>
</div>
@@ -204,9 +208,7 @@ export const Legend = memo(function Legend({
{/* Connection Types */}
<div className={styles.sectionWrapper}>
- <div className={styles.sectionTitle}>
- Connections
- </div>
+ <div className={styles.sectionTitle}>Connections</div>
<div className={styles.itemsList}>
<div className={styles.legendItem}>
<div className={styles.connectionLine} />
@@ -214,16 +216,16 @@ export const Legend = memo(function Legend({
</div>
<div className={styles.legendItem}>
<div className={styles.similarityLine} />
- <span className={styles.legendText}>Doc similarity</span>
+ <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.sectionTitle}>Relations</div>
<div className={styles.itemsList}>
{[
["updates", colors.relations.updates],
@@ -237,7 +239,10 @@ export const Legend = memo(function Legend({
/>
<span
className={styles.legendText}
- style={{ color: color, textTransform: "capitalize" }}
+ style={{
+ color: color,
+ textTransform: "capitalize",
+ }}
>
{label}
</span>
@@ -248,9 +253,7 @@ export const Legend = memo(function Legend({
{/* Similarity Strength */}
<div className={styles.sectionWrapper}>
- <div className={styles.sectionTitle}>
- Similarity
- </div>
+ <div className={styles.sectionTitle}>Similarity</div>
<div className={styles.itemsList}>
<div className={styles.legendItem}>
<div className={styles.weakSimilarity} />
@@ -269,7 +272,7 @@ export const Legend = memo(function Legend({
</div>
</Collapsible>
</div>
- );
-});
+ )
+})
-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<LoadingIndicatorProps>(
({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => {
- if (!isLoading && !isLoadingMore) return null;
+ if (!isLoading && !isLoadingMore) return null
return (
<div className={loadingContainer}>
@@ -33,8 +33,8 @@ export const LoadingIndicator = memo<LoadingIndicatorProps>(
</div>
</div>
</div>
- );
+ )
},
-);
+)
-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<string>("all");
- const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
- const containerRef = useRef<HTMLDivElement>(null);
+ // Internal state for controlled/uncontrolled pattern
+ const [internalSelectedSpace, setInternalSelectedSpace] =
+ useState<string>("all")
+
+ const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
+ const containerRef = useRef<HTMLDivElement>(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<string>("");
+ const lastFittedHighlightKeyRef = useRef<string>("")
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<string>();
- const counts: Record<string, number> = {};
+ 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;
- });
- });
+ 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 = ({
</div>
</div>
</div>
- );
+ )
}
return (
- <div className={`${themeClassName ?? defaultTheme} ${styles.mainContainer}`}>
- {/* Spaces selector - only shown for console */}
- {finalShowSpacesSelector && availableSpaces.length > 0 && (
+ <div
+ className={`${themeClassName ?? defaultTheme} ${styles.mainContainer}`}
+ >
+ {/* Spaces selector - only shown for console variant */}
+ {variant === "console" && availableSpaces.length > 0 && (
<div className={styles.spacesSelectorContainer}>
<SpacesDropdown
availableSpaces={availableSpaces}
- onSpaceChange={setSelectedSpace}
+ onSpaceChange={handleSpaceChange}
selectedSpace={selectedSpace}
spaceMemoryCounts={spaceMemoryCounts}
/>
@@ -411,11 +444,8 @@ export const MemoryGraph = ({
)}
{/* Graph container */}
- <div
- className={styles.graphContainer}
- ref={containerRef}
- >
- {(containerSize.width > 0 && containerSize.height > 0) && (
+ <div className={styles.graphContainer} ref={containerRef}>
+ {containerSize.width > 0 && containerSize.height > 0 && (
<GraphCanvas
draggingNodeId={draggingNodeId}
edges={edges}
@@ -446,8 +476,12 @@ export const MemoryGraph = ({
{containerSize.width > 0 && (
<NavigationControls
onCenter={handleCenter}
- onZoomIn={() => 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 = ({
)}
</div>
</div>
- );
-};
+ )
+}
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<NavigationControlsProps>(
({ onCenter, onZoomIn, onZoomOut, onAutoFit, nodes, className = "" }) => {
if (nodes.length === 0) {
- return null;
+ return null
}
const containerClassName = className
? `${navContainer} ${className}`
- : navContainer;
+ : navContainer
return (
<div className={containerClassName}>
@@ -66,8 +66,8 @@ export const NavigationControls = memo<NavigationControlsProps>(
</button>
</div>
</div>
- );
+ )
},
-);
+)
-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 <GoogleDocs {...iconProps} />;
+ return <GoogleDocs {...iconProps} />
case "google_sheet":
- return <GoogleSheets {...iconProps} />;
+ return <GoogleSheets {...iconProps} />
case "google_slide":
- return <GoogleSlides {...iconProps} />;
+ return <GoogleSlides {...iconProps} />
case "google_drive":
- return <GoogleDrive {...iconProps} />;
+ return <GoogleDrive {...iconProps} />
case "notion":
case "notion_doc":
- return <NotionDoc {...iconProps} />;
+ return <NotionDoc {...iconProps} />
case "word":
case "microsoft_word":
- return <MicrosoftWord {...iconProps} />;
+ return <MicrosoftWord {...iconProps} />
case "excel":
case "microsoft_excel":
- return <MicrosoftExcel {...iconProps} />;
+ return <MicrosoftExcel {...iconProps} />
case "powerpoint":
case "microsoft_powerpoint":
- return <MicrosoftPowerpoint {...iconProps} />;
+ return <MicrosoftPowerpoint {...iconProps} />
case "onenote":
case "microsoft_onenote":
- return <MicrosoftOneNote {...iconProps} />;
+ return <MicrosoftOneNote {...iconProps} />
case "onedrive":
- return <OneDrive {...iconProps} />;
+ return <OneDrive {...iconProps} />
case "pdf":
- return <PDF {...iconProps} />;
+ return <PDF {...iconProps} />
default:
- {/*@ts-ignore */}
- return <FileText {...iconProps} />;
+ {
+ /*@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;
+}
+
+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" />
- return (
<motion.div
animate={{ opacity: 1 }}
- className={styles.container}
- exit={{ opacity: 0 }}
+ className={styles.content}
initial={{ opacity: 0 }}
- transition={{
- duration: 0.2,
- ease: "easeInOut",
- }}
+ transition={{ delay: 0.05, duration: 0.15 }}
>
- {/* 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 ?? "")
- ) : (
+ <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>
+ <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>
- <div className={styles.sections}>
- {isDocument ? (
- <>
+ {(data as DocumentWithMemories).summary && (
<div className={styles.section}>
- <span className={styles.sectionLabel}>
- Title
- </span>
- <p className={styles.sectionValue}>
- {(data as DocumentWithMemories).title ||
- "Untitled Document"}
+ <span className={styles.sectionLabel}>Summary</span>
+ <p className={styles.sectionValueTruncated}>
+ {(data as DocumentWithMemories).summary}
</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}>
- 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}>
- Memory Count
- </span>
- <p className={styles.sectionValue}>
- {(data as DocumentWithMemories).memoryEntries.length}{" "}
- memories
- </p>
+ <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>
-
- {((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>
)}
- </>
- ) : (
- <>
- <div className={styles.section}>
- <span className={styles.sectionLabel}>
- Memory
- </span>
- <p className={styles.sectionValue}>
- {(data as MemoryEntry).memory}
+ {(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>
- {(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>
- <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 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>
- </motion.div>
+ </div>
</motion.div>
- );
- },
-);
+ </motion.div>
+ )
+})
-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<SpacesDropdownProps>(
({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => {
- const [isOpen, setIsOpen] = useState(false);
- const dropdownRef = useRef<HTMLDivElement>(null);
+ const [isOpen, setIsOpen] = useState(false)
+ const [searchQuery, setSearchQuery] = useState("")
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
+ const dropdownRef = useRef<HTMLDivElement>(null)
+ const searchInputRef = useRef<HTMLInputElement>(null)
+ const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map())
// Close dropdown when clicking outside
useEffect(() => {
@@ -18,38 +22,115 @@ export const SpacesDropdown = memo<SpacesDropdownProps>(
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 (
- <div className={styles.container} ref={dropdownRef}>
+ <div
+ className={styles.container}
+ ref={dropdownRef}
+ onKeyDown={handleKeyDown}
+ >
<button
className={styles.trigger}
onClick={() => setIsOpen(!isOpen)}
type="button"
>
- {/*@ts-ignore */}
- <Eye className={styles.triggerIcon} />
+ {/*@ts-ignore */}
+ <Eye className={styles.triggerIcon} />
<div className={styles.triggerContent}>
<span className={styles.triggerLabel}>
{selectedSpace === "all"
- ? "All Spaces"
+ ? "Latest"
: selectedSpace || "Select space"}
</span>
<div className={styles.triggerSubtext}>
{selectedSpace === "all"
- ? `${totalMemories} total memories`
+ ? ""
: `${spaceMemoryCounts[selectedSpace] || 0} memories`}
</div>
</div>
@@ -62,49 +143,105 @@ export const SpacesDropdown = memo<SpacesDropdownProps>(
{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) => (
+ {/* Search Input - Always show for filtering */}
+ <div className={styles.searchContainer}>
+ <div className={styles.searchForm}>
+ {/*@ts-ignore */}
+ <Search className={styles.searchIcon} />
+ <input
+ className={styles.searchInput}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ placeholder="Search spaces..."
+ ref={searchInputRef}
+ type="text"
+ value={searchQuery}
+ />
+ {searchQuery && (
+ <button
+ className={styles.searchClearButton}
+ onClick={() => setSearchQuery("")}
+ type="button"
+ aria-label="Clear search"
+ >
+ {/*@ts-ignore */}
+ <X className={styles.searchIcon} />
+ </button>
+ )}
+ </div>
+ </div>
+
+ {/* Spaces List */}
+ <div className={styles.dropdownList}>
+ {/* Always show "Latest" option */}
<button
+ ref={(el) => {
+ if (el) itemRefs.current.set(0, el)
+ }}
className={
- selectedSpace === space
+ selectedSpace === "all"
? styles.dropdownItemActive
- : styles.dropdownItem
+ : highlightedIndex === 0
+ ? styles.dropdownItemHighlighted
+ : styles.dropdownItem
}
- key={space}
onClick={() => {
- onSpaceChange(space);
- setIsOpen(false);
+ onSpaceChange("all")
+ setIsOpen(false)
}}
+ onMouseEnter={() => setHighlightedIndex(0)}
type="button"
>
- <span className={styles.dropdownItemLabelTruncate}>{space}</span>
+ <span className={styles.dropdownItemLabel}>Latest</span>
<Badge className={styles.dropdownItemBadge}>
- {spaceMemoryCounts[space] || 0}
+ {totalMemories}
</Badge>
</button>
- ))}
+
+ {/* Show all spaces, filtered by search query */}
+ {filteredSpaces.length > 0
+ ? filteredSpaces.map((space, index) => {
+ const itemIndex = index + 1
+ return (
+ <button
+ ref={(el) => {
+ if (el) itemRefs.current.set(itemIndex, el)
+ }}
+ className={
+ selectedSpace === space
+ ? styles.dropdownItemActive
+ : highlightedIndex === itemIndex
+ ? styles.dropdownItemHighlighted
+ : styles.dropdownItem
+ }
+ key={space}
+ onClick={() => {
+ onSpaceChange(space)
+ setIsOpen(false)
+ }}
+ onMouseEnter={() => setHighlightedIndex(itemIndex)}
+ type="button"
+ >
+ <span className={styles.dropdownItemLabelTruncate}>
+ {space}
+ </span>
+ <Badge className={styles.dropdownItemBadge}>
+ {spaceMemoryCounts[space] || 0}
+ </Badge>
+ </button>
+ )
+ })
+ : searchQuery && (
+ <div className={styles.emptyState}>
+ No spaces found matching "{searchQuery}"
+ </div>
+ )}
+ </div>
</div>
</div>
)}
</div>
- );
+ )
},
-);
+)
-SpacesDropdown.displayName = "SpacesDropdown";
+SpacesDropdown.displayName = "SpacesDropdown"
diff --git a/packages/memory-graph/src/constants.ts b/packages/memory-graph/src/constants.ts
index 23193601..fddfdee5 100644
--- a/packages/memory-graph/src/constants.ts
+++ b/packages/memory-graph/src/constants.ts
@@ -47,7 +47,7 @@ export const colors = {
extends: "rgba(16, 185, 129, 0.5)", // green
derives: "rgba(147, 197, 253, 0.5)", // blue
},
-};
+}
export const LAYOUT_CONSTANTS = {
centerX: 400,
@@ -57,7 +57,7 @@ export const LAYOUT_CONSTANTS = {
documentSpacing: 1000, // How far the first doc in a space sits from its space-centre - push docs way out
minDocDist: 900, // Minimum distance two documents in the **same space** are allowed to be - sets repulsion radius
memoryClusterRadius: 300,
-};
+}
// Graph view settings
export const GRAPH_SETTINGS = {
@@ -71,7 +71,7 @@ export const GRAPH_SETTINGS = {
initialPanX: 400, // Pan towards center to compensate for larger layout
initialPanY: 300, // Pan towards center to compensate for larger layout
},
-};
+}
// Responsive positioning for different app variants
export const POSITIONING = {
@@ -97,4 +97,4 @@ export const POSITIONING = {
viewToggle: "top-4 right-4", // Consumer has view toggle
nodeDetail: "top-4 right-4",
},
-};
+}
diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts
index 030eea61..526277bb 100644
--- a/packages/memory-graph/src/hooks/use-graph-data.ts
+++ b/packages/memory-graph/src/hooks/use-graph-data.ts
@@ -1,12 +1,12 @@
-"use client";
+"use client"
import {
calculateSemanticSimilarity,
getConnectionVisualProps,
getMagicalConnectionColor,
-} from "@/lib/similarity";
-import { useMemo } from "react";
-import { colors, LAYOUT_CONSTANTS } from "@/constants";
+} from "@/lib/similarity"
+import { useMemo } from "react"
+import { colors, LAYOUT_CONSTANTS } from "@/constants"
import type {
DocumentsResponse,
DocumentWithMemories,
@@ -14,95 +14,106 @@ import type {
GraphNode,
MemoryEntry,
MemoryRelation,
-} from "@/types";
+} from "@/types"
export function useGraphData(
data: DocumentsResponse | null,
selectedSpace: string,
nodePositions: Map<string, { x: number; y: number }>,
draggingNodeId: string | null,
+ memoryLimit?: number,
) {
return useMemo(() => {
- if (!data?.documents) return { nodes: [], edges: [] };
+ if (!data?.documents) return { nodes: [], edges: [] }
- const allNodes: GraphNode[] = [];
- const allEdges: GraphEdge[] = [];
+ const allNodes: GraphNode[] = []
+ const allEdges: GraphEdge[] = []
// Filter documents that have memories in selected space
+ // AND limit memories per document when memoryLimit is provided
const filteredDocuments = data.documents
- .map((doc) => ({
- ...doc,
- memoryEntries:
+ .map((doc) => {
+ let memories =
selectedSpace === "all"
? doc.memoryEntries
: doc.memoryEntries.filter(
(memory) =>
(memory.spaceContainerTag ?? memory.spaceId ?? "default") ===
selectedSpace,
- ),
- }))
- .filter((doc) => doc.memoryEntries.length > 0);
+ )
+
+ // Apply memory limit if provided and a specific space is selected
+ if (selectedSpace !== "all" && memoryLimit && memoryLimit > 0) {
+ memories = memories.slice(0, memoryLimit)
+ }
+
+ return {
+ ...doc,
+ memoryEntries: memories,
+ }
+ })
+ .filter((doc) => doc.memoryEntries.length > 0)
// Group documents by space for better clustering
- const documentsBySpace = new Map<string, typeof filteredDocuments>();
+ const documentsBySpace = new Map<string, typeof filteredDocuments>()
filteredDocuments.forEach((doc) => {
const docSpace =
doc.memoryEntries[0]?.spaceContainerTag ??
doc.memoryEntries[0]?.spaceId ??
- "default";
+ "default"
if (!documentsBySpace.has(docSpace)) {
- documentsBySpace.set(docSpace, []);
+ documentsBySpace.set(docSpace, [])
}
- const spaceDocsArr = documentsBySpace.get(docSpace);
+ const spaceDocsArr = documentsBySpace.get(docSpace)
if (spaceDocsArr) {
- spaceDocsArr.push(doc);
+ spaceDocsArr.push(doc)
}
- });
+ })
// Enhanced Layout with Space Separation
const { centerX, centerY, clusterRadius, spaceSpacing, documentSpacing } =
- LAYOUT_CONSTANTS;
+ LAYOUT_CONSTANTS
/* 1. Build DOCUMENT nodes with space-aware clustering */
- const documentNodes: GraphNode[] = [];
- let spaceIndex = 0;
+ const documentNodes: GraphNode[] = []
+ let spaceIndex = 0
documentsBySpace.forEach((spaceDocs) => {
- const spaceAngle = (spaceIndex / documentsBySpace.size) * Math.PI * 2;
- const spaceOffsetX = Math.cos(spaceAngle) * spaceSpacing;
- const spaceOffsetY = Math.sin(spaceAngle) * spaceSpacing;
- const spaceCenterX = centerX + spaceOffsetX;
- const spaceCenterY = centerY + spaceOffsetY;
+ const spaceAngle = (spaceIndex / documentsBySpace.size) * Math.PI * 2
+ const spaceOffsetX = Math.cos(spaceAngle) * spaceSpacing
+ const spaceOffsetY = Math.sin(spaceAngle) * spaceSpacing
+ const spaceCenterX = centerX + spaceOffsetX
+ const spaceCenterY = centerY + spaceOffsetY
spaceDocs.forEach((doc, docIndex) => {
// Create proper circular layout with concentric rings
- const docsPerRing = 6; // Start with 6 docs in inner ring
- let currentRing = 0;
- let docsInCurrentRing = docsPerRing;
- let totalDocsInPreviousRings = 0;
+ const docsPerRing = 6 // Start with 6 docs in inner ring
+ let currentRing = 0
+ let docsInCurrentRing = docsPerRing
+ let totalDocsInPreviousRings = 0
// Find which ring this document belongs to
while (totalDocsInPreviousRings + docsInCurrentRing <= docIndex) {
- totalDocsInPreviousRings += docsInCurrentRing;
- currentRing++;
- docsInCurrentRing = docsPerRing + currentRing * 4; // Each ring has more docs
+ totalDocsInPreviousRings += docsInCurrentRing
+ currentRing++
+ docsInCurrentRing = docsPerRing + currentRing * 4 // Each ring has more docs
}
// Position within the ring
- const positionInRing = docIndex - totalDocsInPreviousRings;
- const angleInRing = (positionInRing / docsInCurrentRing) * Math.PI * 2;
+ const positionInRing = docIndex - totalDocsInPreviousRings
+ const angleInRing = (positionInRing / docsInCurrentRing) * Math.PI * 2
// Radius increases significantly with each ring
- const baseRadius = documentSpacing * 0.8;
+ const baseRadius = documentSpacing * 0.8
const radius =
currentRing === 0
? baseRadius
- : baseRadius + currentRing * documentSpacing * 1.2;
+ : baseRadius + currentRing * documentSpacing * 1.2
- const defaultX = spaceCenterX + Math.cos(angleInRing) * radius;
- const defaultY = spaceCenterY + Math.sin(angleInRing) * radius;
+ const defaultX = spaceCenterX + Math.cos(angleInRing) * radius
+ const defaultY = spaceCenterY + Math.sin(angleInRing) * radius
- const customPos = nodePositions.get(doc.id);
+ const customPos = nodePositions.get(doc.id)
documentNodes.push({
id: doc.id,
@@ -114,81 +125,80 @@ export function useGraphData(
color: colors.document.primary,
isHovered: false,
isDragging: draggingNodeId === doc.id,
- } satisfies GraphNode);
- });
+ } satisfies GraphNode)
+ })
- spaceIndex++;
- });
+ spaceIndex++
+ })
/* 2. Gentle document collision avoidance with dampening */
- const minDocDist = LAYOUT_CONSTANTS.minDocDist;
+ const minDocDist = LAYOUT_CONSTANTS.minDocDist
// Reduced iterations and gentler repulsion for smoother movement
for (let iter = 0; iter < 2; iter++) {
documentNodes.forEach((nodeA) => {
documentNodes.forEach((nodeB) => {
- if (nodeA.id >= nodeB.id) return;
+ if (nodeA.id >= nodeB.id) return
// Only repel documents in the same space
const spaceA =
(nodeA.data as DocumentWithMemories).memoryEntries[0]
?.spaceContainerTag ??
(nodeA.data as DocumentWithMemories).memoryEntries[0]?.spaceId ??
- "default";
+ "default"
const spaceB =
(nodeB.data as DocumentWithMemories).memoryEntries[0]
?.spaceContainerTag ??
(nodeB.data as DocumentWithMemories).memoryEntries[0]?.spaceId ??
- "default";
+ "default"
- if (spaceA !== spaceB) return;
+ if (spaceA !== spaceB) return
- const dx = nodeB.x - nodeA.x;
- const dy = nodeB.y - nodeA.y;
- const dist = Math.sqrt(dx * dx + dy * dy) || 1;
+ const dx = nodeB.x - nodeA.x
+ const dy = nodeB.y - nodeA.y
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1
if (dist < minDocDist) {
// Much gentler push with dampening
- const push = (minDocDist - dist) / 8;
- const dampening = Math.max(0.1, Math.min(1, dist / minDocDist));
- const smoothPush = push * dampening * 0.5;
-
- const nx = dx / dist;
- const ny = dy / dist;
- nodeA.x -= nx * smoothPush;
- nodeA.y -= ny * smoothPush;
- nodeB.x += nx * smoothPush;
- nodeB.y += ny * smoothPush;
+ const push = (minDocDist - dist) / 8
+ const dampening = Math.max(0.1, Math.min(1, dist / minDocDist))
+ const smoothPush = push * dampening * 0.5
+
+ const nx = dx / dist
+ const ny = dy / dist
+ nodeA.x -= nx * smoothPush
+ nodeA.y -= ny * smoothPush
+ nodeB.x += nx * smoothPush
+ nodeB.y += ny * smoothPush
}
- });
- });
+ })
+ })
}
- allNodes.push(...documentNodes);
+ allNodes.push(...documentNodes)
/* 3. Add memories around documents WITH doc-memory connections */
documentNodes.forEach((docNode) => {
- const memoryNodeMap = new Map<string, GraphNode>();
- const doc = docNode.data as DocumentWithMemories;
+ const memoryNodeMap = new Map<string, GraphNode>()
+ const doc = docNode.data as DocumentWithMemories
doc.memoryEntries.forEach((memory, memIndex) => {
- const memoryId = `${memory.id}`;
- const customMemPos = nodePositions.get(memoryId);
+ const memoryId = `${memory.id}`
+ const customMemPos = nodePositions.get(memoryId)
- const clusterAngle =
- (memIndex / doc.memoryEntries.length) * Math.PI * 2;
- const variation = Math.sin(memIndex * 2.5) * 0.3 + 0.7;
- const distance = clusterRadius * variation;
+ const clusterAngle = (memIndex / doc.memoryEntries.length) * Math.PI * 2
+ const variation = Math.sin(memIndex * 2.5) * 0.3 + 0.7
+ const distance = clusterRadius * variation
const seed =
- memIndex * 12345 + Number.parseInt(docNode.id.slice(0, 6), 36);
- const offsetX = Math.sin(seed) * 0.5 * 40;
- const offsetY = Math.cos(seed) * 0.5 * 40;
+ memIndex * 12345 + Number.parseInt(docNode.id.slice(0, 6), 36)
+ const offsetX = Math.sin(seed) * 0.5 * 40
+ const offsetY = Math.cos(seed) * 0.5 * 40
const defaultMemX =
- docNode.x + Math.cos(clusterAngle) * distance + offsetX;
+ docNode.x + Math.cos(clusterAngle) * distance + offsetX
const defaultMemY =
- docNode.y + Math.sin(clusterAngle) * distance + offsetY;
+ docNode.y + Math.sin(clusterAngle) * distance + offsetY
if (!memoryNodeMap.has(memoryId)) {
const memoryNode: GraphNode = {
@@ -204,9 +214,9 @@ export function useGraphData(
color: colors.memory.primary,
isHovered: false,
isDragging: draggingNodeId === memoryId,
- };
- memoryNodeMap.set(memoryId, memoryNode);
- allNodes.push(memoryNode);
+ }
+ memoryNodeMap.set(memoryId, memoryNode)
+ allNodes.push(memoryNode)
}
// Create doc-memory edge with similarity
@@ -218,23 +228,23 @@ export function useGraphData(
visualProps: getConnectionVisualProps(1),
color: colors.connection.memory,
edgeType: "doc-memory",
- });
- });
- });
+ })
+ })
+ })
// Build mapping of memoryId -> nodeId for version chains
- const memNodeIdMap = new Map<string, string>();
+ const memNodeIdMap = new Map<string, string>()
allNodes.forEach((n) => {
if (n.type === "memory") {
- memNodeIdMap.set((n.data as MemoryEntry).id, n.id);
+ memNodeIdMap.set((n.data as MemoryEntry).id, n.id)
}
- });
+ })
// Add version-chain edges (old -> new)
data.documents.forEach((doc) => {
doc.memoryEntries.forEach((mem: MemoryEntry) => {
// Support both new object structure and legacy array/single parent fields
- let parentRelations: Record<string, MemoryRelation> = {};
+ let parentRelations: Record<string, MemoryRelation> = {}
if (
mem.memoryRelations &&
@@ -242,18 +252,21 @@ export function useGraphData(
mem.memoryRelations.length > 0
) {
// Convert array to Record
- parentRelations = mem.memoryRelations.reduce((acc, rel) => {
- acc[rel.targetMemoryId] = rel.relationType;
- return acc;
- }, {} as Record<string, MemoryRelation>);
+ parentRelations = mem.memoryRelations.reduce(
+ (acc, rel) => {
+ acc[rel.targetMemoryId] = rel.relationType
+ return acc
+ },
+ {} as Record<string, MemoryRelation>,
+ )
} else if (mem.parentMemoryId) {
parentRelations = {
[mem.parentMemoryId]: "updates" as MemoryRelation,
- };
+ }
}
Object.entries(parentRelations).forEach(([pid, relationType]) => {
- const fromId = memNodeIdMap.get(pid);
- const toId = memNodeIdMap.get(mem.id);
+ const fromId = memNodeIdMap.get(pid)
+ const toId = memNodeIdMap.get(mem.id)
if (fromId && toId) {
allEdges.push({
id: `version-${fromId}-${toId}`,
@@ -270,25 +283,25 @@ export function useGraphData(
color: colors.relations[relationType] ?? colors.relations.updates,
edgeType: "version",
relationType: relationType as MemoryRelation,
- });
+ })
}
- });
- });
- });
+ })
+ })
+ })
// Document-to-document similarity edges
for (let i = 0; i < filteredDocuments.length; i++) {
- const docI = filteredDocuments[i];
- if (!docI) continue;
+ const docI = filteredDocuments[i]
+ if (!docI) continue
for (let j = i + 1; j < filteredDocuments.length; j++) {
- const docJ = filteredDocuments[j];
- if (!docJ) continue;
+ const docJ = filteredDocuments[j]
+ if (!docJ) continue
const sim = calculateSemanticSimilarity(
docI.summaryEmbedding ? Array.from(docI.summaryEmbedding) : null,
docJ.summaryEmbedding ? Array.from(docJ.summaryEmbedding) : null,
- );
+ )
if (sim > 0.725) {
allEdges.push({
id: `doc-doc-${docI.id}-${docJ.id}`,
@@ -298,11 +311,11 @@ export function useGraphData(
visualProps: getConnectionVisualProps(sim),
color: getMagicalConnectionColor(sim, 200),
edgeType: "doc-doc",
- });
+ })
}
}
}
- return { nodes: allNodes, edges: allEdges };
- }, [data, selectedSpace, nodePositions, draggingNodeId]);
+ return { nodes: allNodes, edges: allEdges }
+ }, [data, selectedSpace, nodePositions, draggingNodeId, memoryLimit])
}
diff --git a/packages/memory-graph/src/hooks/use-graph-interactions.ts b/packages/memory-graph/src/hooks/use-graph-interactions.ts
index fa794397..94fc88ee 100644
--- a/packages/memory-graph/src/hooks/use-graph-interactions.ts
+++ b/packages/memory-graph/src/hooks/use-graph-interactions.ts
@@ -1,48 +1,48 @@
-"use client";
+"use client"
-import { useCallback, useRef, useState } from "react";
-import { GRAPH_SETTINGS } from "@/constants";
-import type { GraphNode } from "@/types";
+import { useCallback, useRef, useState } from "react"
+import { GRAPH_SETTINGS } from "@/constants"
+import type { GraphNode } from "@/types"
export function useGraphInteractions(
variant: "console" | "consumer" = "console",
) {
- const settings = GRAPH_SETTINGS[variant];
-
- const [panX, setPanX] = useState(settings.initialPanX);
- const [panY, setPanY] = useState(settings.initialPanY);
- const [zoom, setZoom] = useState(settings.initialZoom);
- const [isPanning, setIsPanning] = useState(false);
- const [panStart, setPanStart] = useState({ x: 0, y: 0 });
- const [hoveredNode, setHoveredNode] = useState<string | null>(null);
- const [selectedNode, setSelectedNode] = useState<string | null>(null);
- const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null);
+ const settings = GRAPH_SETTINGS[variant]
+
+ const [panX, setPanX] = useState(settings.initialPanX)
+ const [panY, setPanY] = useState(settings.initialPanY)
+ const [zoom, setZoom] = useState(settings.initialZoom)
+ const [isPanning, setIsPanning] = useState(false)
+ const [panStart, setPanStart] = useState({ x: 0, y: 0 })
+ const [hoveredNode, setHoveredNode] = useState<string | null>(null)
+ const [selectedNode, setSelectedNode] = useState<string | null>(null)
+ const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null)
const [dragStart, setDragStart] = useState({
x: 0,
y: 0,
nodeX: 0,
nodeY: 0,
- });
+ })
const [nodePositions, setNodePositions] = useState<
Map<string, { x: number; y: number }>
- >(new Map());
+ >(new Map())
// Touch gesture state
const [touchState, setTouchState] = useState<{
- touches: { id: number; x: number; y: number }[];
- lastDistance: number;
- lastCenter: { x: number; y: number };
- isGesturing: boolean;
+ touches: { id: number; x: number; y: number }[]
+ lastDistance: number
+ lastCenter: { x: number; y: number }
+ isGesturing: boolean
}>({
touches: [],
lastDistance: 0,
lastCenter: { x: 0, y: 0 },
isGesturing: false,
- });
+ })
// Animation state for smooth transitions
- const animationRef = useRef<number | null>(null);
- const [isAnimating, setIsAnimating] = useState(false);
+ const animationRef = useRef<number | null>(null)
+ const [isAnimating, setIsAnimating] = useState(false)
// Smooth animation helper
const animateToViewState = useCallback(
@@ -53,219 +53,219 @@ export function useGraphInteractions(
duration = 300,
) => {
if (animationRef.current) {
- cancelAnimationFrame(animationRef.current);
+ cancelAnimationFrame(animationRef.current)
}
- const startPanX = panX;
- const startPanY = panY;
- const startZoom = zoom;
- const startTime = Date.now();
+ const startPanX = panX
+ const startPanY = panY
+ const startZoom = zoom
+ const startTime = Date.now()
- setIsAnimating(true);
+ setIsAnimating(true)
const animate = () => {
- const elapsed = Date.now() - startTime;
- const progress = Math.min(elapsed / duration, 1);
+ const elapsed = Date.now() - startTime
+ const progress = Math.min(elapsed / duration, 1)
// Ease out cubic function for smooth transitions
- const easeOut = 1 - (1 - progress) ** 3;
+ const easeOut = 1 - (1 - progress) ** 3
- const currentPanX = startPanX + (targetPanX - startPanX) * easeOut;
- const currentPanY = startPanY + (targetPanY - startPanY) * easeOut;
- const currentZoom = startZoom + (targetZoom - startZoom) * easeOut;
+ const currentPanX = startPanX + (targetPanX - startPanX) * easeOut
+ const currentPanY = startPanY + (targetPanY - startPanY) * easeOut
+ const currentZoom = startZoom + (targetZoom - startZoom) * easeOut
- setPanX(currentPanX);
- setPanY(currentPanY);
- setZoom(currentZoom);
+ setPanX(currentPanX)
+ setPanY(currentPanY)
+ setZoom(currentZoom)
if (progress < 1) {
- animationRef.current = requestAnimationFrame(animate);
+ animationRef.current = requestAnimationFrame(animate)
} else {
- setIsAnimating(false);
- animationRef.current = null;
+ setIsAnimating(false)
+ animationRef.current = null
}
- };
+ }
- animate();
+ animate()
},
[panX, panY, zoom],
- );
+ )
// Node drag handlers
const handleNodeDragStart = useCallback(
(nodeId: string, e: React.MouseEvent, nodes?: GraphNode[]) => {
- const node = nodes?.find((n) => n.id === nodeId);
- if (!node) return;
+ const node = nodes?.find((n) => n.id === nodeId)
+ if (!node) return
- setDraggingNodeId(nodeId);
+ setDraggingNodeId(nodeId)
setDragStart({
x: e.clientX,
y: e.clientY,
nodeX: node.x,
nodeY: node.y,
- });
+ })
},
[],
- );
+ )
const handleNodeDragMove = useCallback(
(e: React.MouseEvent) => {
- if (!draggingNodeId) return;
+ if (!draggingNodeId) return
- const deltaX = (e.clientX - dragStart.x) / zoom;
- const deltaY = (e.clientY - dragStart.y) / zoom;
+ const deltaX = (e.clientX - dragStart.x) / zoom
+ const deltaY = (e.clientY - dragStart.y) / zoom
- const newX = dragStart.nodeX + deltaX;
- const newY = dragStart.nodeY + deltaY;
+ const newX = dragStart.nodeX + deltaX
+ const newY = dragStart.nodeY + deltaY
setNodePositions((prev) =>
new Map(prev).set(draggingNodeId, { x: newX, y: newY }),
- );
+ )
},
[draggingNodeId, dragStart, zoom],
- );
+ )
const handleNodeDragEnd = useCallback(() => {
- setDraggingNodeId(null);
- }, []);
+ setDraggingNodeId(null)
+ }, [])
// Pan handlers
const handlePanStart = useCallback(
(e: React.MouseEvent) => {
- setIsPanning(true);
- setPanStart({ x: e.clientX - panX, y: e.clientY - panY });
+ setIsPanning(true)
+ setPanStart({ x: e.clientX - panX, y: e.clientY - panY })
},
[panX, panY],
- );
+ )
const handlePanMove = useCallback(
(e: React.MouseEvent) => {
- if (!isPanning || draggingNodeId) return;
+ if (!isPanning || draggingNodeId) return
- const newPanX = e.clientX - panStart.x;
- const newPanY = e.clientY - panStart.y;
- setPanX(newPanX);
- setPanY(newPanY);
+ const newPanX = e.clientX - panStart.x
+ const newPanY = e.clientY - panStart.y
+ setPanX(newPanX)
+ setPanY(newPanY)
},
[isPanning, panStart, draggingNodeId],
- );
+ )
const handlePanEnd = useCallback(() => {
- setIsPanning(false);
- }, []);
+ setIsPanning(false)
+ }, [])
// Zoom handlers
const handleWheel = useCallback(
(e: React.WheelEvent) => {
// Always prevent default to stop browser navigation
- e.preventDefault();
- e.stopPropagation();
+ e.preventDefault()
+ e.stopPropagation()
// Handle horizontal scrolling (trackpad swipe) by converting to pan
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
// Horizontal scroll - pan the graph instead of zooming
- const panDelta = e.deltaX * 0.5;
- setPanX((prev) => prev - panDelta);
- return;
+ const panDelta = e.deltaX * 0.5
+ setPanX((prev) => prev - panDelta)
+ return
}
// Vertical scroll - zoom behavior
- const delta = e.deltaY > 0 ? 0.97 : 1.03;
- const newZoom = Math.max(0.05, Math.min(3, zoom * delta));
+ const delta = e.deltaY > 0 ? 0.97 : 1.03
+ const newZoom = Math.max(0.05, Math.min(3, zoom * delta))
// Get mouse position relative to the viewport
- let mouseX = e.clientX;
- let mouseY = e.clientY;
+ let mouseX = e.clientX
+ let mouseY = e.clientY
// Try to get the container bounds to make coordinates relative to the graph container
- const target = e.currentTarget;
+ const target = e.currentTarget
if (target && "getBoundingClientRect" in target) {
- const rect = target.getBoundingClientRect();
- mouseX = e.clientX - rect.left;
- mouseY = e.clientY - rect.top;
+ const rect = target.getBoundingClientRect()
+ mouseX = e.clientX - rect.left
+ mouseY = e.clientY - rect.top
}
// Calculate the world position of the mouse cursor
- const worldX = (mouseX - panX) / zoom;
- const worldY = (mouseY - panY) / zoom;
+ const worldX = (mouseX - panX) / zoom
+ const worldY = (mouseY - panY) / zoom
// Calculate new pan to keep the mouse position stationary
- const newPanX = mouseX - worldX * newZoom;
- const newPanY = mouseY - worldY * newZoom;
+ const newPanX = mouseX - worldX * newZoom
+ const newPanY = mouseY - worldY * newZoom
- setZoom(newZoom);
- setPanX(newPanX);
- setPanY(newPanY);
+ setZoom(newZoom)
+ setPanX(newPanX)
+ setPanY(newPanY)
},
[zoom, panX, panY],
- );
+ )
const zoomIn = useCallback(
(centerX?: number, centerY?: number, animate = true) => {
- const zoomFactor = 1.2;
- const newZoom = Math.min(3, zoom * zoomFactor); // Increased max zoom to 3x
+ const zoomFactor = 1.2
+ const newZoom = Math.min(3, zoom * zoomFactor) // Increased max zoom to 3x
if (centerX !== undefined && centerY !== undefined) {
// Mouse-centered zoom for programmatic zoom in
- const worldX = (centerX - panX) / zoom;
- const worldY = (centerY - panY) / zoom;
- const newPanX = centerX - worldX * newZoom;
- const newPanY = centerY - worldY * newZoom;
+ const worldX = (centerX - panX) / zoom
+ const worldY = (centerY - panY) / zoom
+ const newPanX = centerX - worldX * newZoom
+ const newPanY = centerY - worldY * newZoom
if (animate && !isAnimating) {
- animateToViewState(newPanX, newPanY, newZoom, 200);
+ animateToViewState(newPanX, newPanY, newZoom, 200)
} else {
- setZoom(newZoom);
- setPanX(newPanX);
- setPanY(newPanY);
+ setZoom(newZoom)
+ setPanX(newPanX)
+ setPanY(newPanY)
}
} else {
if (animate && !isAnimating) {
- animateToViewState(panX, panY, newZoom, 200);
+ animateToViewState(panX, panY, newZoom, 200)
} else {
- setZoom(newZoom);
+ setZoom(newZoom)
}
}
},
[zoom, panX, panY, isAnimating, animateToViewState],
- );
+ )
const zoomOut = useCallback(
(centerX?: number, centerY?: number, animate = true) => {
- const zoomFactor = 0.8;
- const newZoom = Math.max(0.05, zoom * zoomFactor); // Decreased min zoom to 0.05x
+ const zoomFactor = 0.8
+ const newZoom = Math.max(0.05, zoom * zoomFactor) // Decreased min zoom to 0.05x
if (centerX !== undefined && centerY !== undefined) {
// Mouse-centered zoom for programmatic zoom out
- const worldX = (centerX - panX) / zoom;
- const worldY = (centerY - panY) / zoom;
- const newPanX = centerX - worldX * newZoom;
- const newPanY = centerY - worldY * newZoom;
+ const worldX = (centerX - panX) / zoom
+ const worldY = (centerY - panY) / zoom
+ const newPanX = centerX - worldX * newZoom
+ const newPanY = centerY - worldY * newZoom
if (animate && !isAnimating) {
- animateToViewState(newPanX, newPanY, newZoom, 200);
+ animateToViewState(newPanX, newPanY, newZoom, 200)
} else {
- setZoom(newZoom);
- setPanX(newPanX);
- setPanY(newPanY);
+ setZoom(newZoom)
+ setPanX(newPanX)
+ setPanY(newPanY)
}
} else {
if (animate && !isAnimating) {
- animateToViewState(panX, panY, newZoom, 200);
+ animateToViewState(panX, panY, newZoom, 200)
} else {
- setZoom(newZoom);
+ setZoom(newZoom)
}
}
},
[zoom, panX, panY, isAnimating, animateToViewState],
- );
+ )
const resetView = useCallback(() => {
- setPanX(settings.initialPanX);
- setPanY(settings.initialPanY);
- setZoom(settings.initialZoom);
- setNodePositions(new Map());
- }, [settings]);
+ setPanX(settings.initialPanX)
+ setPanY(settings.initialPanY)
+ setZoom(settings.initialZoom)
+ setNodePositions(new Map())
+ }, [settings])
// Auto-fit graph to viewport
const autoFitToViewport = useCallback(
@@ -275,74 +275,74 @@ export function useGraphInteractions(
viewportHeight: number,
options?: { occludedRightPx?: number; animate?: boolean },
) => {
- if (nodes.length === 0) return;
+ if (nodes.length === 0) return
// Find the bounds of all nodes
- let minX = Number.POSITIVE_INFINITY;
- let maxX = Number.NEGATIVE_INFINITY;
- let minY = Number.POSITIVE_INFINITY;
- let maxY = Number.NEGATIVE_INFINITY;
+ let minX = Number.POSITIVE_INFINITY
+ let maxX = Number.NEGATIVE_INFINITY
+ let minY = Number.POSITIVE_INFINITY
+ let maxY = Number.NEGATIVE_INFINITY
nodes.forEach((node) => {
- minX = Math.min(minX, node.x - node.size / 2);
- maxX = Math.max(maxX, node.x + node.size / 2);
- minY = Math.min(minY, node.y - node.size / 2);
- maxY = Math.max(maxY, node.y + node.size / 2);
- });
+ minX = Math.min(minX, node.x - node.size / 2)
+ maxX = Math.max(maxX, node.x + node.size / 2)
+ minY = Math.min(minY, node.y - node.size / 2)
+ maxY = Math.max(maxY, node.y + node.size / 2)
+ })
// Calculate the center of the content
- const contentCenterX = (minX + maxX) / 2;
- const contentCenterY = (minY + maxY) / 2;
+ const contentCenterX = (minX + maxX) / 2
+ const contentCenterY = (minY + maxY) / 2
// Calculate the size of the content
- const contentWidth = maxX - minX;
- const contentHeight = maxY - minY;
+ const contentWidth = maxX - minX
+ const contentHeight = maxY - minY
// Add padding (20% on each side)
- const paddingFactor = 1.4;
- const paddedWidth = contentWidth * paddingFactor;
- const paddedHeight = contentHeight * paddingFactor;
+ const paddingFactor = 1.4
+ const paddedWidth = contentWidth * paddingFactor
+ const paddedHeight = contentHeight * paddingFactor
// Account for occluded area on the right (e.g., chat panel)
- const occludedRightPx = Math.max(0, options?.occludedRightPx ?? 0);
- const availableWidth = Math.max(1, viewportWidth - occludedRightPx);
+ const occludedRightPx = Math.max(0, options?.occludedRightPx ?? 0)
+ const availableWidth = Math.max(1, viewportWidth - occludedRightPx)
// Calculate the zoom needed to fit the content within available width
- const zoomX = availableWidth / paddedWidth;
- const zoomY = viewportHeight / paddedHeight;
- const newZoom = Math.min(Math.max(0.05, Math.min(zoomX, zoomY)), 3);
+ const zoomX = availableWidth / paddedWidth
+ const zoomY = viewportHeight / paddedHeight
+ const newZoom = Math.min(Math.max(0.05, Math.min(zoomX, zoomY)), 3)
// Calculate pan to center the content within available area
- const availableCenterX = availableWidth / 2;
- const newPanX = availableCenterX - contentCenterX * newZoom;
- const newPanY = viewportHeight / 2 - contentCenterY * newZoom;
+ const availableCenterX = availableWidth / 2
+ const newPanX = availableCenterX - contentCenterX * newZoom
+ const newPanY = viewportHeight / 2 - contentCenterY * newZoom
// Apply the new view (optional animation)
if (options?.animate) {
- const steps = 8;
- const durationMs = 160; // snappy
- const intervalMs = Math.max(1, Math.floor(durationMs / steps));
- const startZoom = zoom;
- const startPanX = panX;
- const startPanY = panY;
- let i = 0;
- const ease = (t: number) => 1 - (1 - t) ** 2; // ease-out quad
+ const steps = 8
+ const durationMs = 160 // snappy
+ const intervalMs = Math.max(1, Math.floor(durationMs / steps))
+ const startZoom = zoom
+ const startPanX = panX
+ const startPanY = panY
+ let i = 0
+ const ease = (t: number) => 1 - (1 - t) ** 2 // ease-out quad
const timer = setInterval(() => {
- i++;
- const t = ease(i / steps);
- setZoom(startZoom + (newZoom - startZoom) * t);
- setPanX(startPanX + (newPanX - startPanX) * t);
- setPanY(startPanY + (newPanY - startPanY) * t);
- if (i >= steps) clearInterval(timer);
- }, intervalMs);
+ i++
+ const t = ease(i / steps)
+ setZoom(startZoom + (newZoom - startZoom) * t)
+ setPanX(startPanX + (newPanX - startPanX) * t)
+ setPanY(startPanY + (newPanY - startPanY) * t)
+ if (i >= steps) clearInterval(timer)
+ }, intervalMs)
} else {
- setZoom(newZoom);
- setPanX(newPanX);
- setPanY(newPanY);
+ setZoom(newZoom)
+ setPanX(newPanX)
+ setPanY(newPanY)
}
},
[zoom, panX, panY],
- );
+ )
// Touch gesture handlers for mobile pinch-to-zoom
const handleTouchStart = useCallback((e: React.TouchEvent) => {
@@ -350,117 +350,117 @@ export function useGraphInteractions(
id: touch.identifier,
x: touch.clientX,
y: touch.clientY,
- }));
+ }))
if (touches.length >= 2) {
// Start gesture with two or more fingers
- const touch1 = touches[0]!;
- const touch2 = touches[1]!;
+ const touch1 = touches[0]!
+ const touch2 = touches[1]!
const distance = Math.sqrt(
(touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2,
- );
+ )
const center = {
x: (touch1.x + touch2.x) / 2,
y: (touch1.y + touch2.y) / 2,
- };
+ }
setTouchState({
touches,
lastDistance: distance,
lastCenter: center,
isGesturing: true,
- });
+ })
} else {
- setTouchState((prev) => ({ ...prev, touches, isGesturing: false }));
+ setTouchState((prev) => ({ ...prev, touches, isGesturing: false }))
}
- }, []);
+ }, [])
const handleTouchMove = useCallback(
(e: React.TouchEvent) => {
- e.preventDefault();
+ e.preventDefault()
const touches = Array.from(e.touches).map((touch) => ({
id: touch.identifier,
x: touch.clientX,
y: touch.clientY,
- }));
+ }))
if (touches.length >= 2 && touchState.isGesturing) {
- const touch1 = touches[0]!;
- const touch2 = touches[1]!;
+ const touch1 = touches[0]!
+ const touch2 = touches[1]!
const distance = Math.sqrt(
(touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2,
- );
+ )
const center = {
x: (touch1.x + touch2.x) / 2,
y: (touch1.y + touch2.y) / 2,
- };
+ }
// Calculate zoom change based on pinch distance change
- const distanceChange = distance / touchState.lastDistance;
- const newZoom = Math.max(0.05, Math.min(3, zoom * distanceChange));
+ const distanceChange = distance / touchState.lastDistance
+ const newZoom = Math.max(0.05, Math.min(3, zoom * distanceChange))
// Get canvas bounds for center calculation
- const canvas = e.currentTarget as HTMLElement;
- const rect = canvas.getBoundingClientRect();
- const centerX = center.x - rect.left;
- const centerY = center.y - rect.top;
+ const canvas = e.currentTarget as HTMLElement
+ const rect = canvas.getBoundingClientRect()
+ const centerX = center.x - rect.left
+ const centerY = center.y - rect.top
// Calculate the world position of the pinch center
- const worldX = (centerX - panX) / zoom;
- const worldY = (centerY - panY) / zoom;
+ const worldX = (centerX - panX) / zoom
+ const worldY = (centerY - panY) / zoom
// Calculate new pan to keep the pinch center stationary
- const newPanX = centerX - worldX * newZoom;
- const newPanY = centerY - worldY * newZoom;
+ const newPanX = centerX - worldX * newZoom
+ const newPanY = centerY - worldY * newZoom
// Calculate pan change based on center movement
- const centerDx = center.x - touchState.lastCenter.x;
- const centerDy = center.y - touchState.lastCenter.y;
+ const centerDx = center.x - touchState.lastCenter.x
+ const centerDy = center.y - touchState.lastCenter.y
- setZoom(newZoom);
- setPanX(newPanX + centerDx);
- setPanY(newPanY + centerDy);
+ setZoom(newZoom)
+ setPanX(newPanX + centerDx)
+ setPanY(newPanY + centerDy)
setTouchState({
touches,
lastDistance: distance,
lastCenter: center,
isGesturing: true,
- });
+ })
} else if (touches.length === 1 && !touchState.isGesturing && isPanning) {
// Single finger pan (only if not in gesture mode)
- const touch = touches[0]!;
- const newPanX = touch.x - panStart.x;
- const newPanY = touch.y - panStart.y;
- setPanX(newPanX);
- setPanY(newPanY);
+ const touch = touches[0]!
+ const newPanX = touch.x - panStart.x
+ const newPanY = touch.y - panStart.y
+ setPanX(newPanX)
+ setPanY(newPanY)
}
},
[touchState, zoom, panX, panY, isPanning, panStart],
- );
+ )
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
const touches = Array.from(e.touches).map((touch) => ({
id: touch.identifier,
x: touch.clientX,
y: touch.clientY,
- }));
+ }))
if (touches.length < 2) {
- setTouchState((prev) => ({ ...prev, touches, isGesturing: false }));
+ setTouchState((prev) => ({ ...prev, touches, isGesturing: false }))
} else {
- setTouchState((prev) => ({ ...prev, touches }));
+ setTouchState((prev) => ({ ...prev, touches }))
}
if (touches.length === 0) {
- setIsPanning(false);
+ setIsPanning(false)
}
- }, []);
+ }, [])
// Center viewport on a specific world position (with animation)
const centerViewportOn = useCallback(
@@ -471,63 +471,63 @@ export function useGraphInteractions(
viewportHeight: number,
animate = true,
) => {
- const newPanX = viewportWidth / 2 - worldX * zoom;
- const newPanY = viewportHeight / 2 - worldY * zoom;
+ const newPanX = viewportWidth / 2 - worldX * zoom
+ const newPanY = viewportHeight / 2 - worldY * zoom
if (animate && !isAnimating) {
- animateToViewState(newPanX, newPanY, zoom, 400);
+ animateToViewState(newPanX, newPanY, zoom, 400)
} else {
- setPanX(newPanX);
- setPanY(newPanY);
+ setPanX(newPanX)
+ setPanY(newPanY)
}
},
[zoom, isAnimating, animateToViewState],
- );
+ )
// Node interaction handlers
const handleNodeHover = useCallback((nodeId: string | null) => {
- setHoveredNode(nodeId);
- }, []);
+ setHoveredNode(nodeId)
+ }, [])
const handleNodeClick = useCallback(
(nodeId: string) => {
- setSelectedNode(selectedNode === nodeId ? null : nodeId);
+ setSelectedNode(selectedNode === nodeId ? null : nodeId)
},
[selectedNode],
- );
+ )
const handleDoubleClick = useCallback(
(e: React.MouseEvent) => {
// Calculate new zoom (zoom in by 1.5x)
- const zoomFactor = 1.5;
- const newZoom = Math.min(3, zoom * zoomFactor);
+ const zoomFactor = 1.5
+ const newZoom = Math.min(3, zoom * zoomFactor)
// Get mouse position relative to the container
- let mouseX = e.clientX;
- let mouseY = e.clientY;
+ let mouseX = e.clientX
+ let mouseY = e.clientY
// Try to get the container bounds to make coordinates relative to the graph container
- const target = e.currentTarget;
+ const target = e.currentTarget
if (target && "getBoundingClientRect" in target) {
- const rect = target.getBoundingClientRect();
- mouseX = e.clientX - rect.left;
- mouseY = e.clientY - rect.top;
+ const rect = target.getBoundingClientRect()
+ mouseX = e.clientX - rect.left
+ mouseY = e.clientY - rect.top
}
// Calculate the world position of the clicked point
- const worldX = (mouseX - panX) / zoom;
- const worldY = (mouseY - panY) / zoom;
+ const worldX = (mouseX - panX) / zoom
+ const worldY = (mouseY - panY) / zoom
// Calculate new pan to keep the clicked point in the same screen position
- const newPanX = mouseX - worldX * newZoom;
- const newPanY = mouseY - worldY * newZoom;
+ const newPanX = mouseX - worldX * newZoom
+ const newPanY = mouseY - worldY * newZoom
- setZoom(newZoom);
- setPanX(newPanX);
- setPanY(newPanY);
+ setZoom(newZoom)
+ setPanX(newPanX)
+ setPanY(newPanY)
},
[zoom, panX, panY],
- );
+ )
return {
// State
@@ -560,5 +560,5 @@ export function useGraphInteractions(
autoFitToViewport,
centerViewportOn,
setSelectedNode,
- };
+ }
}
diff --git a/packages/memory-graph/src/index.tsx b/packages/memory-graph/src/index.tsx
index 6e5c882f..f32f29bf 100644
--- a/packages/memory-graph/src/index.tsx
+++ b/packages/memory-graph/src/index.tsx
@@ -1,25 +1,25 @@
// Export the main component
-export { MemoryGraph } from "./components/memory-graph";
+export { MemoryGraph } from "./components/memory-graph"
// Export style injector for manual use if needed
-export { injectStyles } from "./lib/inject-styles";
+export { injectStyles } from "./lib/inject-styles"
// Export types for consumers
-export type { MemoryGraphProps } from "./types";
+export type { MemoryGraphProps } from "./types"
export type {
DocumentWithMemories,
MemoryEntry,
DocumentsResponse,
-} from "./api-types";
+} from "./api-types"
export type {
GraphNode,
GraphEdge,
MemoryRelation,
-} from "./types";
+} from "./types"
// Export theme system for custom theming
-export { themeContract, defaultTheme } from "./styles/theme.css";
-export { sprinkles } from "./styles/sprinkles.css";
-export type { Sprinkles } from "./styles/sprinkles.css";
+export { themeContract, defaultTheme } from "./styles/theme.css"
+export { sprinkles } from "./styles/sprinkles.css"
+export type { Sprinkles } from "./styles/sprinkles.css"
diff --git a/packages/memory-graph/src/lib/inject-styles.ts b/packages/memory-graph/src/lib/inject-styles.ts
index 1a6bf4eb..e50b4f5e 100644
--- a/packages/memory-graph/src/lib/inject-styles.ts
+++ b/packages/memory-graph/src/lib/inject-styles.ts
@@ -4,33 +4,33 @@
*/
// This will be replaced by the build plugin with the actual CSS content
-declare const __MEMORY_GRAPH_CSS__: string;
+declare const __MEMORY_GRAPH_CSS__: string
// Track injection state
-let injected = false;
+let injected = false
/**
* Inject memory-graph styles into the document head.
* Safe to call multiple times - will only inject once.
*/
export function injectStyles(): void {
- // Only run in browser
- if (typeof document === "undefined") return;
+ // Only run in browser
+ if (typeof document === "undefined") return
- // Only inject once
- if (injected) return;
+ // Only inject once
+ if (injected) return
- // Check if already injected (e.g., by another instance)
- if (document.querySelector('style[data-memory-graph]')) {
- injected = true;
- return;
- }
+ // Check if already injected (e.g., by another instance)
+ if (document.querySelector("style[data-memory-graph]")) {
+ injected = true
+ return
+ }
- injected = true;
+ injected = true
- // Create and inject style element
- const style = document.createElement("style");
- style.setAttribute("data-memory-graph", "");
- style.textContent = __MEMORY_GRAPH_CSS__;
- document.head.appendChild(style);
+ // Create and inject style element
+ const style = document.createElement("style")
+ style.setAttribute("data-memory-graph", "")
+ style.textContent = __MEMORY_GRAPH_CSS__
+ document.head.appendChild(style)
}
diff --git a/packages/memory-graph/src/styles/animations.css.ts b/packages/memory-graph/src/styles/animations.css.ts
index d9430ec4..53444052 100644
--- a/packages/memory-graph/src/styles/animations.css.ts
+++ b/packages/memory-graph/src/styles/animations.css.ts
@@ -1,4 +1,4 @@
-import { keyframes } from "@vanilla-extract/css";
+import { keyframes } from "@vanilla-extract/css"
/**
* Animation keyframes
@@ -8,12 +8,12 @@ import { keyframes } from "@vanilla-extract/css";
export const fadeIn = keyframes({
from: { opacity: 0 },
to: { opacity: 1 },
-});
+})
export const fadeOut = keyframes({
from: { opacity: 1 },
to: { opacity: 0 },
-});
+})
export const slideInFromRight = keyframes({
from: {
@@ -24,7 +24,7 @@ export const slideInFromRight = keyframes({
transform: "translateX(0)",
opacity: 1,
},
-});
+})
export const slideInFromLeft = keyframes({
from: {
@@ -35,7 +35,7 @@ export const slideInFromLeft = keyframes({
transform: "translateX(0)",
opacity: 1,
},
-});
+})
export const slideInFromTop = keyframes({
from: {
@@ -46,7 +46,7 @@ export const slideInFromTop = keyframes({
transform: "translateY(0)",
opacity: 1,
},
-});
+})
export const slideInFromBottom = keyframes({
from: {
@@ -57,12 +57,12 @@ export const slideInFromBottom = keyframes({
transform: "translateY(0)",
opacity: 1,
},
-});
+})
export const spin = keyframes({
from: { transform: "rotate(0deg)" },
to: { transform: "rotate(360deg)" },
-});
+})
export const pulse = keyframes({
"0%, 100%": {
@@ -71,7 +71,7 @@ export const pulse = keyframes({
"50%": {
opacity: 0.5,
},
-});
+})
export const bounce = keyframes({
"0%, 100%": {
@@ -82,7 +82,7 @@ export const bounce = keyframes({
transform: "translateY(0)",
animationTimingFunction: "cubic-bezier(0, 0, 0.2, 1)",
},
-});
+})
export const scaleIn = keyframes({
from: {
@@ -93,7 +93,7 @@ export const scaleIn = keyframes({
transform: "scale(1)",
opacity: 1,
},
-});
+})
export const scaleOut = keyframes({
from: {
@@ -104,7 +104,7 @@ export const scaleOut = keyframes({
transform: "scale(0.95)",
opacity: 0,
},
-});
+})
export const shimmer = keyframes({
"0%": {
@@ -113,4 +113,4 @@ export const shimmer = keyframes({
"100%": {
backgroundPosition: "1000px 0",
},
-});
+})
diff --git a/packages/memory-graph/src/styles/effects.css.ts b/packages/memory-graph/src/styles/effects.css.ts
index 2a290d32..306f912c 100644
--- a/packages/memory-graph/src/styles/effects.css.ts
+++ b/packages/memory-graph/src/styles/effects.css.ts
@@ -1,5 +1,5 @@
-import { style, styleVariants } from "@vanilla-extract/css";
-import { themeContract } from "./theme.css";
+import { style, styleVariants } from "@vanilla-extract/css"
+import { themeContract } from "./theme.css"
/**
* Base glass-morphism effect
@@ -10,7 +10,7 @@ const glassBase = style({
WebkitBackdropFilter: "blur(12px)",
border: `1px solid ${themeContract.colors.document.border}`,
borderRadius: themeContract.radii.lg,
-});
+})
/**
* Glass effect variants
@@ -47,7 +47,7 @@ export const glass = styleVariants({
WebkitBackdropFilter: "blur(20px)",
},
],
-});
+})
/**
* Glass panel styles for larger containers
@@ -67,7 +67,7 @@ export const glassPanel = styleVariants({
border: `2px solid ${themeContract.colors.document.border}`,
borderRadius: themeContract.radii.xl,
},
-});
+})
/**
* Focus ring styles for accessibility
@@ -80,7 +80,7 @@ export const focusRing = style({
outlineOffset: "2px",
},
},
-});
+})
/**
* Transition presets
@@ -104,7 +104,7 @@ export const transition = styleVariants({
transform: {
transition: `transform ${themeContract.transitions.normal}`,
},
-});
+})
/**
* Hover glow effect
@@ -117,4 +117,4 @@ export const hoverGlow = style({
boxShadow: `0 0 20px ${themeContract.colors.document.glow}`,
},
},
-});
+})
diff --git a/packages/memory-graph/src/styles/global.css.ts b/packages/memory-graph/src/styles/global.css.ts
index cbe37913..ec08d959 100644
--- a/packages/memory-graph/src/styles/global.css.ts
+++ b/packages/memory-graph/src/styles/global.css.ts
@@ -1,4 +1,4 @@
-import { globalStyle } from "@vanilla-extract/css";
+import { globalStyle } from "@vanilla-extract/css"
/**
* Global CSS reset and base styles
@@ -7,39 +7,39 @@ import { globalStyle } from "@vanilla-extract/css";
// Box sizing reset
globalStyle("*, *::before, *::after", {
boxSizing: "border-box",
-});
+})
// Remove default margins
globalStyle("body, h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd", {
margin: 0,
-});
+})
// Remove list styles
globalStyle("ul[role='list'], ol[role='list']", {
listStyle: "none",
-});
+})
// Core body defaults
globalStyle("html, body", {
height: "100%",
-});
+})
globalStyle("body", {
lineHeight: 1.5,
WebkitFontSmoothing: "antialiased",
MozOsxFontSmoothing: "grayscale",
-});
+})
// Typography defaults
globalStyle("h1, h2, h3, h4, h5, h6", {
fontWeight: 500,
lineHeight: 1.25,
-});
+})
// Inherit fonts for inputs and buttons
globalStyle("input, button, textarea, select", {
font: "inherit",
-});
+})
// Remove default button styles
globalStyle("button", {
@@ -47,25 +47,25 @@ globalStyle("button", {
border: "none",
padding: 0,
cursor: "pointer",
-});
+})
// Improve media defaults
globalStyle("img, picture, video, canvas, svg", {
display: "block",
maxWidth: "100%",
-});
+})
// Remove built-in form typography styles
globalStyle("input, button, textarea, select", {
font: "inherit",
-});
+})
// Avoid text overflows
globalStyle("p, h1, h2, h3, h4, h5, h6", {
overflowWrap: "break-word",
-});
+})
// Improve text rendering
globalStyle("#root, #__next", {
isolation: "isolate",
-});
+})
diff --git a/packages/memory-graph/src/styles/index.ts b/packages/memory-graph/src/styles/index.ts
index f619c689..15dbd1e9 100644
--- a/packages/memory-graph/src/styles/index.ts
+++ b/packages/memory-graph/src/styles/index.ts
@@ -4,17 +4,23 @@
*/
// Import global styles (side effect)
-import "./global.css";
+import "./global.css"
// Theme
-export { themeContract, defaultTheme } from "./theme.css";
+export { themeContract, defaultTheme } from "./theme.css"
// Sprinkles utilities
-export { sprinkles } from "./sprinkles.css";
-export type { Sprinkles } from "./sprinkles.css";
+export { sprinkles } from "./sprinkles.css"
+export type { Sprinkles } from "./sprinkles.css"
// Animations
-export * as animations from "./animations.css";
+export * as animations from "./animations.css"
// Glass-morphism effects
-export { glass, glassPanel, focusRing, transition, hoverGlow } from "./effects.css";
+export {
+ glass,
+ glassPanel,
+ focusRing,
+ transition,
+ hoverGlow,
+} from "./effects.css"
diff --git a/packages/memory-graph/src/styles/sprinkles.css.ts b/packages/memory-graph/src/styles/sprinkles.css.ts
index ecd7a024..6cb06261 100644
--- a/packages/memory-graph/src/styles/sprinkles.css.ts
+++ b/packages/memory-graph/src/styles/sprinkles.css.ts
@@ -1,5 +1,5 @@
-import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles";
-import { themeContract } from "./theme.css";
+import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles"
+import { themeContract } from "./theme.css"
/**
* Responsive conditions for mobile-first design
@@ -122,7 +122,7 @@ const responsiveProperties = defineProperties({
// User select
userSelect: ["auto", "none", "text", "all"],
},
-});
+})
/**
* Color properties (non-responsive)
@@ -152,7 +152,7 @@ const colorProperties = defineProperties({
memoryBorder: themeContract.colors.memory.border,
},
},
-});
+})
/**
* Border properties
@@ -167,7 +167,7 @@ const borderProperties = defineProperties({
},
borderStyle: ["none", "solid", "dashed", "dotted"],
},
-});
+})
/**
* Opacity properties
@@ -188,7 +188,7 @@ const opacityProperties = defineProperties({
100: "1",
},
},
-});
+})
/**
* Combined sprinkles system
@@ -199,6 +199,6 @@ export const sprinkles = createSprinkles(
colorProperties,
borderProperties,
opacityProperties,
-);
+)
-export type Sprinkles = Parameters<typeof sprinkles>[0];
+export type Sprinkles = Parameters<typeof sprinkles>[0]
diff --git a/packages/memory-graph/src/styles/theme.css.ts b/packages/memory-graph/src/styles/theme.css.ts
index bf08e3eb..0f3f5fea 100644
--- a/packages/memory-graph/src/styles/theme.css.ts
+++ b/packages/memory-graph/src/styles/theme.css.ts
@@ -1,4 +1,4 @@
-import { createTheme, createThemeContract } from "@vanilla-extract/css";
+import { createTheme, createThemeContract } from "@vanilla-extract/css"
/**
* Theme contract defines the structure of the design system.
@@ -124,7 +124,7 @@ export const themeContract = createThemeContract({
modal: null,
tooltip: null,
},
-});
+})
/**
* Default theme implementation based on the original constants.ts colors
@@ -242,4 +242,4 @@ export const defaultTheme = createTheme(themeContract, {
modal: "30",
tooltip: "40",
},
-});
+})
diff --git a/packages/memory-graph/src/types.ts b/packages/memory-graph/src/types.ts
index 0a80df22..73d0602a 100644
--- a/packages/memory-graph/src/types.ts
+++ b/packages/memory-graph/src/types.ts
@@ -1,130 +1,148 @@
-import type { DocumentsResponse, DocumentWithMemories, MemoryEntry } from "./api-types";
+import type {
+ DocumentsResponse,
+ DocumentWithMemories,
+ MemoryEntry,
+} from "./api-types"
// Re-export for convenience
-export type { DocumentsResponse, DocumentWithMemories, MemoryEntry };
+export type { DocumentsResponse, DocumentWithMemories, MemoryEntry }
export interface GraphNode {
- id: string;
- type: "document" | "memory";
- x: number;
- y: number;
- data: DocumentWithMemories | MemoryEntry;
- size: number;
- color: string;
- isHovered: boolean;
- isDragging: boolean;
+ id: string
+ type: "document" | "memory"
+ x: number
+ y: number
+ data: DocumentWithMemories | MemoryEntry
+ size: number
+ color: string
+ isHovered: boolean
+ isDragging: boolean
}
-export type MemoryRelation = "updates" | "extends" | "derives";
+export type MemoryRelation = "updates" | "extends" | "derives"
export interface GraphEdge {
- id: string;
- source: string;
- target: string;
- similarity: number;
+ id: string
+ source: string
+ target: string
+ similarity: number
visualProps: {
- opacity: number;
- thickness: number;
- glow: number;
- pulseDuration: number;
- };
- color: string;
- edgeType: "doc-memory" | "doc-doc" | "version";
- relationType?: MemoryRelation;
+ opacity: number
+ thickness: number
+ glow: number
+ pulseDuration: number
+ }
+ color: string
+ edgeType: "doc-memory" | "doc-doc" | "version"
+ relationType?: MemoryRelation
}
export interface SpacesDropdownProps {
- selectedSpace: string;
- availableSpaces: string[];
- spaceMemoryCounts: Record<string, number>;
- onSpaceChange: (space: string) => void;
+ selectedSpace: string
+ availableSpaces: string[]
+ spaceMemoryCounts: Record<string, number>
+ onSpaceChange: (space: string) => void
}
export interface NodeDetailPanelProps {
- node: GraphNode | null;
- onClose: () => void;
- variant?: "console" | "consumer";
+ node: GraphNode | null
+ onClose: () => void
+ variant?: "console" | "consumer"
}
export interface GraphCanvasProps {
- nodes: GraphNode[];
- edges: GraphEdge[];
- panX: number;
- panY: number;
- zoom: number;
- width: number;
- height: number;
- onNodeHover: (nodeId: string | null) => void;
- onNodeClick: (nodeId: string) => void;
- onNodeDragStart: (nodeId: string, e: React.MouseEvent) => void;
- onNodeDragMove: (e: React.MouseEvent) => void;
- onNodeDragEnd: () => void;
- onPanStart: (e: React.MouseEvent) => void;
- onPanMove: (e: React.MouseEvent) => void;
- onPanEnd: () => void;
- onWheel: (e: React.WheelEvent) => void;
- onDoubleClick: (e: React.MouseEvent) => void;
- onTouchStart?: (e: React.TouchEvent) => void;
- onTouchMove?: (e: React.TouchEvent) => void;
- onTouchEnd?: (e: React.TouchEvent) => void;
- draggingNodeId: string | null;
+ nodes: GraphNode[]
+ edges: GraphEdge[]
+ panX: number
+ panY: number
+ zoom: number
+ width: number
+ height: number
+ onNodeHover: (nodeId: string | null) => void
+ onNodeClick: (nodeId: string) => void
+ onNodeDragStart: (nodeId: string, e: React.MouseEvent) => void
+ onNodeDragMove: (e: React.MouseEvent) => void
+ onNodeDragEnd: () => void
+ onPanStart: (e: React.MouseEvent) => void
+ onPanMove: (e: React.MouseEvent) => void
+ onPanEnd: () => void
+ onWheel: (e: React.WheelEvent) => void
+ onDoubleClick: (e: React.MouseEvent) => void
+ onTouchStart?: (e: React.TouchEvent) => void
+ onTouchMove?: (e: React.TouchEvent) => void
+ onTouchEnd?: (e: React.TouchEvent) => void
+ draggingNodeId: string | null
// Optional list of document IDs (customId or internal id) to highlight
- highlightDocumentIds?: string[];
+ highlightDocumentIds?: string[]
}
export interface MemoryGraphProps {
/** The documents to display in the graph */
- documents: DocumentWithMemories[];
+ documents: DocumentWithMemories[]
/** Whether the initial data is loading */
- isLoading?: boolean;
+ isLoading?: boolean
/** Error that occurred during data fetching */
- error?: Error | null;
+ error?: Error | null
/** Optional children to render when no documents exist */
- children?: React.ReactNode;
+ children?: React.ReactNode
/** Whether more data is being loaded (for pagination) */
- isLoadingMore?: boolean;
+ isLoadingMore?: boolean
/** Total number of documents loaded */
- totalLoaded?: number;
+ totalLoaded?: number
/** Whether there are more documents to load */
- hasMore?: boolean;
+ hasMore?: boolean
/** Callback to load more documents (for pagination) */
- loadMoreDocuments?: () => Promise<void>;
+ loadMoreDocuments?: () => Promise<void>
/** Show/hide the spaces filter dropdown */
- showSpacesSelector?: boolean;
+ showSpacesSelector?: boolean
/** Visual variant - "console" for full view, "consumer" for embedded */
- variant?: "console" | "consumer";
+ variant?: "console" | "consumer"
/** Optional ID for the legend component */
- legendId?: string;
+ legendId?: string
/** Document IDs to highlight in the graph */
- highlightDocumentIds?: string[];
+ highlightDocumentIds?: string[]
/** Whether highlights are currently visible */
- highlightsVisible?: boolean;
+ highlightsVisible?: boolean
/** Pixels occluded on the right side of the viewport */
- occludedRightPx?: number;
+ occludedRightPx?: number
/** Whether to auto-load more documents based on viewport visibility */
- autoLoadOnViewport?: boolean;
+ autoLoadOnViewport?: boolean
/** Theme class name to apply */
- themeClassName?: string;
+ themeClassName?: string
+
+ // External space control
+ /** Currently selected space (for controlled component) */
+ selectedSpace?: string
+ /** Callback when space selection changes (for controlled component) */
+ onSpaceChange?: (spaceId: string) => void
+
+ // Memory limit control
+ /** Maximum number of memories to display per document when a space is selected */
+ memoryLimit?: number
+
+ // Feature flags
+ /** Enable experimental features */
+ isExperimental?: boolean
}
export interface LegendProps {
- variant?: "console" | "consumer";
- nodes?: GraphNode[];
- edges?: GraphEdge[];
- isLoading?: boolean;
- hoveredNode?: string | null;
+ variant?: "console" | "consumer"
+ nodes?: GraphNode[]
+ edges?: GraphEdge[]
+ isLoading?: boolean
+ hoveredNode?: string | null
}
export interface LoadingIndicatorProps {
- isLoading: boolean;
- isLoadingMore: boolean;
- totalLoaded: number;
- variant?: "console" | "consumer";
+ isLoading: boolean
+ isLoadingMore: boolean
+ totalLoaded: number
+ variant?: "console" | "consumer"
}
export interface ControlsProps {
- onZoomIn: () => void;
- onZoomOut: () => void;
- onResetView: () => void;
- variant?: "console" | "consumer";
+ onZoomIn: () => void
+ onZoomOut: () => void
+ onResetView: () => void
+ variant?: "console" | "consumer"
}
diff --git a/packages/memory-graph/src/ui/badge.css.ts b/packages/memory-graph/src/ui/badge.css.ts
index 1af96c1d..0f8cbc92 100644
--- a/packages/memory-graph/src/ui/badge.css.ts
+++ b/packages/memory-graph/src/ui/badge.css.ts
@@ -1,6 +1,6 @@
-import { recipe, type RecipeVariants } from "@vanilla-extract/recipes";
-import { style, globalStyle } from "@vanilla-extract/css";
-import { themeContract } from "../styles/theme.css";
+import { recipe, type RecipeVariants } from "@vanilla-extract/recipes"
+import { style, globalStyle } from "@vanilla-extract/css"
+import { themeContract } from "../styles/theme.css"
/**
* Base styles for SVG icons inside badges
@@ -9,7 +9,7 @@ export const badgeIcon = style({
width: "0.75rem",
height: "0.75rem",
pointerEvents: "none",
-});
+})
/**
* Badge recipe with variants
@@ -44,14 +44,14 @@ const badgeBase = style({
borderColor: themeContract.colors.status.forgotten,
},
},
-});
+})
// Global style for SVG children
globalStyle(`${badgeBase} > svg`, {
width: "0.75rem",
height: "0.75rem",
pointerEvents: "none",
-});
+})
export const badge = recipe({
base: badgeBase,
@@ -114,6 +114,6 @@ export const badge = recipe({
defaultVariants: {
variant: "default",
},
-});
+})
-export type BadgeVariants = RecipeVariants<typeof badge>;
+export type BadgeVariants = RecipeVariants<typeof badge>
diff --git a/packages/memory-graph/src/ui/badge.tsx b/packages/memory-graph/src/ui/badge.tsx
index 0708888f..a08578c3 100644
--- a/packages/memory-graph/src/ui/badge.tsx
+++ b/packages/memory-graph/src/ui/badge.tsx
@@ -1,27 +1,20 @@
-import { Slot } from "@radix-ui/react-slot";
-import type * as React from "react";
-import { badge, type BadgeVariants } from "./badge.css";
+import { Slot } from "@radix-ui/react-slot"
+import type * as React from "react"
+import { badge, type BadgeVariants } from "./badge.css"
function Badge({
className,
variant,
asChild = false,
...props
-}: React.ComponentProps<"span"> &
- BadgeVariants & { asChild?: boolean }) {
- const Comp = asChild ? Slot : "span";
+}: React.ComponentProps<"span"> & BadgeVariants & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span"
const combinedClassName = className
? `${badge({ variant })} ${className}`
- : badge({ variant });
+ : badge({ variant })
- return (
- <Comp
- className={combinedClassName}
- data-slot="badge"
- {...props}
- />
- );
+ return <Comp className={combinedClassName} data-slot="badge" {...props} />
}
-export { Badge, badge as badgeVariants };
+export { Badge, badge as badgeVariants }
diff --git a/packages/memory-graph/src/ui/button.css.ts b/packages/memory-graph/src/ui/button.css.ts
index ad9cce8c..375d567b 100644
--- a/packages/memory-graph/src/ui/button.css.ts
+++ b/packages/memory-graph/src/ui/button.css.ts
@@ -1,6 +1,6 @@
-import { recipe, type RecipeVariants } from "@vanilla-extract/recipes";
-import { style } from "@vanilla-extract/css";
-import { themeContract } from "../styles/theme.css";
+import { recipe, type RecipeVariants } from "@vanilla-extract/recipes"
+import { style } from "@vanilla-extract/css"
+import { themeContract } from "../styles/theme.css"
/**
* Base styles for SVG icons inside buttons
@@ -14,7 +14,7 @@ export const buttonIcon = style({
height: "1rem",
},
},
-});
+})
/**
* Button recipe with variants
@@ -205,6 +205,6 @@ export const button = recipe({
variant: "default",
size: "default",
},
-});
+})
-export type ButtonVariants = RecipeVariants<typeof button>;
+export type ButtonVariants = RecipeVariants<typeof button>
diff --git a/packages/memory-graph/src/ui/button.tsx b/packages/memory-graph/src/ui/button.tsx
index 031f2cc8..89015299 100644
--- a/packages/memory-graph/src/ui/button.tsx
+++ b/packages/memory-graph/src/ui/button.tsx
@@ -1,6 +1,6 @@
-import { Slot } from "@radix-ui/react-slot";
-import type * as React from "react";
-import { button, type ButtonVariants } from "./button.css";
+import { Slot } from "@radix-ui/react-slot"
+import type * as React from "react"
+import { button, type ButtonVariants } from "./button.css"
function Button({
className,
@@ -10,21 +10,15 @@ function Button({
...props
}: React.ComponentProps<"button"> &
ButtonVariants & {
- asChild?: boolean;
+ asChild?: boolean
}) {
- const Comp = asChild ? Slot : "button";
+ const Comp = asChild ? Slot : "button"
const combinedClassName = className
? `${button({ variant, size })} ${className}`
- : button({ variant, size });
+ : button({ variant, size })
- return (
- <Comp
- className={combinedClassName}
- data-slot="button"
- {...props}
- />
- );
+ return <Comp className={combinedClassName} data-slot="button" {...props} />
}
-export { Button, button as buttonVariants };
+export { Button, button as buttonVariants }
diff --git a/packages/memory-graph/src/ui/collapsible.tsx b/packages/memory-graph/src/ui/collapsible.tsx
index 0551ffdd..f8de4e4c 100644
--- a/packages/memory-graph/src/ui/collapsible.tsx
+++ b/packages/memory-graph/src/ui/collapsible.tsx
@@ -1,11 +1,11 @@
-"use client";
+"use client"
-import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
+import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
- return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
+ return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
@@ -16,7 +16,7 @@ function CollapsibleTrigger({
data-slot="collapsible-trigger"
{...props}
/>
- );
+ )
}
function CollapsibleContent({
@@ -27,7 +27,7 @@ function CollapsibleContent({
data-slot="collapsible-content"
{...props}
/>
- );
+ )
}
-export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/packages/memory-graph/src/ui/glass-effect.css.ts b/packages/memory-graph/src/ui/glass-effect.css.ts
index 16e0fcdc..c7160d98 100644
--- a/packages/memory-graph/src/ui/glass-effect.css.ts
+++ b/packages/memory-graph/src/ui/glass-effect.css.ts
@@ -1,6 +1,6 @@
-import { style } from "@vanilla-extract/css";
-import { recipe } from "@vanilla-extract/recipes";
-import { themeContract } from "../styles/theme.css";
+import { style } from "@vanilla-extract/css"
+import { recipe } from "@vanilla-extract/recipes"
+import { themeContract } from "../styles/theme.css"
/**
* Glass menu effect container
@@ -8,7 +8,7 @@ import { themeContract } from "../styles/theme.css";
export const glassMenuContainer = style({
position: "absolute",
inset: 0,
-});
+})
/**
* Glass menu effect with customizable border radius
@@ -55,4 +55,4 @@ export const glassMenuEffect = recipe({
defaultVariants: {
rounded: "3xl",
},
-});
+})
diff --git a/packages/memory-graph/src/ui/glass-effect.tsx b/packages/memory-graph/src/ui/glass-effect.tsx
index e1908f52..5c072edf 100644
--- a/packages/memory-graph/src/ui/glass-effect.tsx
+++ b/packages/memory-graph/src/ui/glass-effect.tsx
@@ -1,11 +1,8 @@
-import {
- glassMenuContainer,
- glassMenuEffect,
-} from "./glass-effect.css";
+import { glassMenuContainer, glassMenuEffect } from "./glass-effect.css"
interface GlassMenuEffectProps {
- rounded?: "none" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full";
- className?: string;
+ rounded?: "none" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full"
+ className?: string
}
export function GlassMenuEffect({
@@ -17,5 +14,5 @@ export function GlassMenuEffect({
{/* Frosted glass effect with translucent border */}
<div className={glassMenuEffect({ rounded })} />
</div>
- );
+ )
}
diff --git a/packages/memory-graph/src/ui/heading.css.ts b/packages/memory-graph/src/ui/heading.css.ts
index 128d97a6..384e4fdb 100644
--- a/packages/memory-graph/src/ui/heading.css.ts
+++ b/packages/memory-graph/src/ui/heading.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"
/**
* Responsive heading style with bold weight
@@ -21,4 +21,4 @@ export const headingH3Bold = style({
fontSize: themeContract.typography.fontSize.base, // 16px
},
},
-});
+})
diff --git a/packages/memory-graph/src/ui/heading.tsx b/packages/memory-graph/src/ui/heading.tsx
index 65e8abc8..ed9b80ef 100644
--- a/packages/memory-graph/src/ui/heading.tsx
+++ b/packages/memory-graph/src/ui/heading.tsx
@@ -1,18 +1,16 @@
-import { Root } from "@radix-ui/react-slot";
-import { headingH3Bold } from "./heading.css";
+import { Root } from "@radix-ui/react-slot"
+import { headingH3Bold } from "./heading.css"
export function HeadingH3Bold({
className,
asChild,
...props
}: React.ComponentProps<"h3"> & { asChild?: boolean }) {
- const Comp = asChild ? Root : "h3";
+ const Comp = asChild ? Root : "h3"
const combinedClassName = className
? `${headingH3Bold} ${className}`
- : headingH3Bold;
+ : headingH3Bold
- return (
- <Comp className={combinedClassName} {...props} />
- );
+ return <Comp className={combinedClassName} {...props} />
}