diff --git a/apps/mcp/src/client.ts b/apps/mcp/src/client.ts index 2924f6fb..ace65912 100644 --- a/apps/mcp/src/client.ts +++ b/apps/mcp/src/client.ts @@ -45,52 +45,41 @@ export interface Project { documentCount?: number } -// Graph API types -export interface GraphApiMemory { +// Documents API types +export interface DocumentMemoryEntry { id: string memory: string - isStatic: boolean - isLatest: boolean - isForgotten: boolean - forgetAfter: string | null - version: number - parentMemoryId: string | null + spaceId: string + isStatic?: boolean + isLatest?: boolean + isForgotten?: boolean + forgetAfter?: string | null + forgetReason?: string | null + version?: number + parentMemoryId?: string | null + rootMemoryId?: string | null createdAt: string updatedAt: string } -export interface GraphApiDocument { +export interface DocumentWithMemories { id: string title: string | null - summary: string | null - documentType: string + summary?: string | null + type: string createdAt: string updatedAt: string - x: number - y: number - memories: GraphApiMemory[] + memoryEntries: DocumentMemoryEntry[] } -export interface GraphApiEdge { - source: string - target: string - similarity: number -} - -export interface GraphViewportResponse { - documents: GraphApiDocument[] - edges: GraphApiEdge[] - viewport: { minX: number; maxX: number; minY: number; maxY: number } - totalCount: number -} - -export interface GraphBoundsResponse { - bounds: { - minX: number - maxX: number - minY: number - maxY: number - } | null +export interface DocumentsApiResponse { + documents: DocumentWithMemories[] + pagination: { + currentPage: number + limit: number + totalItems: number + totalPages: number + } } export function getMemoryText(m: Memory): string { @@ -171,9 +160,13 @@ export class SupermemoryClient { message: `Successfully forgot memory (exact match) with ID: ${result.id}`, containerTag: this.containerTag, } - } catch (error: any) { + } catch (error: unknown) { // If not 404, it's a real error - re-throw it - if (error?.status !== 404) { + const status = + error && typeof error === "object" && "status" in error + ? (error as Record).status + : undefined + if (status !== 404) { throw error } // Otherwise continue to semantic search fallback @@ -332,53 +325,33 @@ export class SupermemoryClient { } } - // Fetch graph bounds for coordinate range - async getGraphBounds(containerTags?: string[]): Promise { - try { - const params = new URLSearchParams() - if (containerTags?.length) { - params.set("containerTags", JSON.stringify(containerTags)) - } - const url = `${this.apiUrl}/v3/graph/bounds${params.toString() ? `?${params}` : ""}` - const response = await fetch(url, { - method: "GET", - headers: { - Authorization: `Bearer ${this.bearerToken}`, - "Content-Type": "application/json", - }, - }) - if (!response.ok) { - throw Object.assign(new Error("Failed to fetch graph bounds"), { - status: response.status, - }) - } - return (await response.json()) as GraphBoundsResponse - } catch (error) { - this.handleError(error) - } - } - - // Fetch graph data for a viewport region - async getGraphViewport( - viewport: { minX: number; maxX: number; minY: number; maxY: number }, + // Fetch documents with their memory entries + async getDocuments( containerTags?: string[], + page = 1, limit = 200, - ): Promise { + ): Promise { try { - const response = await fetch(`${this.apiUrl}/v3/graph/viewport`, { + const response = await fetch(`${this.apiUrl}/v3/documents/documents`, { method: "POST", headers: { Authorization: `Bearer ${this.bearerToken}`, "Content-Type": "application/json", }, - body: JSON.stringify({ viewport, containerTags, limit }), + body: JSON.stringify({ + page, + limit, + sort: "createdAt", + order: "desc", + containerTags, + }), }) if (!response.ok) { - throw Object.assign(new Error("Failed to fetch graph viewport"), { + throw Object.assign(new Error("Failed to fetch documents"), { status: response.status, }) } - return (await response.json()) as GraphViewportResponse + return (await response.json()) as DocumentsApiResponse } catch (error) { this.handleError(error) } diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts index 17f8ecd4..d387fde9 100644 --- a/apps/mcp/src/server.ts +++ b/apps/mcp/src/server.ts @@ -311,21 +311,14 @@ export class SupermemoryMCP extends McpAgent { ? [effectiveContainerTag] : undefined - const [bounds, viewport] = await Promise.all([ - client.getGraphBounds(containerTags), - client.getGraphViewport( - { minX: 0, maxX: 1000, minY: 0, maxY: 1000 }, - containerTags, - 200, - ), - ]) + const result = await client.getDocuments(containerTags, 1, 200) - const memoryCount = viewport.documents.reduce( - (sum, d) => sum + d.memories.length, + const memoryCount = result.documents.reduce( + (sum, d) => sum + d.memoryEntries.length, 0, ) const textParts = [ - `Memory Graph: ${viewport.documents.length} documents, ${memoryCount} memories, ${viewport.edges.length} connections`, + `Memory Graph: ${result.documents.length} documents, ${memoryCount} memories`, ] if (effectiveContainerTag) { textParts.push(`Project: ${effectiveContainerTag}`) @@ -335,10 +328,8 @@ export class SupermemoryMCP extends McpAgent { content: [{ type: "text" as const, text: textParts.join(". ") }], structuredContent: { containerTag: effectiveContainerTag, - bounds: bounds.bounds, - documents: viewport.documents, - edges: viewport.edges, - totalCount: viewport.totalCount, + documents: result.documents, + totalCount: result.pagination.totalItems, }, } } catch (error) { @@ -359,20 +350,15 @@ export class SupermemoryMCP extends McpAgent { }, ) - // App-only tool for the UI to fetch additional graph data + // App-only tool for the UI to fetch additional documents (pagination) registerAppTool( this.server, "fetch-graph-data", { - description: "Fetch graph data for a viewport region", + description: "Fetch documents with memories for graph display", inputSchema: z.object({ containerTag: z.string().optional(), - viewport: z.object({ - minX: z.number(), - maxX: z.number(), - minY: z.number(), - maxY: z.number(), - }), + page: z.number().optional().default(1), limit: z.number().optional().default(200), }), _meta: { @@ -385,12 +371,7 @@ export class SupermemoryMCP extends McpAgent { // @ts-expect-error - zod type inference issue with MCP SDK async (args: { containerTag?: string - viewport: { - minX: number - maxX: number - minY: number - maxY: number - } + page?: number limit?: number }) => { try { @@ -400,9 +381,9 @@ export class SupermemoryMCP extends McpAgent { const containerTags = effectiveContainerTag ? [effectiveContainerTag] : undefined - const data = await client.getGraphViewport( - args.viewport, + const data = await client.getDocuments( containerTags, + args.page, args.limit, ) diff --git a/apps/mcp/src/ui/mcp-app.ts b/apps/mcp/src/ui/mcp-app.ts index 1802cf82..6939edcc 100644 --- a/apps/mcp/src/ui/mcp-app.ts +++ b/apps/mcp/src/ui/mcp-app.ts @@ -27,13 +27,18 @@ interface GraphApiMemory { id: string memory: string isStatic: boolean + spaceId: string isLatest: boolean isForgotten: boolean forgetAfter: string | null + forgetReason: string | null version: number parentMemoryId: string | null + rootMemoryId: string | null createdAt: string updatedAt: string + relation?: "updates" | "extends" | "derives" | null + memoryRelations?: Record | null } interface GraphApiDocument { @@ -43,22 +48,12 @@ interface GraphApiDocument { documentType: string createdAt: string updatedAt: string - x: number - y: number memories: GraphApiMemory[] } -interface GraphApiEdge { - source: string - target: string - similarity: number -} - interface ToolResultData { containerTag?: string - bounds: { minX: number; maxX: number; minY: number; maxY: number } | null documents: GraphApiDocument[] - edges: GraphApiEdge[] totalCount: number } @@ -91,8 +86,7 @@ type GraphNode = MemoryNode | DocumentNode interface GraphLink extends LinkObject { source: string | GraphNode target: string | GraphNode - edgeType: "doc-memory" | "version" | "similarity" - similarity?: number + edgeType: "derives" | "updates" | "extends" } // ============================================================================= @@ -107,14 +101,14 @@ const MEMORY_BORDER = { const EDGE_COLORS = { dark: { - "doc-memory": "#4A5568", - version: "#8B5CF6", - similarity: "#00D4B8", + derives: "#38BDF8", + updates: "#A78BFA", + extends: "#2DD4BF", }, light: { - "doc-memory": "#A0AEC0", - version: "#8B5CF6", - similarity: "#0D9488", + derives: "#7DD3FC", + updates: "#A78BFA", + extends: "#5EEAD4", }, } @@ -157,33 +151,20 @@ function getMemoryBorderColor(mem: GraphApiMemory): string { return MEMORY_BORDER.default } -function normalizeDocCoordinates( - documents: GraphApiDocument[], -): GraphApiDocument[] { - if (documents.length <= 1) return documents - - let minX = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - for (const doc of documents) { - minX = Math.min(minX, doc.x) - maxX = Math.max(maxX, doc.x) - minY = Math.min(minY, doc.y) - maxY = Math.max(maxY, doc.y) +/** Simple hash to get deterministic initial positions from doc ID */ +function hashCode(s: string): number { + let h = 0 + for (let i = 0; i < s.length; i++) { + h = (Math.imul(31, h) + s.charCodeAt(i)) | 0 } + return h +} - const rangeX = maxX - minX || 1 - const rangeY = maxY - minY || 1 - // Small spread so documents start near each other. - // The force simulation will naturally separate them. - const SPREAD = 50 - - return documents.map((doc) => ({ - ...doc, - x: ((doc.x - minX) / rangeX - 0.5) * SPREAD, - y: ((doc.y - minY) / rangeY - 0.5) * SPREAD, - })) +function initialPosition(id: string, spread: number): { x: number; y: number } { + const h = hashCode(id) + const angle = ((h & 0xffff) / 0xffff) * Math.PI * 2 + const radius = (((h >>> 16) & 0xffff) / 0xffff) * spread + return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius } } function transformData(data: ToolResultData): { @@ -192,11 +173,18 @@ function transformData(data: ToolResultData): { } { const nodes: GraphNode[] = [] const links: GraphLink[] = [] + const SPREAD = 50 + + // Pre-populate all node IDs so edge targets are always resolvable + // regardless of iteration order. const nodeIds = new Set() + for (const doc of data.documents) { + nodeIds.add(doc.id) + for (const mem of doc.memories) nodeIds.add(mem.id) + } - const normalizedDocs = normalizeDocCoordinates(data.documents) - - for (const doc of normalizedDocs) { + for (const doc of data.documents) { + const pos = initialPosition(doc.id, SPREAD) nodes.push({ id: doc.id, nodeType: "document", @@ -205,10 +193,9 @@ function transformData(data: ToolResultData): { docType: doc.documentType, createdAt: doc.createdAt, memoryCount: doc.memories.length, - x: doc.x, - y: doc.y, + x: pos.x, + y: pos.y, } as DocumentNode) - nodeIds.add(doc.id) const memCount = doc.memories.length for (let i = 0; i < memCount; i++) { @@ -227,34 +214,38 @@ function transformData(data: ToolResultData): { parentMemoryId: mem.parentMemoryId, createdAt: mem.createdAt, borderColor: getMemoryBorderColor(mem), - x: doc.x + Math.cos(angle) * CLUSTER_SPREAD, - y: doc.y + Math.sin(angle) * CLUSTER_SPREAD, + x: pos.x + Math.cos(angle) * CLUSTER_SPREAD, + y: pos.y + Math.sin(angle) * CLUSTER_SPREAD, } as MemoryNode) - nodeIds.add(mem.id) - // Doc-memory link - links.push({ source: doc.id, target: mem.id, edgeType: "doc-memory" }) + // Derives link (doc -> memory) + links.push({ source: doc.id, target: mem.id, edgeType: "derives" }) - // Version chain link - if (mem.parentMemoryId && nodeIds.has(mem.parentMemoryId)) { - links.push({ - source: mem.parentMemoryId, - target: mem.id, - edgeType: "version", - }) + // Memory-to-memory relation edges from backend data. + // Uses memoryRelations as primary source, falls back to parentMemoryId. + // Keep in sync with packages/memory-graph/src/hooks/use-graph-data.ts + let relations: Record = {} + if ( + // Defensive: data comes from structuredContent cast, may be unexpected type + mem.memoryRelations && + typeof mem.memoryRelations === "object" && + Object.keys(mem.memoryRelations).length > 0 + ) { + relations = mem.memoryRelations + } else if (mem.parentMemoryId) { + relations = { [mem.parentMemoryId]: "updates" } } - } - } - // Similarity edges from API - for (const edge of data.edges) { - if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) { - links.push({ - source: edge.source, - target: edge.target, - edgeType: "similarity", - similarity: edge.similarity, - }) + for (const [targetId, relationType] of Object.entries(relations)) { + if (!nodeIds.has(targetId)) continue + const edgeType = + relationType === "updates" || + relationType === "extends" || + relationType === "derives" + ? relationType + : "updates" + links.push({ source: targetId, target: mem.id, edgeType }) + } } } @@ -317,7 +308,7 @@ function drawDocumentNode( // ============================================================================= function getLinkColor(link: GraphLink): string { const palette = isDark ? EDGE_COLORS.dark : EDGE_COLORS.light - return palette[link.edgeType] || palette["doc-memory"] + return palette[link.edgeType] || palette["derives"] } const graph = new ForceGraph(container) @@ -370,18 +361,17 @@ const graph = new ForceGraph(container) }, ) .linkWidth((link: GraphLink) => { - if (link.edgeType === "version") return 2 - if (link.edgeType === "similarity") - return 0.5 + (link.similarity || 0) * 1.5 + if (link.edgeType === "updates") return 2 + if (link.edgeType === "extends") return 0.5 return 1 }) .linkColor(getLinkColor) .linkLineDash((link: GraphLink) => { - if (link.edgeType === "similarity") return [4, 2] + if (link.edgeType === "extends") return [4, 2] return null as unknown as number[] }) .linkDirectionalArrowLength((link: GraphLink) => - link.edgeType === "version" ? 4 : 0, + link.edgeType === "updates" ? 4 : 0, ) .linkDirectionalArrowRelPos(1) .onNodeClick(handleNodeClick) @@ -395,11 +385,11 @@ const graph = new ForceGraph(container) .d3Force( "link", forceLink() - .distance((l: GraphLink) => (l.edgeType === "doc-memory" ? 40 : 80)) + .distance((l: GraphLink) => (l.edgeType === "derives" ? 40 : 80)) .strength((l: GraphLink) => { - if (l.edgeType === "doc-memory") return 0.8 - if (l.edgeType === "version") return 1.0 - return (l.similarity || 0.3) * 0.3 + if (l.edgeType === "derives") return 0.8 + if (l.edgeType === "updates") return 1.0 + return 0.15 // extends }), ) .d3Force("collide", forceCollide(18)) diff --git a/apps/memory-graph-playground/next-env.d.ts b/apps/memory-graph-playground/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/apps/memory-graph-playground/next-env.d.ts +++ b/apps/memory-graph-playground/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/memory-graph-playground/src/app/page.tsx b/apps/memory-graph-playground/src/app/page.tsx index 0682905a..68a6f945 100644 --- a/apps/memory-graph-playground/src/app/page.tsx +++ b/apps/memory-graph-playground/src/app/page.tsx @@ -1,10 +1,13 @@ "use client" -import { useState, useCallback } from "react" +import { useState, useCallback, useMemo } from "react" import { MemoryGraph, type DocumentWithMemories, + type GraphApiDocument, + type GraphApiMemory, } from "@supermemory/memory-graph" +import { generateMockGraphData } from "@supermemory/memory-graph/mock-data" interface DocumentsResponse { documents: DocumentWithMemories[] @@ -16,24 +19,50 @@ interface DocumentsResponse { } } +/** Convert the external API format to the internal graph format */ +function toGraphDocuments(docs: DocumentWithMemories[]): GraphApiDocument[] { + return docs.map((doc) => ({ + id: doc.id, + title: doc.title, + summary: doc.summary ?? null, + documentType: doc.documentType, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + memories: doc.memories.map( + (mem): GraphApiMemory => ({ + id: mem.id, + memory: mem.content, + isStatic: mem.isStatic ?? false, + spaceId: mem.spaceId ?? "", + isLatest: mem.isLatest ?? true, + isForgotten: mem.isForgotten ?? false, + forgetAfter: mem.forgetAfter ?? null, + forgetReason: mem.forgetReason ?? null, + version: mem.version ?? 1, + parentMemoryId: mem.parentMemoryId ?? null, + rootMemoryId: mem.rootMemoryId ?? null, + createdAt: mem.createdAt, + updatedAt: mem.updatedAt, + }), + ), + })) +} + export default function Home() { const [apiKey, setApiKey] = useState("") const [documents, setDocuments] = useState([]) const [isLoading, setIsLoading] = useState(false) - const [isLoadingMore, setIsLoadingMore] = useState(false) const [error, setError] = useState(null) - const [hasMore, setHasMore] = useState(false) - const [currentPage, setCurrentPage] = useState(0) const [showGraph, setShowGraph] = useState(false) - - // State for controlled space selection - const [selectedSpace, setSelectedSpace] = useState("all") + const [stressTestCount, setStressTestCount] = useState(0) // State for slideshow const [isSlideshowActive, setIsSlideshowActive] = useState(false) - const [currentSlideshowNode, setCurrentSlideshowNode] = useState< - string | null - >(null) + + // Mock data for stress testing + const [mockData, setMockData] = useState<{ + documents: GraphApiDocument[] + } | null>(null) const PAGE_SIZE = 500 @@ -43,8 +72,6 @@ export default function Home() { if (page === 1) { setIsLoading(true) - } else { - setIsLoadingMore(true) } setError(null) @@ -76,43 +103,37 @@ export default function Home() { setDocuments(data.documents) } - setCurrentPage(data.pagination.currentPage) - setHasMore(data.pagination.currentPage < data.pagination.totalPages) setShowGraph(true) + setMockData(null) + setStressTestCount(0) } catch (err) { setError(err instanceof Error ? err : new Error("Unknown error")) } finally { setIsLoading(false) - setIsLoadingMore(false) } }, [apiKey], ) - const loadMoreDocuments = useCallback(async () => { - if (hasMore && !isLoadingMore) { - await fetchDocuments(currentPage + 1, true) - } - }, [hasMore, isLoadingMore, currentPage, fetchDocuments]) - const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (apiKey) { setDocuments([]) - setCurrentPage(0) - setSelectedSpace("all") fetchDocuments(1) } } - // Handle space change - const handleSpaceChange = useCallback((spaceId: string) => { - setSelectedSpace(spaceId) - }, []) - - // Reset to defaults - const handleReset = () => { - setSelectedSpace("all") + const handleStressTest = (count: number) => { + const data = generateMockGraphData({ + documentCount: count, + memoriesPerDoc: [2, 5], + seed: 12345, + }) + setMockData({ documents: data.documents }) + setDocuments([]) + setStressTestCount(count) + setShowGraph(true) + setError(null) } // Toggle slideshow @@ -122,16 +143,22 @@ export default function Home() { // Handle slideshow node change const handleSlideshowNodeChange = useCallback((nodeId: string | null) => { - // Track which node is being shown in slideshow - setCurrentSlideshowNode(nodeId) console.log("Slideshow showing node:", nodeId) }, []) - // Handle slideshow stop (when user clicks outside) + // Handle slideshow stop const handleSlideshowStop = useCallback(() => { setIsSlideshowActive(false) }, []) + // Convert real documents to graph format + const graphDocuments = useMemo(() => { + if (mockData) return mockData.documents + return toGraphDocuments(documents) + }, [documents, mockData]) + + const displayCount = mockData ? stressTestCount : documents.length + return (
{/* Header */} @@ -165,68 +192,65 @@ export default function Home() {
- {/* State Display Panel - For Testing */} - {showGraph && ( -
-
-
-
- Selected Space: - {selectedSpace} -
-
- Documents: - - {documents.length} - -
+ {/* Controls Panel */} +
+
+
+
+ Documents: + {displayCount}
-
+ {stressTestCount > 0 && ( + + Stress Test Mode + + )} +
+
+ {/* Stress test buttons */} + Stress Test: + {[50, 100, 200, 500].map((count) => ( -
- -
+ {isSlideshowActive ? ( + + ) : ( + + )} + + Slideshow +
- )} +
{/* Main content */}
@@ -239,6 +263,7 @@ export default function Home() { fill="none" viewBox="0 0 24 24" stroke="currentColor" + aria-hidden="true" >

- Enter your Supermemory API key above to visualize your memory - graph. + Enter your API key above, or click a stress test button to + generate mock data.

Features to test:

    -
  • ✨ Search and filter by spaces
  • -
  • ✨ Arrow key navigation in spaces dropdown
  • Pan and zoom the graph
  • Click on nodes to see details
  • Drag nodes around
  • -
  • Filter by space
  • -
  • Pagination loads more documents
  • +
  • Arrow key navigation
  • +
  • Stress test with 50-500 documents
  • +
  • FPS counter (shown during stress tests)
@@ -274,20 +298,12 @@ export default function Home() { ) : (
0} isSlideshowActive={isSlideshowActive} onSlideshowNodeChange={handleSlideshowNodeChange} onSlideshowStop={handleSlideshowStop} diff --git a/apps/web/components/graph-layout-view.tsx b/apps/web/components/graph-layout-view.tsx index 180ce642..44961717 100644 --- a/apps/web/components/graph-layout-view.tsx +++ b/apps/web/components/graph-layout-view.tsx @@ -3,7 +3,7 @@ import { memo, useCallback, useRef } from "react" import { useQueryState } from "nuqs" import Image from "next/image" -import { MemoryGraph } from "./memory-graph/memory-graph" +import { MemoryGraph } from "./memory-graph" import { useProject } from "@/stores" import { useGraphHighlights } from "@/stores/highlights" import { Button } from "@ui/components/button" diff --git a/apps/web/components/memory-graph/api-types.ts b/apps/web/components/memory-graph/api-types.ts deleted file mode 100644 index 0ebc86ee..00000000 --- a/apps/web/components/memory-graph/api-types.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Standalone TypeScript types for Memory Graph -// 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 | 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 | null - spaceContainerTag?: string | null - // Version chain fields - updatesMemoryId?: string | null - nextVersionId?: string | null - relation?: "updates" | "extends" | "derives" | null - // Memory status fields - isForgotten?: boolean - forgetAfter?: Date | string | null - isLatest?: boolean - // Space/container fields - spaceId?: string | null - // Legacy fields - memory?: string | null - memoryRelations?: Array<{ - 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 | null - processingMetadata?: Record | 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[] - pagination: { - currentPage: number - limit: number - totalItems: number - totalPages: number - } -} diff --git a/apps/web/components/memory-graph/canvas/renderer.ts b/apps/web/components/memory-graph/canvas/renderer.ts deleted file mode 100644 index 7d9cc3c3..00000000 --- a/apps/web/components/memory-graph/canvas/renderer.ts +++ /dev/null @@ -1,681 +0,0 @@ -import type { ViewportState } from "./viewport" -import type { GraphNode, GraphEdge, DocumentNodeData } from "../types" - -export interface RenderState { - selectedNodeId: string | null - hoveredNodeId: string | null - highlightIds: Set - dimProgress: number -} - -export function renderFrame( - ctx: CanvasRenderingContext2D, - nodes: GraphNode[], - edges: GraphEdge[], - viewport: ViewportState, - width: number, - height: number, - state: RenderState, - nodeMap: Map, -): void { - ctx.clearRect(0, 0, width, height) - drawDocDocLines(ctx, nodes, viewport, width, height) - drawEdges(ctx, edges, viewport, width, height, state, nodeMap) - drawNodes(ctx, nodes, viewport, width, height, state) -} - -// Connect each visible doc to its 2 nearest neighbors -function drawDocDocLines( - ctx: CanvasRenderingContext2D, - nodes: GraphNode[], - viewport: ViewportState, - width: number, - height: number, -): void { - const docs: { x: number; y: number }[] = [] - for (const n of nodes) { - if (n.type !== "document") continue - const s = viewport.worldToScreen(n.x, n.y) - if (s.x > -100 && s.x < width + 100 && s.y > -100 && s.y < height + 100) { - docs.push(s) - } - } - if (docs.length < 2) return - - ctx.strokeStyle = "#8DA3F4" - ctx.lineWidth = 1 - ctx.globalAlpha = 0.3 - ctx.setLineDash([4, 6]) - ctx.beginPath() - - // Deduplicate: only draw line when i < neighbor index - for (let i = 0; i < docs.length; i++) { - const d = docs[i]! - let best1 = -1 - let best2 = -1 - let dist1 = Number.POSITIVE_INFINITY - let dist2 = Number.POSITIVE_INFINITY - - for (let j = 0; j < docs.length; j++) { - if (j === i) continue - const dx = docs[j]!.x - d.x - const dy = docs[j]!.y - d.y - const dist = dx * dx + dy * dy - if (dist < dist1) { - best2 = best1 - dist2 = dist1 - best1 = j - dist1 = dist - } else if (dist < dist2) { - best2 = j - dist2 = dist - } - } - - if (best1 >= 0 && i < best1) { - ctx.moveTo(d.x, d.y) - ctx.lineTo(docs[best1]!.x, docs[best1]!.y) - } - if (best2 >= 0 && i < best2) { - ctx.moveTo(d.x, d.y) - ctx.lineTo(docs[best2]!.x, docs[best2]!.y) - } - } - - ctx.stroke() - ctx.setLineDash([]) - ctx.globalAlpha = 1 -} - -// --- Edges --- - -const EDGE_STYLE: Record = { - "doc-memory": { color: "#4A5568", width: 1.5 }, - version: { color: "#8B5CF6", width: 2 }, -} - -const SIM_STRONG = { color: "#00D4B8", width: 2 } as const -const SIM_MEDIUM = { color: "#6B8FBF", width: 1.5 } as const -const SIM_WEAK = { color: "#4A6A8A", width: 1 } as const - -function edgeStyle(edge: GraphEdge): { color: string; width: number } { - const preset = EDGE_STYLE[edge.edgeType] - if (preset) return preset - if (edge.similarity >= 0.9) return SIM_STRONG - if (edge.similarity >= 0.8) return SIM_MEDIUM - return SIM_WEAK -} - -// Unique key for batching: "color|width" -function batchKey(style: { color: string; width: number }): string { - return `${style.color}|${style.width}` -} - -interface PreparedEdge { - startX: number - startY: number - endX: number - endY: number - connected: boolean - style: { color: string; width: number } - isVersion: boolean - arrowSize: number -} - -function drawEdges( - ctx: CanvasRenderingContext2D, - edges: GraphEdge[], - viewport: ViewportState, - width: number, - height: number, - state: RenderState, - nodeMap: Map, -): void { - const margin = 100 - const hasDim = state.selectedNodeId !== null && state.dimProgress > 0 - - // Prepare all visible edges - const prepared: PreparedEdge[] = [] - - for (const edge of edges) { - const src = - typeof edge.source === "string" ? nodeMap.get(edge.source) : edge.source - const tgt = - typeof edge.target === "string" ? nodeMap.get(edge.target) : edge.target - if (!src || !tgt) continue - - // Skip doc-memory edges when memory dots are too small to see connections - if (edge.edgeType === "doc-memory") { - const mem = src.type === "memory" ? src : tgt - if (mem.size * viewport.zoom < 3) continue - } - - const s = viewport.worldToScreen(src.x, src.y) - const t = viewport.worldToScreen(tgt.x, tgt.y) - - if ( - (s.x < -margin && t.x < -margin) || - (s.x > width + margin && t.x > width + margin) || - (s.y < -margin && t.y < -margin) || - (s.y > height + margin && t.y > height + margin) - ) - continue - - const dx = t.x - s.x - const dy = t.y - s.y - const dist = Math.sqrt(dx * dx + dy * dy) - if (dist < 1) continue - - const ux = dx / dist - const uy = dy / dist - const sr = src.size * viewport.zoom * 0.5 - const tr = tgt.size * viewport.zoom * 0.5 - - let connected = true - if (hasDim) { - const srcId = - typeof edge.source === "string" ? edge.source : edge.source.id - const tgtId = - typeof edge.target === "string" ? edge.target : edge.target.id - connected = - srcId === state.selectedNodeId || tgtId === state.selectedNodeId - } - - prepared.push({ - startX: s.x + ux * sr, - startY: s.y + uy * sr, - endX: t.x - ux * tr, - endY: t.y - uy * tr, - connected, - style: edgeStyle(edge), - isVersion: edge.edgeType === "version", - arrowSize: - edge.edgeType === "version" ? Math.max(6, 8 * viewport.zoom) : 0, - }) - } - - // Batch by style + dim state: group into "key|connected" and "key|dimmed" - const batches = new Map() - for (const e of prepared) { - const dimKey = hasDim ? (e.connected ? "|c" : "|d") : "" - const key = batchKey(e.style) + dimKey - let batch = batches.get(key) - if (!batch) { - batch = [] - batches.set(key, batch) - } - batch.push(e) - } - - // Draw each batch in a single beginPath/stroke - ctx.setLineDash([]) - for (const [key, batch] of batches) { - const first = batch[0]! - const isDimmed = key.endsWith("|d") - - ctx.globalAlpha = isDimmed ? 1 - state.dimProgress * 0.8 : 1 - ctx.strokeStyle = first.style.color - ctx.lineWidth = first.style.width - - ctx.beginPath() - for (const e of batch) { - ctx.moveTo(e.startX, e.startY) - ctx.lineTo(e.endX, e.endY) - } - ctx.stroke() - - // Arrow heads for version edges (fill calls — unavoidable per-arrow) - const versionEdges = batch.filter((e) => e.isVersion) - if (versionEdges.length > 0) { - ctx.fillStyle = first.style.color - for (const e of versionEdges) { - drawArrowHead(ctx, e.startX, e.startY, e.endX, e.endY, e.arrowSize) - } - } - } - - ctx.globalAlpha = 1 -} - -function drawArrowHead( - ctx: CanvasRenderingContext2D, - fromX: number, - fromY: number, - toX: number, - toY: number, - size: number, -): void { - const angle = Math.atan2(toY - fromY, toX - fromX) - ctx.beginPath() - ctx.moveTo(toX, toY) - ctx.lineTo( - toX - size * Math.cos(angle - Math.PI / 6), - toY - size * Math.sin(angle - Math.PI / 6), - ) - ctx.lineTo( - toX - size * Math.cos(angle + Math.PI / 6), - toY - size * Math.sin(angle + Math.PI / 6), - ) - ctx.closePath() - ctx.fill() -} - -// --- Nodes --- - -function drawNodes( - ctx: CanvasRenderingContext2D, - nodes: GraphNode[], - viewport: ViewportState, - width: number, - height: number, - state: RenderState, -): void { - const margin = 60 - const memDots: { x: number; y: number; r: number; color: string }[] = [] - const docDots: { x: number; y: number; s: number }[] = [] - - for (const node of nodes) { - const screen = viewport.worldToScreen(node.x, node.y) - const screenSize = node.size * viewport.zoom - - // Frustum cull (use at least 2px so tiny nodes aren't culled) - const cullSize = Math.max(screenSize, 2) - if ( - screen.x + cullSize < -margin || - screen.x - cullSize > width + margin || - screen.y + cullSize < -margin || - screen.y - cullSize > height + margin - ) - continue - - const isSelected = node.id === state.selectedNodeId - const isHovered = node.id === state.hoveredNodeId - const isHighlighted = state.highlightIds.has(node.id) - - // LOD: tiny nodes → batched dots (but selected/highlighted always get full detail) - if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) { - if (node.type === "document") { - docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) }) - } else { - memDots.push({ - x: screen.x, - y: screen.y, - r: Math.max(2, screenSize * 0.45), - color: node.borderColor || "#3B73B8", - }) - } - continue - } - - let alpha = 1 - if (state.selectedNodeId && state.dimProgress > 0 && !isSelected) { - alpha = 1 - state.dimProgress * 0.7 - } - ctx.globalAlpha = alpha - - if (node.type === "document") { - drawDocumentNode( - ctx, - screen.x, - screen.y, - screenSize, - node, - isSelected, - isHovered, - isHighlighted, - ) - } else { - drawMemoryNode( - ctx, - screen.x, - screen.y, - screenSize, - node, - isSelected, - isHovered, - isHighlighted, - ) - } - - if (isSelected || isHighlighted) { - drawGlow(ctx, screen.x, screen.y, screenSize, node.type) - } - } - - const dimAlpha = - state.selectedNodeId && state.dimProgress > 0 - ? 1 - state.dimProgress * 0.7 - : 1 - - // Batch: document dots as filled squares - if (docDots.length > 0) { - ctx.fillStyle = "#1B1F24" - ctx.strokeStyle = "#2A2F36" - ctx.lineWidth = 1 - ctx.globalAlpha = dimAlpha - for (const d of docDots) { - const h = d.s * 0.5 - ctx.fillRect(d.x - h, d.y - h, d.s, d.s) - ctx.strokeRect(d.x - h, d.y - h, d.s, d.s) - } - } - - // Batch: memory dots — dark fill, then colored border strokes - if (memDots.length > 0) { - ctx.globalAlpha = dimAlpha - - // Pass 1: all dark fills in one batch - ctx.fillStyle = "#0D2034" - ctx.beginPath() - for (const d of memDots) { - ctx.moveTo(d.x + d.r, d.y) - ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) - } - ctx.fill() - - // Pass 2: colored strokes grouped by border color - ctx.lineWidth = 1.5 - const byColor = new Map() - for (const d of memDots) { - let batch = byColor.get(d.color) - if (!batch) { - batch = [] - byColor.set(d.color, batch) - } - batch.push(d) - } - for (const [color, batch] of byColor) { - ctx.strokeStyle = color - ctx.beginPath() - for (const d of batch) { - ctx.moveTo(d.x + d.r, d.y) - ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) - } - ctx.stroke() - } - } - - ctx.globalAlpha = 1 -} - -function drawDocumentNode( - ctx: CanvasRenderingContext2D, - sx: number, - sy: number, - size: number, - node: GraphNode, - isSelected: boolean, - isHovered: boolean, - isHighlighted: boolean, -): void { - const half = size * 0.5 - const cornerR = 8 * (size / 50) - - // Outer rect - ctx.fillStyle = "#1B1F24" - ctx.strokeStyle = - isSelected || isHighlighted ? "#3B73B8" : isHovered ? "#3B73B8" : "#2A2F36" - ctx.lineWidth = isSelected || isHighlighted ? 2 : 1 - roundRect(ctx, sx - half, sy - half, size, size, cornerR) - ctx.fill() - ctx.stroke() - - // Inner rect - const innerSize = size * 0.72 - const innerHalf = innerSize * 0.5 - const innerR = 6 * (size / 50) - ctx.fillStyle = "#13161A" - roundRect(ctx, sx - innerHalf, sy - innerHalf, innerSize, innerSize, innerR) - ctx.fill() - - // Icon - const iconSize = size * 0.35 - const docType = - node.type === "document" ? (node.data as DocumentNodeData).type : "text" - drawDocIcon(ctx, sx, sy, iconSize, docType || "text") -} - -function drawMemoryNode( - ctx: CanvasRenderingContext2D, - sx: number, - sy: number, - size: number, - node: GraphNode, - isSelected: boolean, - isHovered: boolean, - _isHighlighted: boolean, -): void { - const radius = size * 0.5 - - // Fill - ctx.fillStyle = isHovered ? "#112840" : "#0D2034" - drawHexagon(ctx, sx, sy, radius) - ctx.fill() - - // Stroke with time-based border color - const borderColor = node.borderColor || "#3B73B8" - ctx.strokeStyle = isSelected ? "#3B73B8" : borderColor - ctx.lineWidth = isHovered ? 2 : 1.5 - ctx.stroke() -} - -function drawGlow( - ctx: CanvasRenderingContext2D, - sx: number, - sy: number, - size: number, - nodeType: "document" | "memory", -): void { - ctx.strokeStyle = "#3B73B8" - ctx.lineWidth = 2 - ctx.setLineDash([3, 3]) - ctx.globalAlpha = 0.8 - - if (nodeType === "document") { - const glowSize = size * 1.15 - const half = glowSize * 0.5 - const r = 8 * (glowSize / 50) - roundRect(ctx, sx - half, sy - half, glowSize, glowSize, r) - } else { - drawHexagon(ctx, sx, sy, size * 0.5 * 1.15) - } - - ctx.stroke() - ctx.setLineDash([]) - ctx.globalAlpha = 1 -} - -// --- Shapes --- - -function drawHexagon( - ctx: CanvasRenderingContext2D, - cx: number, - cy: number, - radius: number, -): void { - ctx.beginPath() - for (let i = 0; i < 6; i++) { - const angle = (Math.PI / 3) * i - Math.PI / 6 - const x = cx + radius * Math.cos(angle) - const y = cy + radius * Math.sin(angle) - i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y) - } - ctx.closePath() -} - -function roundRect( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - w: number, - h: number, - r: number, -): void { - ctx.beginPath() - ctx.moveTo(x + r, y) - ctx.lineTo(x + w - r, y) - ctx.arcTo(x + w, y, x + w, y + r, r) - ctx.lineTo(x + w, y + h - r) - ctx.arcTo(x + w, y + h, x + w - r, y + h, r) - ctx.lineTo(x + r, y + h) - ctx.arcTo(x, y + h, x, y + h - r, r) - ctx.lineTo(x, y + r) - ctx.arcTo(x, y, x + r, y, r) - ctx.closePath() -} - -// --- Document icons --- - -const ICON_COLOR = "#3B73B8" - -function drawDocIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, - type: string, -): void { - ctx.save() - ctx.fillStyle = ICON_COLOR - ctx.strokeStyle = ICON_COLOR - ctx.lineWidth = Math.max(1, size / 12) - ctx.lineCap = "round" - ctx.lineJoin = "round" - - switch (type) { - case "webpage": - case "url": - drawGlobeIcon(ctx, x, y, size) - break - case "pdf": - drawTextLabel(ctx, x, y, size, "PDF", 0.35) - break - case "md": - case "markdown": - drawTextLabel(ctx, x, y, size, "MD", 0.3) - break - case "doc": - case "docx": - drawTextLabel(ctx, x, y, size, "DOC", 0.28) - break - case "csv": - drawGridIcon(ctx, x, y, size) - break - case "json": - drawBracesIcon(ctx, x, y, size) - break - case "notion": - case "notion_doc": - drawTextLabel(ctx, x, y, size, "N", 0.4) - break - case "google_doc": - case "google_sheet": - case "google_slide": - drawTextLabel(ctx, x, y, size, "G", 0.4) - break - default: - drawDocOutline(ctx, x, y, size) - break - } - - ctx.restore() -} - -function drawTextLabel( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, - text: string, - fontRatio: number, -): void { - ctx.font = `bold ${size * fontRatio}px sans-serif` - ctx.textAlign = "center" - ctx.textBaseline = "middle" - ctx.fillText(text, x, y) -} - -function drawGlobeIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - const r = size * 0.4 - ctx.beginPath() - ctx.arc(x, y, r, 0, Math.PI * 2) - ctx.stroke() - ctx.beginPath() - ctx.ellipse(x, y, r * 0.4, r, 0, 0, Math.PI * 2) - ctx.stroke() - ctx.beginPath() - ctx.moveTo(x - r, y) - ctx.lineTo(x + r, y) - ctx.stroke() -} - -function drawGridIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - const w = size * 0.7 - const h = size * 0.7 - ctx.strokeRect(x - w / 2, y - h / 2, w, h) - ctx.beginPath() - ctx.moveTo(x, y - h / 2) - ctx.lineTo(x, y + h / 2) - ctx.moveTo(x - w / 2, y) - ctx.lineTo(x + w / 2, y) - ctx.stroke() -} - -function drawBracesIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - const w = size * 0.6 - const h = size * 0.8 - ctx.beginPath() - ctx.moveTo(x - w / 4, y - h / 2) - ctx.quadraticCurveTo(x - w / 2, y - h / 3, x - w / 2, y) - ctx.quadraticCurveTo(x - w / 2, y + h / 3, x - w / 4, y + h / 2) - ctx.stroke() - ctx.beginPath() - ctx.moveTo(x + w / 4, y - h / 2) - ctx.quadraticCurveTo(x + w / 2, y - h / 3, x + w / 2, y) - ctx.quadraticCurveTo(x + w / 2, y + h / 3, x + w / 4, y + h / 2) - ctx.stroke() -} - -function drawDocOutline( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - const w = size * 0.7 - const h = size * 0.85 - const fold = size * 0.2 - ctx.beginPath() - ctx.moveTo(x - w / 2, y - h / 2) - ctx.lineTo(x + w / 2 - fold, y - h / 2) - ctx.lineTo(x + w / 2, y - h / 2 + fold) - ctx.lineTo(x + w / 2, y + h / 2) - ctx.lineTo(x - w / 2, y + h / 2) - ctx.closePath() - ctx.stroke() - const sp = size * 0.15 - const lw = size * 0.4 - ctx.beginPath() - ctx.moveTo(x - lw / 2, y - sp) - ctx.lineTo(x + lw / 2, y - sp) - ctx.moveTo(x - lw / 2, y) - ctx.lineTo(x + lw / 2, y) - ctx.moveTo(x - lw / 2, y + sp) - ctx.lineTo(x + lw / 2, y + sp) - ctx.stroke() -} diff --git a/apps/web/components/memory-graph/canvas/simulation.ts b/apps/web/components/memory-graph/canvas/simulation.ts deleted file mode 100644 index f0a4088b..00000000 --- a/apps/web/components/memory-graph/canvas/simulation.ts +++ /dev/null @@ -1,79 +0,0 @@ -import * as d3 from "d3-force" -import type { GraphNode, GraphEdge } from "../types" - -export class ForceSimulation { - private sim: d3.Simulation | null = null - - init(nodes: GraphNode[], edges: GraphEdge[]): void { - this.destroy() - - try { - this.sim = d3 - .forceSimulation(nodes) - .alphaDecay(0.03) - .alphaMin(0.001) - .velocityDecay(0.6) - - this.sim.force( - "link", - d3 - .forceLink(edges) - .id((d) => d.id) - .distance((link) => (link.edgeType === "doc-memory" ? 150 : 300)) - .strength((link) => { - if (link.edgeType === "doc-memory") return 0.8 - if (link.edgeType === "version") return 1.0 - return link.similarity * 0.3 - }), - ) - - this.sim.force("charge", d3.forceManyBody().strength(-1000)) - - this.sim.force( - "collide", - d3 - .forceCollide() - .radius((d) => (d.type === "document" ? 80 : 40)) - .strength(0.7), - ) - - this.sim.force("x", d3.forceX().strength(0.05)) - this.sim.force("y", d3.forceY().strength(0.05)) - - // Pre-settle synchronously, then start the live simulation - this.sim.stop() - this.sim.alpha(1) - for (let i = 0; i < 50; i++) this.sim.tick() - this.sim.alphaTarget(0).restart() - } catch (e) { - console.error("ForceSimulation.init failed:", e) - this.destroy() - } - } - - update(nodes: GraphNode[], edges: GraphEdge[]): void { - if (!this.sim) return - this.sim.nodes(nodes) - const linkForce = this.sim.force>("link") - if (linkForce) linkForce.links(edges) - } - - reheat(): void { - this.sim?.alphaTarget(0.3).restart() - } - - coolDown(): void { - this.sim?.alphaTarget(0) - } - - isActive(): boolean { - return (this.sim?.alpha() ?? 0) > 0.001 - } - - destroy(): void { - if (this.sim) { - this.sim.stop() - this.sim = null - } - } -} diff --git a/apps/web/components/memory-graph/canvas/version-chain.ts b/apps/web/components/memory-graph/canvas/version-chain.ts deleted file mode 100644 index ec151701..00000000 --- a/apps/web/components/memory-graph/canvas/version-chain.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { GraphApiDocument, GraphApiMemory } from "../types" - -export interface ChainEntry { - id: string - version: number - memory: string - isForgotten: boolean - isLatest: boolean -} - -export class VersionChainIndex { - private memoryMap = new Map() - private cache = new Map() - private lastDocs: GraphApiDocument[] | null = null - - rebuild(documents: GraphApiDocument[]): void { - if (documents === this.lastDocs) return - this.lastDocs = documents - this.memoryMap.clear() - this.cache.clear() - - for (const doc of documents) { - for (const m of doc.memories) { - this.memoryMap.set(m.id, m) - } - } - } - - getChain(memoryId: string): ChainEntry[] | null { - const cached = this.cache.get(memoryId) - if (cached) return cached - - const mem = this.memoryMap.get(memoryId) - if (!mem || mem.version <= 1) return null - - // Walk parentMemoryId up to the root - const chain: ChainEntry[] = [] - const visited = new Set() - let current: GraphApiMemory | undefined = mem - while (current && !visited.has(current.id)) { - visited.add(current.id) - chain.push({ - id: current.id, - version: current.version, - memory: current.memory, - isForgotten: current.isForgotten, - isLatest: current.isLatest, - }) - current = current.parentMemoryId - ? this.memoryMap.get(current.parentMemoryId) - : undefined - } - - chain.reverse() - - // Cache for every member in the chain - for (const entry of chain) { - this.cache.set(entry.id, chain) - } - - return chain - } -} diff --git a/apps/web/components/memory-graph/constants.ts b/apps/web/components/memory-graph/constants.ts deleted file mode 100644 index b879bf1d..00000000 --- a/apps/web/components/memory-graph/constants.ts +++ /dev/null @@ -1,62 +0,0 @@ -export const colors = { - background: { - primary: "#0f1419", - secondary: "#1a1f29", - accent: "#252a35", - }, - hexagon: { - active: { fill: "#0D2034", stroke: "#3B73B8", strokeWidth: 1.68 }, - inactive: { fill: "#0B1826", stroke: "#3D4857", strokeWidth: 1.4 }, - hovered: { fill: "#112840", stroke: "#4A8AD0", strokeWidth: 2 }, - }, - document: { - outer: { fill: "#1B1F24", stroke: "#2A2F36", radius: 8 }, - inner: { fill: "#13161A", radius: 6 }, - iconColor: "#3B73B8", - }, - text: { - primary: "#ffffff", - secondary: "#e2e8f0", - muted: "#94a3b8", - }, -} - -export const MEMORY_BORDER = { - forgotten: "#EF4444", - expiring: "#F59E0B", - recent: "#10B981", - default: "#3B73B8", -} as const - -export const EDGE_COLORS = { - docMemory: "#4A5568", - similarityStrong: "#00D4B8", - similarityMedium: "#6B8FBF", - similarityWeak: "#4A6A8A", - version: "#8B5CF6", -} as const - -export const FORCE_CONFIG = { - linkStrength: { - docMemory: 0.8, - version: 1.0, - docDocBase: 0.3, - }, - linkDistance: 300, - docMemoryDistance: 150, - chargeStrength: -1000, - collisionRadius: { document: 80, memory: 40 }, - alphaDecay: 0.03, - alphaMin: 0.001, - velocityDecay: 0.6, - alphaTarget: 0.3, -} - -export const GRAPH_SETTINGS = { - console: { initialZoom: 0.8, initialPanX: 0, initialPanY: 0 }, - consumer: { initialZoom: 0.5, initialPanX: 400, initialPanY: 300 }, -} - -export const ANIMATION = { - dimDuration: 1500, -} diff --git a/apps/web/components/memory-graph/graph-canvas.tsx b/apps/web/components/memory-graph/graph-canvas.tsx deleted file mode 100644 index 06b7bc4c..00000000 --- a/apps/web/components/memory-graph/graph-canvas.tsx +++ /dev/null @@ -1,248 +0,0 @@ -"use client" - -import { memo, useEffect, useLayoutEffect, useRef } from "react" -import type { GraphCanvasProps, GraphNode } from "./types" -import { ViewportState } from "./canvas/viewport" -import { SpatialIndex } from "./canvas/hit-test" -import { InputHandler } from "./canvas/input-handler" -import { renderFrame } from "./canvas/renderer" -import { GRAPH_SETTINGS } from "./constants" - -export const GraphCanvas = memo(function GraphCanvas({ - nodes, - edges, - width, - height, - highlightDocumentIds, - selectedNodeId = null, - onNodeHover, - onNodeClick, - onNodeDragStart, - onNodeDragEnd, - onViewportChange, - canvasRef: externalCanvasRef, - variant = "console", - simulation, - viewportRef: externalViewportRef, -}) { - const internalCanvasRef = useRef(null) - const canvasRef = externalCanvasRef || internalCanvasRef - - // Engine instances — mutable, never trigger re-renders - const viewportRef = useRef(null) - const spatialRef = useRef(new SpatialIndex()) - const inputRef = useRef(null) - const rafRef = useRef(0) - const renderNeeded = useRef(true) - const nodeMapRef = useRef(new Map()) - - // All mutable render state in a single ref — the rAF loop reads from here - const s = useRef({ - nodes, - edges, - width, - height, - selectedNodeId, - hoveredNodeId: null as string | null, - highlightIds: new Set(highlightDocumentIds ?? []), - dimProgress: 0, - dimTarget: selectedNodeId ? 1 : 0, - }) - - // Sync incoming props to mutable state (no re-renders) - s.current.nodes = nodes - s.current.edges = edges - s.current.width = width - s.current.height = height - - // Stable callback refs so InputHandler never needs recreation - const cb = useRef({ - onNodeHover, - onNodeClick, - onNodeDragStart, - onNodeDragEnd, - onViewportChange, - simulation, - }) - cb.current = { - onNodeHover, - onNodeClick, - onNodeDragStart, - onNodeDragEnd, - onViewportChange, - simulation, - } - - // Rebuild nodeMap + spatial index when nodes change - useEffect(() => { - const map = nodeMapRef.current - map.clear() - for (const n of nodes) map.set(n.id, n) - spatialRef.current.rebuild(nodes) - renderNeeded.current = true - }, [nodes]) - - useEffect(() => { - s.current.highlightIds = new Set(highlightDocumentIds ?? []) - renderNeeded.current = true - }, [highlightDocumentIds]) - - useEffect(() => { - s.current.selectedNodeId = selectedNodeId - s.current.dimTarget = selectedNodeId ? 1 : 0 - renderNeeded.current = true - }, [selectedNodeId]) - - // Create viewport + input handler (once per variant) - useLayoutEffect(() => { - const canvas = canvasRef.current - if (!canvas) return - - const cfg = GRAPH_SETTINGS[variant] - const vp = new ViewportState( - cfg.initialPanX, - cfg.initialPanY, - cfg.initialZoom, - ) - viewportRef.current = vp - if (externalViewportRef) { - ;( - externalViewportRef as React.MutableRefObject - ).current = vp - } - - const handler = new InputHandler(canvas, vp, spatialRef.current, { - onNodeHover: (id) => { - s.current.hoveredNodeId = id - cb.current.onNodeHover(id) - renderNeeded.current = true - }, - onNodeClick: (id) => cb.current.onNodeClick(id), - onNodeDragStart: (id, node) => { - cb.current.onNodeDragStart(id) - cb.current.simulation?.reheat() - }, - onNodeDragEnd: () => { - cb.current.onNodeDragEnd() - cb.current.simulation?.coolDown() - }, - onRequestRender: () => { - renderNeeded.current = true - }, - }) - inputRef.current = handler - - return () => handler.destroy() - }, [variant]) - - // High-DPI canvas sizing - const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1 - - useLayoutEffect(() => { - const canvas = canvasRef.current - if (!canvas || width === 0 || height === 0) return - - const MAX = 16384 - const d = Math.min(MAX / width, MAX / height, dpr) - canvas.style.width = `${width}px` - canvas.style.height = `${height}px` - canvas.width = Math.min(width * d, MAX) - canvas.height = Math.min(height * d, MAX) - - const ctx = canvas.getContext("2d") - if (ctx) { - ctx.scale(d, d) - ctx.imageSmoothingEnabled = true - ctx.imageSmoothingQuality = "high" - } - renderNeeded.current = true - }, [width, height, dpr]) - - // Single render loop — runs for component lifetime, reads everything from refs - useEffect(() => { - let lastReportedZoom = 0 - - const tick = () => { - rafRef.current = requestAnimationFrame(tick) - - const vp = viewportRef.current - const canvas = canvasRef.current - if (!vp || !canvas) return - - const ctx = canvas.getContext("2d") - if (!ctx) return - - const cur = s.current - - // 1. Viewport momentum / spring zoom / lerp pan - const vpMoving = vp.tick() - - // 2. Dim animation (ease toward target) - const dd = cur.dimTarget - cur.dimProgress - let dimming = false - if (Math.abs(dd) > 0.01) { - cur.dimProgress += dd * 0.1 - dimming = true - } else { - cur.dimProgress = cur.dimTarget - } - - // 3. Simulation physics - const simActive = cb.current.simulation?.isActive() ?? false - - // 4. Spatial index rebuild (only when positions actually move) - const spatialChanged = - simActive || inputRef.current?.getDraggingNode() - ? spatialRef.current.rebuild(cur.nodes) - : false - - // Skip frame if nothing changed - if ( - !vpMoving && - !simActive && - !dimming && - !spatialChanged && - !renderNeeded.current - ) - return - renderNeeded.current = false - - // Throttled zoom reporting for NavigationControls - if ( - vpMoving && - cb.current.onViewportChange && - Math.abs(vp.zoom - lastReportedZoom) > 0.005 - ) { - lastReportedZoom = vp.zoom - cb.current.onViewportChange(vp.zoom) - } - - renderFrame( - ctx, - cur.nodes, - cur.edges, - vp, - cur.width, - cur.height, - { - selectedNodeId: cur.selectedNodeId, - hoveredNodeId: cur.hoveredNodeId, - highlightIds: cur.highlightIds, - dimProgress: cur.dimProgress, - }, - nodeMapRef.current, - ) - } - - rafRef.current = requestAnimationFrame(tick) - return () => cancelAnimationFrame(rafRef.current) - }, []) - - return ( - - ) -}) diff --git a/apps/web/components/memory-graph/graph-card.tsx b/apps/web/components/memory-graph/graph-card.tsx index 841039ef..d6837521 100644 --- a/apps/web/components/memory-graph/graph-card.tsx +++ b/apps/web/components/memory-graph/graph-card.tsx @@ -85,6 +85,8 @@ function StaticGraphPreview({ height={height} className="absolute inset-0" viewBox={`0 0 ${width} ${height}`} + role="img" + aria-label="Memory graph preview" > {edges.map((e, i) => ( ( ({ containerTags, width = 216, height = 220, className }) => { const { setViewMode } = useViewMode() - const { data, isLoading, error } = useGraphApi({ + const { documents, isLoading, error } = useGraphApi({ containerTags, - limit: 20, enabled: true, }) @@ -139,11 +140,8 @@ export const GraphCard = memo( ) } - const documentCount = data.stats?.documentsWithSpatial ?? 0 - const memoryCount = data.documents.reduce( - (sum, d) => sum + d.memories.length, - 0, - ) + const documentCount = documents.length + const memoryCount = documents.reduce((sum, d) => sum + d.memories.length, 0) return ( - ) -}) - -// Hexagon SVG for memory nodes -const HexagonIcon = memo(function HexagonIcon({ - fill = "#0D2034", - stroke = "#3B73B8", - opacity = 1, - size = 12, -}: { - fill?: string - stroke?: string - opacity?: number - size?: number -}) { - return ( - - ) -}) - -// Document icon (rounded square) -const DocumentIcon = memo(function DocumentIcon() { - return ( -
-
-
- ) -}) - -// Connection icon (graph) -const ConnectionIcon = memo(function ConnectionIcon() { - return ( - - ) -}) - -// Line icon for connections -const LineIcon = memo(function LineIcon({ - color, - dashed = false, -}: { - color: string - dashed?: boolean -}) { - return ( -
-
-
- ) -}) - -// Similarity circle icon -const SimilarityCircle = memo(function SimilarityCircle({ - variant, -}: { - variant: "strong" | "weak" -}) { - return ( -
- ) -}) - -// Accordion row with count -const StatRow = memo(function StatRow({ - icon, - label, - count, - expandable = false, - expanded = false, - onToggle, - children, -}: { - icon: React.ReactNode - label: string - count: number - expandable?: boolean - expanded?: boolean - onToggle?: () => void - children?: React.ReactNode -}) { - return ( -
- - {expandable && expanded && children && ( -
{children}
- )} -
- ) -}) - -// Toggle row for relations/similarity -const ToggleRow = memo(function ToggleRow({ - icon, - label, - checked, - onChange, -}: { - icon: React.ReactNode - label: string - checked: boolean - onChange: (checked: boolean) => void -}) { - return ( -
-
- {icon} - {label} -
- -
- ) -}) - -export const Legend = memo(function Legend({ - variant: _variant = "console", - id, - nodes = [], - edges = [], - isLoading: _isLoading = false, -}: ExtendedLegendProps) { - const isMobile = useIsMobile() - const [isExpanded, setIsExpanded] = useState(false) - const [isInitialized, setIsInitialized] = useState(false) - - // Toggle states for relations - const [showUpdates, setShowUpdates] = useState(true) - const [showExtends, setShowExtends] = useState(true) - const [showInferences, setShowInferences] = useState(false) - - // Toggle states for similarity - const [showStrong, setShowStrong] = useState(true) - const [showWeak, setShowWeak] = useState(true) - - // Expanded accordion states - const [memoriesExpanded, setMemoriesExpanded] = useState(false) - const [documentsExpanded, setDocumentsExpanded] = useState(false) - const [connectionsExpanded, setConnectionsExpanded] = useState(true) - - // Load saved preference on client side - useEffect(() => { - if (!isInitialized) { - const savedState = getCookie("legendCollapsed") - if (savedState === "true") { - setIsExpanded(false) - } else if (savedState === "false") { - setIsExpanded(true) - } else { - // Default: collapsed on mobile, collapsed on desktop too (per Figma) - setIsExpanded(false) - } - setIsInitialized(true) - } - }, [isInitialized]) - - // Save to cookie when state changes - const handleToggleExpanded = (expanded: boolean) => { - setIsExpanded(expanded) - setCookie("legendCollapsed", expanded ? "false" : "true") - } - - // Calculate stats - const memoryCount = nodes.filter((n) => n.type === "memory").length - const documentCount = nodes.filter((n) => n.type === "document").length - const connectionCount = edges.length - - // Hide on mobile - if (isMobile) return null - - return ( -
- - {/* Glass background */} -
- -
- {/* Header - always visible */} - - {isExpanded ? ( - - ) : ( - - )} - - Legend - - - - -
- {/* Main content column */} -
- {/* STATISTICS Section */} -
- - STATISTICS - -
- {/* Memories */} - - } - label="Memories" - count={memoryCount} - expandable - expanded={memoriesExpanded} - onToggle={() => setMemoriesExpanded(!memoriesExpanded)} - > -
-
-
- - - Memory (latest) - -
- 76 -
-
-
- - - Memory (oldest) - -
- 182 -
-
-
-
- -
- - Score - -
- 23 -
-
-
- - - New memory - -
- 17 -
-
-
- - - Expiring soon - -
- 11 -
-
-
-
- -
- - Forgotten - -
- 6 -
-
-
- - {/* Documents */} - } - label="Documents" - count={documentCount} - expandable - expanded={documentsExpanded} - onToggle={() => setDocumentsExpanded(!documentsExpanded)} - /> - - {/* Connections */} - } - label="Connections" - count={connectionCount} - expandable - expanded={connectionsExpanded} - onToggle={() => - setConnectionsExpanded(!connectionsExpanded) - } - > -
-
-
- - - Doc > Memory - -
-
- } - label="Doc similarity" - checked={showStrong} - onChange={setShowStrong} - /> -
-
-
-
- - {/* RELATIONS Section */} -
- - RELATIONS - -
- } - label="Updates" - checked={showUpdates} - onChange={setShowUpdates} - /> - } - label="Extends" - checked={showExtends} - onChange={setShowExtends} - /> - } - label="Inferences" - checked={showInferences} - onChange={setShowInferences} - /> -
-
- - {/* SIMILARITY Section */} -
- - SIMILARITY - -
- } - label="Strong" - checked={showStrong} - onChange={setShowStrong} - /> - } - label="Weak" - checked={showWeak} - onChange={setShowWeak} - /> -
-
-
- - {/* Scrollbar indicator */} -
-
- -
- -
- ) -}) - -Legend.displayName = "Legend" diff --git a/apps/web/components/memory-graph/loading-indicator.tsx b/apps/web/components/memory-graph/loading-indicator.tsx deleted file mode 100644 index 41026304..00000000 --- a/apps/web/components/memory-graph/loading-indicator.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client" - -import { GlassMenuEffect } from "@repo/ui/other/glass-effect" -import { Sparkles } from "lucide-react" -import { memo } from "react" -import type { LoadingIndicatorProps } from "./types" - -export const LoadingIndicator = memo( - ({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => { - if (!isLoading && !isLoadingMore) return null - - return ( -
- {/* Glass effect background */} - - -
-
- - - {isLoading - ? "Loading memory graph..." - : `Loading more documents... (${totalLoaded})`} - -
-
-
- ) - }, -) - -LoadingIndicator.displayName = "LoadingIndicator" diff --git a/apps/web/components/memory-graph/memory-graph-wrapper.tsx b/apps/web/components/memory-graph/memory-graph-wrapper.tsx new file mode 100644 index 00000000..a387f58e --- /dev/null +++ b/apps/web/components/memory-graph/memory-graph-wrapper.tsx @@ -0,0 +1,80 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph" +import { useGraphApi } from "./hooks/use-graph-api" + +export interface MemoryGraphWrapperProps { + children?: React.ReactNode + isLoading?: boolean + error?: Error | null + variant?: "console" | "consumer" + legendId?: string + highlightDocumentIds?: string[] + highlightsVisible?: boolean + containerTags?: string[] + documentIds?: string[] + maxNodes?: number + isSlideshowActive?: boolean + onSlideshowNodeChange?: (nodeId: string | null) => void + onSlideshowStop?: () => void + canvasRef?: React.RefObject +} + +export function MemoryGraph({ + children, + isLoading: externalIsLoading = false, + error: externalError = null, + variant = "console", + containerTags, + maxNodes = 200, + canvasRef, + ...rest +}: MemoryGraphWrapperProps) { + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) + const containerRef = useRef(null) + + useEffect(() => { + const el = containerRef.current + if (!el) return + const ro = new ResizeObserver(() => { + setContainerSize({ width: el.clientWidth, height: el.clientHeight }) + }) + ro.observe(el) + setContainerSize({ width: el.clientWidth, height: el.clientHeight }) + return () => ro.disconnect() + }, []) + + const { + documents, + isLoading: apiIsLoading, + isLoadingMore, + error: apiError, + hasMore, + loadMore, + totalCount, + } = useGraphApi({ + containerTags, + enabled: containerSize.width > 0 && containerSize.height > 0, + }) + + return ( +
+ loadMore() : undefined} + hasMore={hasMore} + error={externalError || apiError} + variant={variant} + maxNodes={maxNodes} + canvasRef={canvasRef} + totalCount={totalCount} + {...rest} + > + {children} + +
+ ) +} diff --git a/apps/web/components/memory-graph/memory-graph.tsx b/apps/web/components/memory-graph/memory-graph.tsx deleted file mode 100644 index 27825787..00000000 --- a/apps/web/components/memory-graph/memory-graph.tsx +++ /dev/null @@ -1,513 +0,0 @@ -"use client" - -import { GlassMenuEffect } from "@repo/ui/other/glass-effect" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { useHotkeys } from "react-hotkeys-hook" -import { GraphCanvas } from "./graph-canvas" -import { useGraphApi } from "./hooks/use-graph-api" -import { useGraphData } from "./hooks/use-graph-data" -import { ForceSimulation } from "./canvas/simulation" -import { VersionChainIndex } from "./canvas/version-chain" -import type { ViewportState } from "./canvas/viewport" -import { Legend } from "./legend" -import { LoadingIndicator } from "./loading-indicator" -import { NavigationControls } from "./navigation-controls" -import { NodeHoverPopover } from "./node-hover-popover" -import { colors } from "./constants" -import type { GraphNode } from "./types" - -export interface MemoryGraphProps { - children?: React.ReactNode - isLoading?: boolean - error?: Error | null - variant?: "console" | "consumer" - legendId?: string - highlightDocumentIds?: string[] - highlightsVisible?: boolean - containerTags?: string[] - documentIds?: string[] - maxNodes?: number - isSlideshowActive?: boolean - onSlideshowNodeChange?: (nodeId: string | null) => void - onSlideshowStop?: () => void - canvasRef?: React.RefObject -} - -export const MemoryGraph = ({ - children, - isLoading: externalIsLoading = false, - error: externalError = null, - variant = "console", - legendId, - highlightDocumentIds = [], - highlightsVisible = true, - containerTags, - documentIds, - maxNodes = 200, - isSlideshowActive = false, - onSlideshowNodeChange, - onSlideshowStop, - canvasRef, -}: MemoryGraphProps) => { - const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) - const [containerBounds, setContainerBounds] = useState(null) - const containerRef = useRef(null) - const viewportRef = useRef(null) - const simulationRef = useRef(null) - const chainIndex = useRef(new VersionChainIndex()) - - // React state only for things that affect DOM - const [hoveredNode, setHoveredNode] = useState(null) - const [selectedNode, setSelectedNode] = useState(null) - const [zoomDisplay, setZoomDisplay] = useState(50) - - const { - data: apiData, - isLoading: apiIsLoading, - error: apiError, - } = useGraphApi({ - containerTags, - documentIds, - limit: maxNodes, - enabled: containerSize.width > 0 && containerSize.height > 0, - }) - - const { nodes, edges } = useGraphData( - apiData.documents, - apiData.edges, - null, - containerSize.width, - containerSize.height, - ) - - // Rebuild version chain index when documents change - useEffect(() => { - chainIndex.current.rebuild(apiData.documents) - }, [apiData.documents]) - - // Force simulation (created once, updated when data changes) - useEffect(() => { - if (nodes.length === 0) return - - if (!simulationRef.current) { - simulationRef.current = new ForceSimulation() - } - simulationRef.current.init(nodes, edges) - - return () => { - simulationRef.current?.destroy() - simulationRef.current = null - } - }, [nodes, edges]) - - // Auto-fit when data first loads - const hasAutoFittedRef = useRef(false) - useEffect(() => { - if ( - !hasAutoFittedRef.current && - nodes.length > 0 && - viewportRef.current && - containerSize.width > 0 - ) { - const timer = setTimeout(() => { - viewportRef.current?.fitToNodes( - nodes, - containerSize.width, - containerSize.height, - ) - hasAutoFittedRef.current = true - }, 100) - return () => clearTimeout(timer) - } - }, [nodes, containerSize.width, containerSize.height]) - - useEffect(() => { - if (nodes.length === 0) hasAutoFittedRef.current = false - }, [nodes.length]) - - // Container resize observer - useEffect(() => { - const el = containerRef.current - if (!el) return - - const ro = new ResizeObserver(() => { - setContainerSize({ width: el.clientWidth, height: el.clientHeight }) - setContainerBounds(el.getBoundingClientRect()) - }) - ro.observe(el) - setContainerSize({ width: el.clientWidth, height: el.clientHeight }) - setContainerBounds(el.getBoundingClientRect()) - - return () => ro.disconnect() - }, []) - - // Callbacks for GraphCanvas - const handleNodeHover = useCallback( - (id: string | null) => setHoveredNode(id), - [], - ) - - const handleNodeClick = useCallback((id: string | null) => { - setSelectedNode((prev) => (id === null ? null : prev === id ? null : id)) - }, []) - - const handleNodeDragStart = useCallback((_id: string) => { - // Drag is handled imperatively by InputHandler - }, []) - - const handleNodeDragEnd = useCallback(() => { - // Drag end handled by InputHandler - }, []) - - const handleViewportChange = useCallback((zoom: number) => { - setZoomDisplay(Math.round(zoom * 100)) - }, []) - - // Navigation - const handleAutoFit = useCallback(() => { - if (nodes.length === 0 || !viewportRef.current) return - viewportRef.current.fitToNodes( - nodes, - containerSize.width, - containerSize.height, - ) - }, [nodes, containerSize.width, containerSize.height]) - - const handleCenter = useCallback(() => { - if (nodes.length === 0 || !viewportRef.current) return - let sx = 0 - let sy = 0 - for (const n of nodes) { - sx += n.x - sy += n.y - } - viewportRef.current.centerOn( - sx / nodes.length, - sy / nodes.length, - containerSize.width, - containerSize.height, - ) - }, [nodes, containerSize.width, containerSize.height]) - - const handleZoomIn = useCallback(() => { - const vp = viewportRef.current - if (!vp) return - vp.zoomTo(vp.zoom * 1.3, containerSize.width / 2, containerSize.height / 2) - }, [containerSize.width, containerSize.height]) - - const handleZoomOut = useCallback(() => { - const vp = viewportRef.current - if (!vp) return - vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, containerSize.height / 2) - }, [containerSize.width, containerSize.height]) - - // Keyboard shortcuts - useHotkeys("z", handleAutoFit, [handleAutoFit]) - useHotkeys("c", handleCenter, [handleCenter]) - useHotkeys("equal", handleZoomIn, [handleZoomIn]) - useHotkeys("minus", handleZoomOut, [handleZoomOut]) - useHotkeys("escape", () => setSelectedNode(null), []) - - // Arrow key navigation through nodes - const selectAndCenter = useCallback( - (nodeId: string) => { - setSelectedNode(nodeId) - const n = nodes.find((nd) => nd.id === nodeId) - if (n && viewportRef.current) - viewportRef.current.centerOn( - n.x, - n.y, - containerSize.width, - containerSize.height, - ) - }, - [nodes, containerSize.width, containerSize.height], - ) - - const navigateUp = useCallback(() => { - if (!selectedNode) return - const chain = chainIndex.current.getChain(selectedNode) - if (chain && chain.length > 1) { - const idx = chain.findIndex((e) => e.id === selectedNode) - if (idx > 0) { - selectAndCenter(chain[idx - 1]!.id) - return - } - } - // At top of chain or no chain — go to parent document - const node = nodes.find((n) => n.id === selectedNode) - if (node?.type === "memory" && "documentId" in node.data) { - selectAndCenter(node.data.documentId) - } - }, [selectedNode, nodes, selectAndCenter]) - - const navigateDown = useCallback(() => { - if (!selectedNode) return - // Version chain navigation - const chain = chainIndex.current.getChain(selectedNode) - if (chain && chain.length > 1) { - const idx = chain.findIndex((e) => e.id === selectedNode) - if (idx >= 0 && idx < chain.length - 1) { - selectAndCenter(chain[idx + 1]!.id) - return - } - } - // On a document — go to its first memory - const node = nodes.find((n) => n.id === selectedNode) - if (node?.type === "document") { - const child = nodes.find( - (n) => - n.type === "memory" && - "documentId" in n.data && - n.data.documentId === selectedNode, - ) - if (child) selectAndCenter(child.id) - } - }, [selectedNode, nodes, selectAndCenter]) - - const navigateNext = useCallback(() => { - if (!selectedNode) return - const node = nodes.find((n) => n.id === selectedNode) - if (!node) return - - if (node.type === "document") { - const docs = nodes.filter((n) => n.type === "document") - const idx = docs.findIndex((n) => n.id === selectedNode) - const next = docs[(idx + 1) % docs.length]! - setSelectedNode(next.id) - if (viewportRef.current) - viewportRef.current.centerOn( - next.x, - next.y, - containerSize.width, - containerSize.height, - ) - } else { - const docId = "documentId" in node.data ? node.data.documentId : null - const siblings = nodes.filter( - (n) => - n.type === "memory" && - "documentId" in n.data && - n.data.documentId === docId, - ) - if (siblings.length === 0) return - const idx = siblings.findIndex((n) => n.id === selectedNode) - const next = siblings[(idx + 1) % siblings.length]! - setSelectedNode(next.id) - if (viewportRef.current) - viewportRef.current.centerOn( - next.x, - next.y, - containerSize.width, - containerSize.height, - ) - } - }, [selectedNode, nodes, containerSize.width, containerSize.height]) - - const navigatePrev = useCallback(() => { - if (!selectedNode) return - const node = nodes.find((n) => n.id === selectedNode) - if (!node) return - - if (node.type === "document") { - const docs = nodes.filter((n) => n.type === "document") - const idx = docs.findIndex((n) => n.id === selectedNode) - const prev = docs[(idx - 1 + docs.length) % docs.length]! - setSelectedNode(prev.id) - if (viewportRef.current) - viewportRef.current.centerOn( - prev.x, - prev.y, - containerSize.width, - containerSize.height, - ) - } else { - const docId = "documentId" in node.data ? node.data.documentId : null - const siblings = nodes.filter( - (n) => - n.type === "memory" && - "documentId" in n.data && - n.data.documentId === docId, - ) - if (siblings.length === 0) return - const idx = siblings.findIndex((n) => n.id === selectedNode) - const prev = siblings[(idx - 1 + siblings.length) % siblings.length]! - setSelectedNode(prev.id) - if (viewportRef.current) - viewportRef.current.centerOn( - prev.x, - prev.y, - containerSize.width, - containerSize.height, - ) - } - }, [selectedNode, nodes, containerSize.width, containerSize.height]) - - useHotkeys("up", navigateUp, [navigateUp]) - useHotkeys("down", navigateDown, [navigateDown]) - useHotkeys("right", navigateNext, [navigateNext]) - useHotkeys("left", navigatePrev, [navigatePrev]) - - // Slideshow - useEffect(() => { - if (!isSlideshowActive || nodes.length === 0) { - if (!isSlideshowActive) { - setSelectedNode(null) - simulationRef.current?.coolDown() - } - return - } - - let lastIdx = -1 - const pick = () => { - if (nodes.length === 0) return - let idx: number - if (nodes.length > 1) { - do { - idx = Math.floor(Math.random() * nodes.length) - } while (idx === lastIdx) - } else { - idx = 0 - } - lastIdx = idx - const n = nodes[idx]! - setSelectedNode(n.id) - viewportRef.current?.centerOn( - n.x, - n.y, - containerSize.width, - containerSize.height, - ) - simulationRef.current?.reheat() - onSlideshowNodeChange?.(n.id) - setTimeout(() => simulationRef.current?.coolDown(), 1000) - } - - pick() - const interval = setInterval(pick, 3500) - return () => clearInterval(interval) - }, [ - isSlideshowActive, - nodes, - containerSize.width, - containerSize.height, - onSlideshowNodeChange, - ]) - - // Active node: selected takes priority, then hovered - const activeNodeId = selectedNode ?? hoveredNode - const activeNodeData = useMemo(() => { - if (!activeNodeId) return null - return nodes.find((n) => n.id === activeNodeId) ?? null - }, [activeNodeId, nodes]) - - const activePopoverPosition = useMemo(() => { - if (!activeNodeData || !viewportRef.current) return null - const vp = viewportRef.current - const screen = vp.worldToScreen(activeNodeData.x, activeNodeData.y) - return { - screenX: screen.x, - screenY: screen.y, - nodeRadius: (activeNodeData.size * vp.zoom) / 2, - } - }, [activeNodeData]) - - const activeVersionChain = useMemo(() => { - if (!activeNodeData || activeNodeData.type !== "memory") return null - return chainIndex.current.getChain(activeNodeData.id) - }, [activeNodeData]) - - const isLoading = externalIsLoading || apiIsLoading - const error = externalError || apiError - - if (error) { - return ( -
-
- -
- Error loading graph: {error.message} -
-
-
- ) - } - - return ( -
- - - {!isLoading && !nodes.some((n) => n.type === "document") && children} - -
- {containerSize.width > 0 && containerSize.height > 0 && ( - - )} - - {activeNodeData && activePopoverPosition && ( - - )} - -
- {containerSize.width > 0 && ( - - )} - -
-
-
- ) -} diff --git a/apps/web/components/memory-graph/navigation-controls.tsx b/apps/web/components/memory-graph/navigation-controls.tsx deleted file mode 100644 index 27d2a7e0..00000000 --- a/apps/web/components/memory-graph/navigation-controls.tsx +++ /dev/null @@ -1,164 +0,0 @@ -"use client" - -import { memo } from "react" -import type { GraphNode } from "./types" -import { cn } from "@lib/utils" -import { Settings } from "lucide-react" - -interface NavigationControlsProps { - onCenter: () => void - onZoomIn: () => void - onZoomOut: () => void - onAutoFit: () => void - nodes: GraphNode[] - className?: string - zoomLevel: number -} - -// Keyboard shortcut badge component -const KeyboardShortcut = memo(function KeyboardShortcut({ - keys, -}: { - keys: string -}) { - return ( -
- - {keys} - -
- ) -}) - -// Navigation buttons component -const NavigationButtons = memo(function NavigationButtons({ - onAutoFit, - onCenter, - onZoomIn, - onZoomOut, - zoomLevel, -}: { - onAutoFit: () => void - onCenter: () => void - onZoomIn: () => void - onZoomOut: () => void - zoomLevel: number -}) { - return ( -
- {/* Fit button */} - - - {/* Center button */} - - - {/* Zoom controls */} -
- {zoomLevel}% -
- - -
-
-
- ) -}) - -const SettingsButton = memo(function SettingsButton() { - return ( - - ) -}) - -export const NavigationControls = memo( - ({ - onCenter, - onZoomIn, - onZoomOut, - onAutoFit, - nodes, - className = "", - zoomLevel, - }) => { - if (nodes.length === 0) { - return null - } - - return ( -
-
- - {/* Commented out for now as we are not using this */} - {/**/} -
-
- ) - }, -) - -NavigationControls.displayName = "NavigationControls" diff --git a/apps/web/components/memory-graph/node-hover-popover.tsx b/apps/web/components/memory-graph/node-hover-popover.tsx deleted file mode 100644 index a1e1deeb..00000000 --- a/apps/web/components/memory-graph/node-hover-popover.tsx +++ /dev/null @@ -1,467 +0,0 @@ -"use client" - -import { memo, useMemo, useCallback, useState } from "react" -import type { GraphNode, DocumentNodeData, MemoryNodeData } from "./types" -import type { ChainEntry } from "./canvas/version-chain" - -export interface NodeHoverPopoverProps { - node: GraphNode - screenX: number - screenY: number - nodeRadius: number - containerBounds?: DOMRect - versionChain?: ChainEntry[] | null - onNavigateNext?: () => void - onNavigatePrev?: () => void - onNavigateUp?: () => void - onNavigateDown?: () => void - onSelectNode?: (nodeId: string) => void -} - -function KeyBadge({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ) -} - -function NavButton({ - icon, - label, - onClick, -}: { - icon: string - label: string - onClick?: () => void -}) { - return ( - - ) -} - -function CopyableId({ label, value }: { label: string; value: string }) { - const [copied, setCopied] = useState(false) - const copy = useCallback(() => { - navigator.clipboard.writeText(value) - setCopied(true) - setTimeout(() => setCopied(false), 1500) - }, [value]) - - const short = - value.length > 12 ? `${value.slice(0, 6)}...${value.slice(-4)}` : value - - return ( - - ) -} - -type Quadrant = "right" | "left" | "above" | "below" - -function pickBestQuadrant( - screenX: number, - screenY: number, - nodeRadius: number, - containerWidth: number, - containerHeight: number, - popoverWidth: number, - popoverHeight: number, -): Quadrant { - const gap = 24 - const spaceRight = containerWidth - (screenX + nodeRadius + gap) - const spaceLeft = screenX - nodeRadius - gap - const spaceAbove = screenY - nodeRadius - gap - const spaceBelow = containerHeight - (screenY + nodeRadius + gap) - - const fits: [Quadrant, number][] = [ - ["right", spaceRight >= popoverWidth ? spaceRight : -1], - ["left", spaceLeft >= popoverWidth ? spaceLeft : -1], - ["above", spaceAbove >= popoverHeight ? spaceAbove : -1], - ["below", spaceBelow >= popoverHeight ? spaceBelow : -1], - ] - - const preferred: Quadrant[] = ["right", "left", "below", "above"] - for (const q of preferred) { - const entry = fits.find(([dir]) => dir === q) - if (entry && entry[1] > 0) return q - } - - return fits.sort((a, b) => b[1] - a[1])[0]![0] -} - -function truncate(s: string, max: number) { - return s.length > max ? `${s.substring(0, max)}...` : s -} - -function VersionTimeline({ - chain, - currentId, - onSelect, -}: { - chain: ChainEntry[] - currentId: string - onSelect?: (id: string) => void -}) { - return ( -
- {chain.map((entry) => { - const isCurrent = entry.id === currentId - return ( - - ) - })} -
- ) -} - -export const NodeHoverPopover = memo( - function NodeHoverPopover({ - node, - screenX, - screenY, - nodeRadius, - containerBounds, - versionChain, - onNavigateNext, - onNavigatePrev, - onNavigateUp, - onNavigateDown, - onSelectNode, - }) { - const CARD_W = 280 - const SHORTCUTS_W = 100 - const GAP = 24 - const TOTAL_W = CARD_W + 12 + SHORTCUTS_W - - const isMemory = node.type === "memory" - const data = node.data - - const memoryMeta = useMemo(() => { - if (!isMemory) return null - const md = data as MemoryNodeData - return { - version: md.version ?? 1, - isLatest: md.isLatest ?? false, - isForgotten: md.isForgotten ?? false, - forgetReason: md.forgetReason ?? null, - forgetAfter: md.forgetAfter ?? null, - } - }, [isMemory, data]) - - const hasChain = versionChain && versionChain.length > 1 - const hasForgetInfo = - memoryMeta && (memoryMeta.isForgotten || memoryMeta.forgetAfter) - - const CARD_H = hasChain ? 200 : hasForgetInfo ? 165 : 135 - const TOTAL_H = CARD_H - - const { popoverX, popoverY, connectorPath } = useMemo(() => { - const cw = containerBounds?.width ?? 800 - const ch = containerBounds?.height ?? 600 - - const quadrant = pickBestQuadrant( - screenX, - screenY, - nodeRadius, - cw, - ch, - TOTAL_W + GAP, - TOTAL_H, - ) - - let px: number - let py: number - let connStart: { x: number; y: number } - - switch (quadrant) { - case "right": - px = screenX + nodeRadius + GAP - py = screenY - TOTAL_H / 2 - connStart = { x: screenX + nodeRadius, y: screenY } - break - case "left": - px = screenX - nodeRadius - GAP - TOTAL_W - py = screenY - TOTAL_H / 2 - connStart = { x: screenX - nodeRadius, y: screenY } - break - case "below": - px = screenX - TOTAL_W / 2 - py = screenY + nodeRadius + GAP - connStart = { x: screenX, y: screenY + nodeRadius } - break - case "above": - px = screenX - TOTAL_W / 2 - py = screenY - nodeRadius - GAP - TOTAL_H - connStart = { x: screenX, y: screenY - nodeRadius } - break - } - - px = Math.max(8, Math.min(cw - TOTAL_W - 8, px)) - py = Math.max(8, Math.min(ch - TOTAL_H - 8, py)) - - const cardCenterX = px + CARD_W / 2 - const cardCenterY = py + TOTAL_H / 2 - const path = `M ${connStart.x} ${connStart.y} L ${cardCenterX} ${cardCenterY}` - - return { popoverX: px, popoverY: py, connectorPath: path } - }, [screenX, screenY, nodeRadius, containerBounds, TOTAL_W, TOTAL_H]) - - const content = useMemo(() => { - if (isMemory) { - const md = data as MemoryNodeData - return md.memory || md.content || "" - } - const dd = data as DocumentNodeData - return dd.summary || dd.title || "" - }, [isMemory, data]) - - const docData = !isMemory ? (data as DocumentNodeData) : null - - return ( -
- - - - -
- {/* Card */} -
- {/* Content — show timeline if chain exists, otherwise plain text */} - {hasChain ? ( - - ) : ( -
-

- {truncate(content, 100) || "No content"} -

-
- )} - - {/* Forget info (memory-only) */} - {memoryMeta && hasForgetInfo && ( -
- {memoryMeta.forgetAfter && ( - - Expires:{" "} - {new Date(memoryMeta.forgetAfter).toLocaleDateString()} - - )} - {memoryMeta.forgetReason && ( - - Reason: {memoryMeta.forgetReason} - - )} - {memoryMeta.isForgotten && !memoryMeta.forgetReason && ( - - Forgotten - - )} -
- )} - - {/* Bottom bar */} -
- {memoryMeta ? ( - <> - - v{memoryMeta.version}{" "} - {memoryMeta.isForgotten - ? "Forgotten" - : memoryMeta.isLatest - ? "Latest" - : "Superseded"} - - - ) : ( - <> - - {docData?.type || "document"} - - - {docData?.memories?.length ?? 0} memories - - - )} -
- - {/* ID row */} -
- {isMemory ? ( - - ) : ( - - )} -
-
- - {/* Navigation */} -
- {isMemory && ( - - )} - {(isMemory ? hasChain : true) && ( - - )} - - -
-
-
- ) - }, -) diff --git a/apps/web/components/memory-graph/node-popover.tsx b/apps/web/components/memory-graph/node-popover.tsx deleted file mode 100644 index 7a2a9c70..00000000 --- a/apps/web/components/memory-graph/node-popover.tsx +++ /dev/null @@ -1,362 +0,0 @@ -"use client" - -import { memo, useEffect } from "react" -import type { GraphNode } from "./types" -import { cn } from "@lib/utils" - -export interface NodePopoverProps { - node: GraphNode - x: number // Screen X position - y: number // Screen Y position - onClose: () => void - containerBounds?: DOMRect // Optional container bounds to limit backdrop - onBackdropClick?: () => void // Optional callback when backdrop is clicked -} - -export const NodePopover = memo(function NodePopover({ - node, - x, - y, - onClose, - containerBounds, - onBackdropClick, -}) { - // Handle Escape key to close popover - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose() - } - } - - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [onClose]) - - // Calculate backdrop bounds - use container bounds if provided, otherwise full viewport - const backdropStyle = containerBounds - ? { - left: `${containerBounds.left}px`, - top: `${containerBounds.top}px`, - width: `${containerBounds.width}px`, - height: `${containerBounds.height}px`, - } - : undefined - - const handleBackdropClick = () => { - onBackdropClick?.() - onClose() - } - - return ( - <> - {/* Invisible backdrop to catch clicks outside */} -
- - {/* Popover content */} -
e.stopPropagation()} // Prevent closing when clicking inside - className="fixed backdrop-blur-[12px] bg-white/5 border border-white/25 rounded-xl p-4 w-80 z-[1000] pointer-events-auto shadow-[0_20px_25px_-5px_rgb(0_0_0/0.3),0_8px_10px_-6px_rgb(0_0_0/0.3)]" - style={{ - left: `${x}px`, - top: `${y}px`, - }} - > - {node.type === "document" ? ( - // Document popover -
- {/* Header */} -
-
- - - - - - - -

Document

-
- -
- - {/* Sections */} -
- {/* Title */} -
-
- Title -
-

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

-
- - {/* Summary - truncated to 2 lines */} - {(node.data as any).summary && ( -
-
- Summary -
-

- {(node.data as any).summary} -

-
- )} - - {/* Type */} -
-
- Type -
-

- {(node.data as any).type || "Document"} -

-
- - {/* Memory Count */} -
-
- Memory Count -
-

- {(node.data as any).memoryEntries?.length || 0} memories -

-
- - {/* URL */} - {((node.data as any).url || (node.data as any).customId) && ( - - )} - - {/* Footer with metadata */} -
-
- - - - - - - - {new Date( - (node.data as any).createdAt, - ).toLocaleDateString()} - -
-
- - - - - - - - {node.id} - -
-
-
-
- ) : ( - // Memory popover -
- {/* Header */} -
-
- - - - -

Memory

-
- -
- - {/* Sections */} -
- {/* Memory content */} -
-
- Memory -
-

- {(node.data as any).memory || - (node.data as any).content || - "No content"} -

- {(node.data as any).isForgotten && ( -
- Forgotten -
- )} - {/* Expires (inline with memory if exists) */} - {(node.data as any).forgetAfter && ( -

- Expires:{" "} - {new Date( - (node.data as any).forgetAfter, - ).toLocaleDateString()} - {(node.data as any).forgetReason && - ` - ${(node.data as any).forgetReason}`} -

- )} -
- - {/* Space */} -
-
- Space -
-

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

-
- - {/* Footer with metadata */} -
-
- - - - - - - - {new Date( - (node.data as any).createdAt, - ).toLocaleDateString()} - -
-
- - - - - - - - {node.id} - -
-
-
-
- )} -
- - ) -}) diff --git a/apps/web/components/memory-graph/types.ts b/apps/web/components/memory-graph/types.ts deleted file mode 100644 index f20243f6..00000000 --- a/apps/web/components/memory-graph/types.ts +++ /dev/null @@ -1,163 +0,0 @@ -import type { - DocumentsResponse, - DocumentWithMemories, - MemoryEntry, -} from "./api-types" - -export type { DocumentsResponse, DocumentWithMemories, MemoryEntry } - -// Graph API types matching backend response - -export interface GraphApiMemory { - id: string - memory: string - isStatic: boolean - spaceId: string - isLatest: boolean - isForgotten: boolean - forgetAfter: string | null - forgetReason: string | null - version: number - parentMemoryId: string | null - rootMemoryId: string | null - createdAt: string - updatedAt: string -} - -export interface GraphApiDocument { - id: string - title: string | null - summary: string | null - documentType: string - createdAt: string - updatedAt: string - x: number // backend coordinates (dynamic range) - y: number // backend coordinates (dynamic range) - memories: GraphApiMemory[] -} - -export interface GraphApiEdge { - source: string - target: string - similarity: number // 0-1 -} - -export interface GraphViewportResponse { - documents: GraphApiDocument[] - edges: GraphApiEdge[] - viewport: { - minX: number - maxX: number - minY: number - maxY: number - } - totalCount: number -} - -export interface GraphBoundsResponse { - bounds: { - minX: number - maxX: number - minY: number - maxY: number - } | null -} - -export interface GraphStatsResponse { - totalDocuments: number - documentsWithSpatial: number - totalDocumentEdges: number -} - -// Typed node data - -export interface DocumentNodeData { - id: string - title: string | null - summary: string | null - type: string - createdAt: string - updatedAt: string - memories: GraphApiMemory[] -} - -export interface MemoryNodeData { - id: string - memory: string - content: string - documentId: string - isStatic: boolean - isLatest: boolean - isForgotten: boolean - forgetAfter: string | null - forgetReason: string | null - version: number - parentMemoryId: string | null - spaceId: string - createdAt: string - updatedAt: string -} - -export interface GraphNode { - id: string - type: "document" | "memory" - x: number - y: number - data: DocumentNodeData | MemoryNodeData - size: number - borderColor: string - isHovered: boolean - isDragging: boolean - // D3-force simulation properties - vx?: number - vy?: number - fx?: number | null - fy?: number | null -} - -export interface GraphEdge { - id: string - source: string | GraphNode - target: string | GraphNode - similarity: number - visualProps: { - opacity: number - thickness: number - } - edgeType: "doc-memory" | "similarity" | "version" -} - -export interface GraphCanvasProps { - nodes: GraphNode[] - edges: GraphEdge[] - width: number - height: number - highlightDocumentIds?: string[] - selectedNodeId?: string | null - onNodeHover: (nodeId: string | null) => void - onNodeClick: (nodeId: string | null) => void - onNodeDragStart: (nodeId: string) => void - onNodeDragEnd: () => void - onViewportChange?: (zoom: number) => void - canvasRef?: React.RefObject - variant?: "console" | "consumer" - simulation?: import("./canvas/simulation").ForceSimulation - viewportRef?: React.RefObject< - import("./canvas/viewport").ViewportState | null - > -} - -export interface LegendProps { - variant?: "console" | "consumer" - nodes?: GraphNode[] - edges?: GraphEdge[] - isLoading?: boolean - hoveredNode?: string | null -} - -export interface LoadingIndicatorProps { - isLoading: boolean - isLoadingMore: boolean - totalLoaded: number - variant?: "console" | "consumer" -} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index e53a5c2f..c4992e71 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -6,6 +6,7 @@ const nextConfig: NextConfig = { ignoreBuildErrors: true, }, transpilePackages: [ + "@supermemory/memory-graph", "@tiptap/core", "@tiptap/react", "@tiptap/pm", diff --git a/apps/web/package.json b/apps/web/package.json index db7632c7..5551c607 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -106,7 +106,8 @@ "vaul": "^1.1.2", "zustand": "^5.0.7", "@repo/lib": "workspace:*", - "@repo/validation": "workspace:*" + "@repo/validation": "workspace:*", + "@supermemory/memory-graph": "workspace:*" }, "devDependencies": { "@biomejs/biome": "^2.2.2", diff --git a/bun.lock b/bun.lock index b98246e1..945692e6 100644 --- a/bun.lock +++ b/bun.lock @@ -161,6 +161,7 @@ "@repo/lib": "workspace:*", "@repo/validation": "workspace:*", "@sentry/nextjs": "^10.33.0", + "@supermemory/memory-graph": "workspace:*", "@tailwindcss/typography": "^0.5.16", "@tanstack/react-form": "^1.12.4", "@tanstack/react-query": "^5.90.14", @@ -294,26 +295,18 @@ }, "packages/memory-graph": { "name": "@supermemory/memory-graph", - "version": "0.1.8", + "version": "0.2.0", "dependencies": { - "@emotion/is-prop-valid": "^1.4.0", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-slot": "^1.2.4", - "@vanilla-extract/css": "^1.17.4", - "@vanilla-extract/recipes": "^0.5.7", - "@vanilla-extract/sprinkles": "^1.6.5", "d3-force": "^3.0.0", - "lucide-react": "^0.552.0", - "motion": "^12.23.24", }, "devDependencies": { "@types/d3-force": "^3.0.10", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", - "@vanilla-extract/vite-plugin": "^5.1.1", "@vitejs/plugin-react": "^5.1.0", "typescript": "^5.9.3", "vite": "^7.2.1", + "vitest": "^3.2.4", }, "peerDependencies": { "react": ">=18.0.0", @@ -2059,22 +2052,12 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@vanilla-extract/babel-plugin-debug-ids": ["@vanilla-extract/babel-plugin-debug-ids@1.2.2", "", { "dependencies": { "@babel/core": "^7.23.9" } }, "sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw=="], - - "@vanilla-extract/compiler": ["@vanilla-extract/compiler@0.3.4", "", { "dependencies": { "@vanilla-extract/css": "^1.18.0", "@vanilla-extract/integration": "^8.0.7", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", "vite-node": "^3.2.2" } }, "sha512-W9HXf9EAccpE1vEIATvSoBVj/bQnmHfYHfDJjUN8dcOHW6oMcnoGTqweDM9I66BHqlNH4d0IsaeZKSViOv7K4w=="], - "@vanilla-extract/css": ["@vanilla-extract/css@1.18.0", "", { "dependencies": { "@emotion/hash": "^0.9.0", "@vanilla-extract/private": "^1.0.9", "css-what": "^6.1.0", "cssesc": "^3.0.0", "csstype": "^3.2.3", "dedent": "^1.5.3", "deep-object-diff": "^1.1.9", "deepmerge": "^4.2.2", "lru-cache": "^10.4.3", "media-query-parser": "^2.0.2", "modern-ahocorasick": "^1.0.0", "picocolors": "^1.0.0" } }, "sha512-/p0dwOjr0o8gE5BRQ5O9P0u/2DjUd6Zfga2JGmE4KaY7ZITWMszTzk4x4CPlM5cKkRr2ZGzbE6XkuPNfp9shSQ=="], - "@vanilla-extract/integration": ["@vanilla-extract/integration@8.0.7", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/plugin-syntax-typescript": "^7.23.3", "@vanilla-extract/babel-plugin-debug-ids": "^1.2.2", "@vanilla-extract/css": "^1.18.0", "dedent": "^1.5.3", "esbuild": "npm:esbuild@>=0.17.6 <0.28.0", "eval": "0.1.8", "find-up": "^5.0.0", "javascript-stringify": "^2.0.1", "mlly": "^1.4.2" } }, "sha512-ILob4F9cEHXpbWAVt3Y2iaQJpqYq/c/5TJC8Fz58C2XmX3QW2Y589krvViiyJhQfydCGK3EbwPQhVFjQaBeKfg=="], - "@vanilla-extract/private": ["@vanilla-extract/private@1.0.9", "", {}, "sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA=="], "@vanilla-extract/recipes": ["@vanilla-extract/recipes@0.5.7", "", { "peerDependencies": { "@vanilla-extract/css": "^1.0.0" } }, "sha512-Fvr+htdyb6LVUu+PhH61UFPhwkjgDEk8L4Zq9oIdte42sntpKrgFy90MyTRtGwjVALmrJ0pwRUVr8UoByYeW8A=="], - "@vanilla-extract/sprinkles": ["@vanilla-extract/sprinkles@1.6.5", "", { "peerDependencies": { "@vanilla-extract/css": "^1.0.0" } }, "sha512-HOYidLONR/SeGk8NBAeI64I4gYdsMX9vJmniL13ZcLVwawyK0s2GUENEAcGA+GYLIoeyQB61UqmhqPodJry7zA=="], - - "@vanilla-extract/vite-plugin": ["@vanilla-extract/vite-plugin@5.1.4", "", { "dependencies": { "@vanilla-extract/compiler": "^0.3.4", "@vanilla-extract/integration": "^8.0.7" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-fTYNKUK3n4ApkUf2FEcO7mpqNKEHf9kDGg8DXlkqHtPxgwPhjuaajmDfQCSBsNgnA2SLI+CB5EO6kLQuKsw2Rw=="], - "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="], @@ -2829,8 +2812,6 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - "eval": ["eval@0.1.8", "", { "dependencies": { "@types/node": "*", "require-like": ">= 0.1.1" } }, "sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw=="], - "event-target-polyfill": ["event-target-polyfill@0.0.4", "", {}, "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ=="], "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], @@ -3299,8 +3280,6 @@ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - "javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="], - "jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -4177,8 +4156,6 @@ "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], - "require-like": ["require-like@0.1.2", "", {}, "sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A=="], - "resend": ["resend@4.8.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA=="], "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], @@ -5169,8 +5146,6 @@ "@supermemory/ai-sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "@supermemory/memory-graph/lucide-react": ["lucide-react@0.552.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-g9WCjmfwqbexSnZE+2cl21PCfXOcqnGeWeMTNAOGEfpPbm/ZF4YIq77Z8qWrxbu660EKuLB4nSLggoKnCb+isw=="], - "@supermemory/memory-graph/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "@supermemory/tools/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.70", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W3WjQlb0Ho+CVAQUvb8Rtk3hGS3Jlgy79ihY2H0yj2k4yU8XuxpQw0Oz+7JQsB47j+jlHhk7nUXtxhAeRg3S3Q=="], @@ -5199,8 +5174,6 @@ "@vanilla-extract/css/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "@vanilla-extract/integration/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], - "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], @@ -6043,58 +6016,6 @@ "@supermemory/tools/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="], - "@vanilla-extract/integration/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], - - "@vanilla-extract/integration/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], - - "@vanilla-extract/integration/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], - - "@vanilla-extract/integration/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], - - "@vanilla-extract/integration/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], - - "@vanilla-extract/integration/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], - - "@vanilla-extract/integration/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], - - "@vanilla-extract/integration/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], - - "@vanilla-extract/integration/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], - - "@vanilla-extract/integration/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], - - "@vanilla-extract/integration/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], - - "@vanilla-extract/integration/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], - - "@vanilla-extract/integration/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - "agents/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "agents/@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], diff --git a/packages/memory-graph/package.json b/packages/memory-graph/package.json index a0cb5bb4..403375bc 100644 --- a/packages/memory-graph/package.json +++ b/packages/memory-graph/package.json @@ -1,37 +1,27 @@ { "name": "@supermemory/memory-graph", - "version": "0.1.8", + "version": "0.2.0", "description": "Interactive graph visualization component for Supermemory - visualize and explore your memory connections", "type": "module", - "main": "./dist/memory-graph.cjs", - "module": "./dist/memory-graph.js", - "types": "./dist/index.d.ts", + "main": "./src/index.tsx", + "module": "./src/index.tsx", + "types": "./src/index.tsx", "exports": { - ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/memory-graph.js" - }, - "require": { - "types": "./dist/index.d.ts", - "default": "./dist/memory-graph.cjs" - } - }, - "./styles.css": "./dist/memory-graph.css", + ".": "./src/index.tsx", + "./mock-data": "./src/mock-data.ts", "./package.json": "./package.json" }, "files": [ "dist", "README.md" ], - "sideEffects": [ - "**/*.css" - ], "scripts": { "dev": "vite build --watch", "build": "vite build && tsc --emitDeclarationOnly", "check-types": "tsc --noEmit", - "prepublishOnly": "bun run build" + "prepack": "bun run build && bun run scripts/swap-exports.ts pack", + "postpack": "bun run scripts/swap-exports.ts unpack", + "test": "vitest run" }, "keywords": [ "supermemory", @@ -59,23 +49,15 @@ "react-dom": ">=18.0.0" }, "dependencies": { - "@emotion/is-prop-valid": "^1.4.0", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-slot": "^1.2.4", - "@vanilla-extract/css": "^1.17.4", - "@vanilla-extract/recipes": "^0.5.7", - "@vanilla-extract/sprinkles": "^1.6.5", - "d3-force": "^3.0.0", - "lucide-react": "^0.552.0", - "motion": "^12.23.24" + "d3-force": "^3.0.0" }, "devDependencies": { "@types/d3-force": "^3.0.10", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", - "@vanilla-extract/vite-plugin": "^5.1.1", "@vitejs/plugin-react": "^5.1.0", "typescript": "^5.9.3", - "vite": "^7.2.1" + "vite": "^7.2.1", + "vitest": "^3.2.4" } } diff --git a/packages/memory-graph/scripts/swap-exports.ts b/packages/memory-graph/scripts/swap-exports.ts new file mode 100644 index 00000000..1049ca43 --- /dev/null +++ b/packages/memory-graph/scripts/swap-exports.ts @@ -0,0 +1,50 @@ +/** + * Swaps package.json exports between source (for workspace/monorepo use) + * and dist (for npm publishing). + * + * Usage: + * bun run scripts/swap-exports.ts pack # switch to dist exports + * bun run scripts/swap-exports.ts unpack # switch back to source exports + */ +import { readFileSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +const pkgPath = join(import.meta.dirname, "..", "package.json") +const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) + +const mode = process.argv[2] + +if (mode === "pack") { + // Switch to dist exports for npm publishing + pkg.main = "./dist/memory-graph.cjs" + pkg.module = "./dist/memory-graph.js" + pkg.types = "./dist/index.d.ts" + pkg.exports = { + ".": { + types: "./dist/index.d.ts", + import: "./dist/memory-graph.js", + require: "./dist/memory-graph.cjs", + }, + "./mock-data": { + types: "./dist/mock-data.d.ts", + import: "./dist/mock-data.js", + }, + "./package.json": "./package.json", + } +} else if (mode === "unpack") { + // Switch back to source exports for workspace use + pkg.main = "./src/index.tsx" + pkg.module = "./src/index.tsx" + pkg.types = "./src/index.tsx" + pkg.exports = { + ".": "./src/index.tsx", + "./mock-data": "./src/mock-data.ts", + "./package.json": "./package.json", + } +} else { + console.error("Usage: bun run scripts/swap-exports.ts [pack|unpack]") + process.exit(1) +} + +writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`) +console.log(`Exports swapped to ${mode === "pack" ? "dist" : "source"} mode`) diff --git a/packages/memory-graph/src/__tests__/edge-logic.test.ts b/packages/memory-graph/src/__tests__/edge-logic.test.ts new file mode 100644 index 00000000..ff9003f4 --- /dev/null +++ b/packages/memory-graph/src/__tests__/edge-logic.test.ts @@ -0,0 +1,655 @@ +/** + * Adversarial tests for the three core changes in graph-perf-consolidation. + * + * Focuses on the exact logic that was changed, NOT on surrounding scaffolding: + * + * (1) use-graph-data.ts — edges useMemo now uses memoryRelations Record as + * primary source, falls back to parentMemoryId for legacy data. + * The old code expected an ARRAY [{relationType, targetMemoryId}]. + * The new code expects a Record. + * + * (2) MCP mcp-app.ts — transformData now pre-populates nodeIds before edge + * computation, fixing a forward-reference bug where edges to memories + * that appeared later in the iteration order were silently dropped. + * + * (3) Invalid relationType values now default to "updates" instead of blowing up. + * + * The edges useMemo is a pure function of `documents` — no React hook machinery + * is needed. We extract the identical logic here and test it directly. + */ + +import { describe, it, expect } from "vitest" +import type { GraphApiDocument, GraphApiMemory } from "../types" +import { getEdgeVisualProps } from "../hooks/use-graph-data" + +// --------------------------------------------------------------------------- +// Pure extraction of edges useMemo from use-graph-data.ts +// This is a verbatim copy of the logic so a regression in the source will +// cause this test to diverge — but more importantly, we can verify the exact +// semantics match the spec described in the commit messages. +// --------------------------------------------------------------------------- + +interface ComputedEdge { + id: string + source: string + target: string + edgeType: string +} + +function computeEdges(documents: GraphApiDocument[]): ComputedEdge[] { + if (!documents || documents.length === 0) return [] + + const result: ComputedEdge[] = [] + + // Pre-populate all node IDs (the key step from the forward-reference fix) + const allNodeIds = new Set() + for (const doc of documents) { + allNodeIds.add(doc.id) + for (const mem of doc.memories) allNodeIds.add(mem.id) + } + + // 1. Derives edges: document -> memory (structural) + for (const doc of documents) { + for (const mem of doc.memories) { + result.push({ + id: `dm-${doc.id}-${mem.id}`, + source: doc.id, + target: mem.id, + edgeType: "derives", + }) + } + } + + // 2. Memory-to-memory relation edges from backend data. + // Uses memoryRelations Record as primary source, + // falls back to parentMemoryId for legacy data. + for (const doc of documents) { + for (const mem of doc.memories) { + let relations: Record = {} + + if ( + mem.memoryRelations && + typeof mem.memoryRelations === "object" && + Object.keys(mem.memoryRelations).length > 0 + ) { + relations = mem.memoryRelations + } else if (mem.parentMemoryId) { + // Legacy fallback: parentMemoryId implies "updates" + relations = { [mem.parentMemoryId]: "updates" } + } + + for (const [targetId, relationType] of Object.entries(relations)) { + if (!allNodeIds.has(targetId)) continue + const edgeType = + relationType === "updates" || + relationType === "extends" || + relationType === "derives" + ? relationType + : "updates" + result.push({ + id: `rel-${targetId}-${mem.id}`, + source: targetId, + target: mem.id, + edgeType, + }) + } + } + } + + return result +} + +// --------------------------------------------------------------------------- +// MCP transformData logic — pure extraction from mcp-app.ts transformData(). +// Includes the forward-reference fix (nodeIds pre-populated before edges). +// --------------------------------------------------------------------------- + +interface McpLink { + source: string + target: string + edgeType: "derives" | "updates" | "extends" +} + +function mcpComputeLinks( + documents: Array<{ + id: string + memories: Array<{ + id: string + parentMemoryId: string | null + memoryRelations?: Record | null + }> + }>, +): McpLink[] { + const links: McpLink[] = [] + + // Pre-populate all node IDs (the forward-reference fix) + const nodeIds = new Set() + for (const doc of documents) { + nodeIds.add(doc.id) + for (const mem of doc.memories) nodeIds.add(mem.id) + } + + for (const doc of documents) { + for (const mem of doc.memories) { + // Derives link (doc -> memory) + links.push({ source: doc.id, target: mem.id, edgeType: "derives" }) + + let relations: Record = {} + if ( + mem.memoryRelations && + typeof mem.memoryRelations === "object" && + Object.keys(mem.memoryRelations).length > 0 + ) { + relations = mem.memoryRelations + } else if (mem.parentMemoryId) { + relations = { [mem.parentMemoryId]: "updates" } + } + + for (const [targetId, relationType] of Object.entries(relations)) { + if (!nodeIds.has(targetId)) continue + const edgeType = + relationType === "updates" || + relationType === "extends" || + relationType === "derives" + ? relationType + : "updates" + links.push({ source: targetId, target: mem.id, edgeType }) + } + } + } + + return links +} + +/** The BUGGY version of mcpComputeLinks: nodeIds populated lazily (per-memory) + * rather than upfront. Used to prove the regression test would catch the bug. */ +function mcpComputeLinks_BUGGY( + documents: Array<{ + id: string + memories: Array<{ + id: string + parentMemoryId: string | null + memoryRelations?: Record | null + }> + }>, +): McpLink[] { + const links: McpLink[] = [] + const nodeIds = new Set() // NOT pre-populated — reproduces the old bug + + for (const doc of documents) { + nodeIds.add(doc.id) + for (const mem of doc.memories) { + nodeIds.add(mem.id) // only added as we reach this memory + + links.push({ source: doc.id, target: mem.id, edgeType: "derives" }) + + let relations: Record = {} + if ( + mem.memoryRelations && + typeof mem.memoryRelations === "object" && + Object.keys(mem.memoryRelations).length > 0 + ) { + relations = mem.memoryRelations + } else if (mem.parentMemoryId) { + relations = { [mem.parentMemoryId]: "updates" } + } + + for (const [targetId, relationType] of Object.entries(relations)) { + if (!nodeIds.has(targetId)) continue // forward refs silently dropped here! + const edgeType = + relationType === "updates" || + relationType === "extends" || + relationType === "derives" + ? relationType + : "updates" + links.push({ source: targetId, target: mem.id, edgeType }) + } + } + } + + return links +} + +// --------------------------------------------------------------------------- +// Helper factories +// --------------------------------------------------------------------------- + +function makeMem( + overrides: Partial & { id: string }, +): GraphApiMemory { + return { + memory: `Memory ${overrides.id}`, + isStatic: false, + spaceId: "default", + isLatest: true, + isForgotten: false, + forgetAfter: null, + forgetReason: null, + version: 1, + parentMemoryId: null, + rootMemoryId: null, + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + ...overrides, + } +} + +function makeDoc(id: string, memories: GraphApiMemory[]): GraphApiDocument { + return { + id, + title: `Doc ${id}`, + summary: null, + documentType: "text", + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + memories, + } +} + +// =========================================================================== +// (1) memoryRelations Record as PRIMARY source +// =========================================================================== + +describe("use-graph-data edges: memoryRelations Record as primary source", () => { + it("creates an extends edge from memoryRelations { targetId: 'extends' }", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", memoryRelations: { m1: "extends" } }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find((e) => e.source === "m1" && e.target === "m2") + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("extends") + }) + + it("creates an updates edge from memoryRelations { targetId: 'updates' }", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", memoryRelations: { m1: "updates" } }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find((e) => e.source === "m1" && e.target === "m2") + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("updates") + }) + + it("creates a derives edge from memoryRelations { targetId: 'derives' }", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", memoryRelations: { m1: "derives" } }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find( + (e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"), + ) + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("derives") + }) + + it("creates multiple edges when memoryRelations has multiple targets", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2" }), + makeMem({ + id: "m3", + memoryRelations: { m1: "updates", m2: "extends" }, + }), + ]), + ] + const edges = computeEdges(docs) + const relEdgesToM3 = edges.filter( + (e) => e.target === "m3" && e.id.startsWith("rel-"), + ) + expect(relEdgesToM3.length).toBe(2) + const types = new Set(relEdgesToM3.map((e) => e.edgeType)) + expect(types.has("updates")).toBe(true) + expect(types.has("extends")).toBe(true) + }) +}) + +// =========================================================================== +// (2) memoryRelations TAKES PRECEDENCE over parentMemoryId +// =========================================================================== + +describe("use-graph-data edges: memoryRelations wins over parentMemoryId", () => { + /** + * Critical regression test: if a memory has BOTH memoryRelations and + * parentMemoryId, only memoryRelations should be used. The fallback + * parentMemoryId path must NOT fire when memoryRelations is present. + */ + it("uses extends from memoryRelations, ignores parentMemoryId updates fallback", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "mParent" }), + makeMem({ id: "mOther" }), + makeMem({ + id: "m3", + // parentMemoryId would imply an "updates" edge to mParent + parentMemoryId: "mParent", + // memoryRelations says extends to mOther — this should win + memoryRelations: { mOther: "extends" }, + }), + ]), + ] + const edges = computeEdges(docs) + const relEdges = edges.filter( + (e) => e.id.startsWith("rel-") && e.target === "m3", + ) + + // Should be exactly ONE rel edge (extends to mOther), NOT two + expect(relEdges.length).toBe(1) + expect(relEdges[0]?.edgeType).toBe("extends") + expect(relEdges[0]?.source).toBe("mOther") + + // The parentMemoryId-implied updates edge to mParent must NOT exist + const badEdge = relEdges.find((e) => e.source === "mParent") + expect(badEdge).toBeUndefined() + }) +}) + +// =========================================================================== +// (3) parentMemoryId FALLBACK when memoryRelations absent/null/empty +// =========================================================================== + +describe("use-graph-data edges: parentMemoryId fallback", () => { + it("creates updates edge from parentMemoryId when memoryRelations is absent", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", parentMemoryId: "m1" }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find( + (e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"), + ) + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("updates") + }) + + it("creates updates edge from parentMemoryId when memoryRelations is null", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", parentMemoryId: "m1", memoryRelations: null }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find( + (e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"), + ) + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("updates") + }) + + it("creates updates edge from parentMemoryId when memoryRelations is empty {}", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ id: "m2", parentMemoryId: "m1", memoryRelations: {} }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find( + (e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"), + ) + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("updates") + }) + + it("drops the parentMemoryId edge when the parent is not in the dataset", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1", parentMemoryId: "ghost_id_not_in_docs" }), + ]), + ] + const edges = computeEdges(docs) + const ghostEdges = edges.filter((e) => e.source === "ghost_id_not_in_docs") + expect(ghostEdges.length).toBe(0) + }) +}) + +// =========================================================================== +// (4) Invalid relationType defaults to "updates" +// =========================================================================== + +describe("use-graph-data edges: invalid relationType defaults to updates", () => { + it("maps an unknown relationType string to 'updates' edge type", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), + makeMem({ + id: "m2", + // biome-ignore lint/suspicious/noExplicitAny: intentional bad-data test + memoryRelations: { m1: "totally-bogus-type" as any }, + }), + ]), + ] + const edges = computeEdges(docs) + const relEdge = edges.find( + (e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"), + ) + expect(relEdge).toBeDefined() + expect(relEdge?.edgeType).toBe("updates") + }) +}) + +// =========================================================================== +// (5) MCP forward-reference fix — THE CRITICAL BUG +// =========================================================================== + +describe("MCP transformData: forward-reference fix (pre-populated nodeIds)", () => { + /** + * SETUP: Two memories in the same document. m1 is processed first. + * m1 has memoryRelations pointing at m2, which appears AFTER m1. + * + * OLD BUG: nodeIds was built lazily — when m1 was processed, m2 had not + * yet been added to nodeIds, so `!nodeIds.has("m2")` was true and the + * edge was silently skipped. + * + * FIX: All node IDs are pre-populated before any edge is evaluated, so + * m2 is always in nodeIds when m1's relations are checked. + */ + it("creates edge for forward-referenced memory within same document", () => { + const docs = [ + { + id: "d1", + memories: [ + // m1 comes FIRST and references m2 which comes SECOND + { + id: "m1", + parentMemoryId: null, + memoryRelations: { m2: "extends" }, + }, + { id: "m2", parentMemoryId: null, memoryRelations: null }, + ], + }, + ] + + // Fixed version creates the edge + const fixedLinks = mcpComputeLinks(docs) + const edge = fixedLinks.find((l) => l.source === "m2" && l.target === "m1") + expect(edge).toBeDefined() + expect(edge?.edgeType).toBe("extends") + }) + + it("creates edge for forward-referenced memory in a LATER document", () => { + // m1 in doc1 references m2 in doc2. m2 is only encountered during + // doc2's iteration, AFTER m1's relations are evaluated. + const docs = [ + { + id: "d1", + memories: [ + { + id: "m1", + parentMemoryId: null, + memoryRelations: { m2: "updates" }, + }, + ], + }, + { + id: "d2", + memories: [{ id: "m2", parentMemoryId: null, memoryRelations: null }], + }, + ] + + const fixedLinks = mcpComputeLinks(docs) + const edge = fixedLinks.find((l) => l.source === "m2" && l.target === "m1") + expect(edge).toBeDefined() + expect(edge?.edgeType).toBe("updates") + }) + + /** + * REGRESSION PROOF: Run the same scenario through the BUGGY implementation. + * The buggy version MUST drop the edge. If this test fails (i.e. buggy code + * creates the edge too), the forward-reference scenario doesn't actually + * demonstrate the bug, and our fix tests prove nothing. + */ + it("proves the bug: lazy nodeIds population drops forward-referenced edges", () => { + const docs = [ + { + id: "d1", + memories: [ + { + id: "m1", + parentMemoryId: null, + memoryRelations: { m2: "extends" }, + }, + { id: "m2", parentMemoryId: null, memoryRelations: null }, + ], + }, + ] + + // The buggy version MUST drop the m1->m2 edge (forward reference) + const buggyLinks = mcpComputeLinks_BUGGY(docs) + const buggyEdge = buggyLinks.find( + (l) => l.source === "m2" && l.target === "m1", + ) + expect(buggyEdge).toBeUndefined() // confirms the bug was real + + // The fixed version MUST create it + const fixedLinks = mcpComputeLinks(docs) + const fixedEdge = fixedLinks.find( + (l) => l.source === "m2" && l.target === "m1", + ) + expect(fixedEdge).toBeDefined() // confirms the fix works + }) + + it("proves the bug: cross-document forward reference also dropped by lazy code", () => { + const docs = [ + { + id: "d1", + memories: [ + { + id: "m1", + parentMemoryId: null, + memoryRelations: { m2: "updates" }, + }, + ], + }, + { + id: "d2", + memories: [{ id: "m2", parentMemoryId: null, memoryRelations: null }], + }, + ] + + // Buggy: m2 is not in nodeIds when m1 is processed — edge dropped + const buggyLinks = mcpComputeLinks_BUGGY(docs) + expect( + buggyLinks.find((l) => l.source === "m2" && l.target === "m1"), + ).toBeUndefined() + + // Fixed: m2 is pre-populated — edge created + const fixedLinks = mcpComputeLinks(docs) + expect( + fixedLinks.find((l) => l.source === "m2" && l.target === "m1"), + ).toBeDefined() + }) +}) + +// =========================================================================== +// (6) Structural correctness: derives edges always created, doc->mem +// =========================================================================== + +describe("use-graph-data edges: derives edges always present for all doc->mem pairs", () => { + it("creates exactly one derives edge per memory across multiple documents", () => { + const docs = [ + makeDoc("d1", [makeMem({ id: "m1" }), makeMem({ id: "m2" })]), + makeDoc("d2", [ + makeMem({ id: "m3" }), + makeMem({ id: "m4" }), + makeMem({ id: "m5" }), + ]), + ] + const edges = computeEdges(docs) + const derivesEdges = edges.filter((e) => e.edgeType === "derives") + // 2 memories in d1 + 3 in d2 = 5 derives edges + expect(derivesEdges.length).toBe(5) + expect(derivesEdges.map((e) => e.target).sort()).toEqual([ + "m1", + "m2", + "m3", + "m4", + "m5", + ]) + }) + + it("returns empty array for empty documents input", () => { + expect(computeEdges([])).toEqual([]) + }) + + it("creates no relation edges when all memories are standalone", () => { + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1" }), // no parentMemoryId, no memoryRelations + makeMem({ id: "m2" }), + ]), + ] + const edges = computeEdges(docs) + const relEdges = edges.filter((e) => e.id.startsWith("rel-")) + expect(relEdges.length).toBe(0) + }) +}) + +// =========================================================================== +// (7) getEdgeVisualProps: the MemoryRelation type is the canonical source +// =========================================================================== + +describe("getEdgeVisualProps: all MemoryRelation values return valid visual props", () => { + const relations = ["updates", "extends", "derives"] as const + + for (const rel of relations) { + it(`returns positive opacity and thickness for '${rel}'`, () => { + const props = getEdgeVisualProps(rel) + expect(props.opacity).toBeGreaterThan(0) + expect(props.thickness).toBeGreaterThan(0) + }) + } + + it("extends edges have higher opacity than derives edges (rare but meaningful)", () => { + const ext = getEdgeVisualProps("extends") + const der = getEdgeVisualProps("derives") + expect(ext.opacity).toBeGreaterThan(der.opacity) + }) + + it("updates edges have higher opacity than derives edges (version chains are prominent)", () => { + const upd = getEdgeVisualProps("updates") + const der = getEdgeVisualProps("derives") + expect(upd.opacity).toBeGreaterThan(der.opacity) + }) + + it("unknown edge type returns default props (opacity 0.4, thickness 1.2)", () => { + // The default case returns { opacity: 0.4, thickness: 1.2 }. + // A safe conservative fallback matching derives (the most common edge type). + const unknown = getEdgeVisualProps("nonexistent") + expect(unknown.opacity).toBeCloseTo(0.4) + expect(unknown.thickness).toBeCloseTo(1.2) + }) +}) diff --git a/packages/memory-graph/src/__tests__/graph-data-utils.test.ts b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts new file mode 100644 index 00000000..9c643925 --- /dev/null +++ b/packages/memory-graph/src/__tests__/graph-data-utils.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest" +import { + getMemoryBorderColor, + getEdgeVisualProps, +} from "../hooks/use-graph-data" +import { DEFAULT_COLORS } from "../constants" +import type { GraphApiMemory } from "../types" + +function makeMemory(overrides: Partial = {}): GraphApiMemory { + return { + id: "m1", + memory: "test", + isStatic: false, + spaceId: "default", + isLatest: true, + isForgotten: false, + forgetAfter: null, + forgetReason: null, + version: 1, + parentMemoryId: null, + rootMemoryId: null, + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + ...overrides, + } +} + +describe("getMemoryBorderColor", () => { + const colors = DEFAULT_COLORS + + it("returns forgotten color for forgotten memories", () => { + const mem = makeMemory({ isForgotten: true }) + expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderForgotten) + }) + + it("returns expiring color for memories expiring within 7 days", () => { + const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString() + const mem = makeMemory({ forgetAfter: soon }) + expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderExpiring) + }) + + it("returns recent color for memories created within 24 hours", () => { + const recent = new Date(Date.now() - 1000).toISOString() + const mem = makeMemory({ createdAt: recent }) + expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderRecent) + }) + + it("returns default color for normal memories", () => { + const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() + const mem = makeMemory({ createdAt: old }) + expect(getMemoryBorderColor(mem, colors)).toBe(colors.memStrokeDefault) + }) + + it("forgotten takes priority over expiring", () => { + const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString() + const mem = makeMemory({ isForgotten: true, forgetAfter: soon }) + expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderForgotten) + }) +}) + +describe("getEdgeVisualProps", () => { + it("returns correct props for derives edges", () => { + const props = getEdgeVisualProps("derives") + expect(props.opacity).toBeCloseTo(0.4) + expect(props.thickness).toBeCloseTo(1.2) + }) + + it("returns correct props for updates edges", () => { + const props = getEdgeVisualProps("updates") + expect(props.opacity).toBeCloseTo(0.7) + expect(props.thickness).toBeCloseTo(2) + }) + + it("returns correct props for extends edges", () => { + const props = getEdgeVisualProps("extends") + expect(props.opacity).toBeCloseTo(0.55) + expect(props.thickness).toBeCloseTo(1.5) + }) + + it("returns default props for unknown edge types", () => { + const props = getEdgeVisualProps("unknown") + expect(props.opacity).toBeCloseTo(0.4) + expect(props.thickness).toBeCloseTo(1.2) + }) +}) diff --git a/packages/memory-graph/src/__tests__/mock-data.test.ts b/packages/memory-graph/src/__tests__/mock-data.test.ts new file mode 100644 index 00000000..4774497d --- /dev/null +++ b/packages/memory-graph/src/__tests__/mock-data.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest" +import { generateMockGraphData } from "../mock-data" + +describe("generateMockGraphData", () => { + it("produces deterministic output with same seed", () => { + const data1 = generateMockGraphData({ documentCount: 10, seed: 42 }) + const data2 = generateMockGraphData({ documentCount: 10, seed: 42 }) + + expect(data1.documents.length).toBe(data2.documents.length) + expect(data1.documents[0]!.id).toBe(data2.documents[0]!.id) + expect(data1.documents[0]!.title).toBe(data2.documents[0]!.title) + }) + + it("produces different output with different seeds", () => { + const data1 = generateMockGraphData({ documentCount: 10, seed: 42 }) + const data2 = generateMockGraphData({ documentCount: 10, seed: 99 }) + + // At least some documents should differ + const titles1 = data1.documents.map((d) => d.title).join(",") + const titles2 = data2.documents.map((d) => d.title).join(",") + expect(titles1).not.toBe(titles2) + }) + + it("generates correct number of documents", () => { + const data = generateMockGraphData({ documentCount: 25, seed: 1 }) + expect(data.documents.length).toBe(25) + }) + + it("documents have required fields", () => { + const data = generateMockGraphData({ documentCount: 5, seed: 1 }) + for (const doc of data.documents) { + expect(doc.id).toBeDefined() + expect(doc.title).toBeDefined() + expect(doc.summary).toBeDefined() + expect(doc.documentType).toBeDefined() + expect(doc.createdAt).toBeDefined() + expect(doc.updatedAt).toBeDefined() + expect(Array.isArray(doc.memories)).toBe(true) + } + }) + + it("memories have required fields", () => { + const data = generateMockGraphData({ documentCount: 5, seed: 1 }) + const doc = data.documents.find((d) => d.memories.length > 0) + expect(doc).toBeDefined() + + for (const mem of doc!.memories) { + expect(mem.id).toBeDefined() + expect(mem.memory).toBeDefined() + expect(typeof mem.isStatic).toBe("boolean") + expect(typeof mem.isForgotten).toBe("boolean") + expect(typeof mem.isLatest).toBe("boolean") + expect(typeof mem.version).toBe("number") + expect(mem.createdAt).toBeDefined() + expect(mem.updatedAt).toBeDefined() + } + }) + + it("handles zero documents", () => { + const data = generateMockGraphData({ documentCount: 0, seed: 1 }) + expect(data.documents.length).toBe(0) + }) + + it("respects memoriesPerDoc range", () => { + const data = generateMockGraphData({ + documentCount: 50, + memoriesPerDoc: [3, 3], + seed: 1, + }) + for (const doc of data.documents) { + expect(doc.memories.length).toBe(3) + } + }) + + it("generates version chains for some documents", () => { + const data = generateMockGraphData({ + documentCount: 50, + memoriesPerDoc: [3, 6], + seed: 42, + }) + // With 50 docs and 30% chain probability, we should have some chains + const hasChain = data.documents.some((doc) => + doc.memories.some((mem) => mem.parentMemoryId !== null), + ) + expect(hasChain).toBe(true) + }) +}) diff --git a/packages/memory-graph/src/__tests__/renderer-utils.test.ts b/packages/memory-graph/src/__tests__/renderer-utils.test.ts new file mode 100644 index 00000000..481256a2 --- /dev/null +++ b/packages/memory-graph/src/__tests__/renderer-utils.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "vitest" +import { lightenColor } from "../canvas/renderer" + +describe("lightenColor", () => { + test("lightens a dark hex color", () => { + // #1B1F24 lightened by 0.08 → each channel +20 (0.08*255≈20) + const result = lightenColor("#1B1F24", 0.08) + // R: 0x1B(27)+20=47=0x2f, G: 0x1F(31)+20=51=0x33, B: 0x24(36)+20=56=0x38 + expect(result).toBe("#2f3338") + }) + + test("clamps channels at 255", () => { + // #FFFFFF lightened by 0.1 → all channels clamped at 255 + const result = lightenColor("#ffffff", 0.1) + expect(result).toBe("#ffffff") + }) + + test("handles zero amount (no change)", () => { + const result = lightenColor("#1B1F24", 0) + expect(result).toBe("#1b1f24") + }) + + test("returns input unchanged for 3-digit hex", () => { + expect(lightenColor("#abc", 0.1)).toBe("#abc") + }) + + test("returns input unchanged for rgb() format", () => { + expect(lightenColor("rgb(27, 31, 36)", 0.1)).toBe("rgb(27, 31, 36)") + }) + + test("returns input unchanged for 8-digit hex with alpha", () => { + expect(lightenColor("#1B1F24FF", 0.1)).toBe("#1B1F24FF") + }) + + test("caches result for repeated calls", () => { + const first = lightenColor("#0D2034", 0.08) + const second = lightenColor("#0D2034", 0.08) + expect(first).toBe(second) + }) + + test("cache invalidates on different input", () => { + const a = lightenColor("#0D2034", 0.08) + const b = lightenColor("#1B1F24", 0.08) + expect(a).not.toBe(b) + }) + + test("cache invalidates on different amount", () => { + const a = lightenColor("#1B1F24", 0.05) + const b = lightenColor("#1B1F24", 0.1) + expect(a).not.toBe(b) + }) + + test("handles hex without # prefix", () => { + const result = lightenColor("1B1F24", 0.08) + expect(result).toBe("#2f3338") + }) +}) diff --git a/packages/memory-graph/src/__tests__/simulation.test.ts b/packages/memory-graph/src/__tests__/simulation.test.ts new file mode 100644 index 00000000..61d46752 --- /dev/null +++ b/packages/memory-graph/src/__tests__/simulation.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest" +import { ForceSimulation } from "../canvas/simulation" +import type { GraphNode, GraphEdge } from "../types" + +function makeNode(id: string, x: number, y: number): GraphNode { + return { + id, + type: "document", + x, + y, + size: 50, + borderColor: "#fff", + isHovered: false, + isDragging: false, + data: { + id, + title: id, + summary: null, + type: "text", + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + memories: [], + }, + } +} + +function makeEdge(source: string, target: string): GraphEdge { + return { + id: `e-${source}-${target}`, + source, + target, + visualProps: { opacity: 0.5, thickness: 1.5 }, + edgeType: "derives", + } +} + +describe("ForceSimulation", () => { + it("init creates simulation and isActive returns true", () => { + const sim = new ForceSimulation() + const nodes = [makeNode("a", 0, 0), makeNode("b", 100, 100)] + const edges = [makeEdge("a", "b")] + sim.init(nodes, edges) + expect(sim.isActive()).toBe(true) + sim.destroy() + }) + + it("destroy stops simulation", () => { + const sim = new ForceSimulation() + const nodes = [makeNode("a", 0, 0)] + sim.init(nodes, []) + sim.destroy() + expect(sim.isActive()).toBe(false) + }) + + it("init moves nodes from initial positions (pre-tick)", () => { + const sim = new ForceSimulation() + const nodes = [ + makeNode("a", 0, 0), + makeNode("b", 0, 0), // Same position - repulsion should move them + ] + sim.init(nodes, []) + + // After init with pre-ticks, nodes at same position should have moved apart + const dx = nodes[0]!.x - nodes[1]!.x + const dy = nodes[0]!.y - nodes[1]!.y + const dist = Math.sqrt(dx * dx + dy * dy) + expect(dist).toBeGreaterThan(0) + sim.destroy() + }) + + it("update hot-swaps nodes without full re-init", () => { + const sim = new ForceSimulation() + const nodes = [makeNode("a", 0, 0), makeNode("b", 100, 100)] + sim.init(nodes, []) + + // Update with same nodes but different positions + nodes[0]!.x = 50 + expect(() => sim.update(nodes, [])).not.toThrow() + expect(sim.isActive()).toBe(true) + sim.destroy() + }) + + it("reheat increases simulation energy", () => { + const sim = new ForceSimulation() + const nodes = [makeNode("a", 0, 0)] + sim.init(nodes, []) + // Should not throw + expect(() => sim.reheat()).not.toThrow() + sim.destroy() + }) + + it("coolDown reduces simulation energy", () => { + const sim = new ForceSimulation() + const nodes = [makeNode("a", 0, 0)] + sim.init(nodes, []) + expect(() => sim.coolDown()).not.toThrow() + sim.destroy() + }) + + it("handles empty nodes array", () => { + const sim = new ForceSimulation() + expect(() => sim.init([], [])).not.toThrow() + sim.destroy() + }) + + it("handles edges with missing nodes gracefully", () => { + const sim = new ForceSimulation() + const nodes = [makeNode("a", 0, 0)] + const edges = [makeEdge("a", "nonexistent")] + // Should not throw even with dangling edge + expect(() => sim.init(nodes, edges)).not.toThrow() + sim.destroy() + }) +}) diff --git a/packages/memory-graph/src/__tests__/spatial-index.test.ts b/packages/memory-graph/src/__tests__/spatial-index.test.ts new file mode 100644 index 00000000..46d7beab --- /dev/null +++ b/packages/memory-graph/src/__tests__/spatial-index.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from "vitest" +import { SpatialIndex } from "../canvas/hit-test" +import type { GraphNode } from "../types" + +function makeNode( + id: string, + x: number, + y: number, + type: "document" | "memory" = "document", + size = 50, +): GraphNode { + return { + id, + type, + x, + y, + size, + borderColor: "#fff", + isHovered: false, + isDragging: false, + data: { + id, + title: id, + summary: null, + type: "text", + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + memories: [], + }, + } +} + +describe("SpatialIndex", () => { + it("rebuild returns true on first build", () => { + const idx = new SpatialIndex() + const result = idx.rebuild([makeNode("a", 100, 100)]) + expect(result).toBe(true) + }) + + it("rebuild returns false when hash unchanged", () => { + const idx = new SpatialIndex() + const nodes = [makeNode("a", 100, 100)] + idx.rebuild(nodes) + const result = idx.rebuild(nodes) + expect(result).toBe(false) + }) + + it("rebuild returns true when positions change", () => { + const idx = new SpatialIndex() + const nodes = [makeNode("a", 100, 100)] + idx.rebuild(nodes) + nodes[0]!.x = 500 + const result = idx.rebuild(nodes) + expect(result).toBe(true) + }) + + it("rebuild detects sub-pixel movements (10x granularity)", () => { + const idx = new SpatialIndex() + const nodes = [makeNode("a", 100.0, 100.0)] + idx.rebuild(nodes) + // Move by 0.2 pixels — should be detected with 10x rounding + nodes[0]!.x = 100.2 + const result = idx.rebuild(nodes) + expect(result).toBe(true) + }) + + it("queryPoint finds correct node (document - square hit test)", () => { + const idx = new SpatialIndex() + const node = makeNode("a", 100, 100, "document", 50) + idx.rebuild([node]) + const found = idx.queryPoint(105, 105) + expect(found).not.toBeNull() + expect(found!.id).toBe("a") + }) + + it("queryPoint finds correct node (memory - circle hit test)", () => { + const idx = new SpatialIndex() + const node = makeNode("m1", 200, 200, "memory", 36) + idx.rebuild([node]) + // Inside the circle (radius = 18) + const found = idx.queryPoint(210, 210) + expect(found).not.toBeNull() + expect(found!.id).toBe("m1") + }) + + it("queryPoint returns null for empty grid", () => { + const idx = new SpatialIndex() + idx.rebuild([]) + expect(idx.queryPoint(100, 100)).toBeNull() + }) + + it("queryPoint returns null for distant coordinates", () => { + const idx = new SpatialIndex() + idx.rebuild([makeNode("a", 100, 100)]) + expect(idx.queryPoint(5000, 5000)).toBeNull() + }) + + it("queryPoint handles overlapping nodes (returns last in render order)", () => { + const idx = new SpatialIndex() + const nodes = [ + makeNode("a", 100, 100, "document", 50), + makeNode("b", 110, 110, "document", 50), + ] + idx.rebuild(nodes) + // Both nodes overlap at (105, 105), should return the last one (higher z) + const found = idx.queryPoint(105, 105) + expect(found).not.toBeNull() + expect(found!.id).toBe("b") + }) + + it("queryPoint works across cell boundaries", () => { + const idx = new SpatialIndex() + // Node at cell boundary (cellSize = 200) + const node = makeNode("edge", 199, 199, "document", 50) + idx.rebuild([node]) + // Query from adjacent cell + const found = idx.queryPoint(201, 201) + expect(found).not.toBeNull() + expect(found!.id).toBe("edge") + }) + + it("handles many nodes without errors", () => { + const idx = new SpatialIndex() + const nodes = Array.from({ length: 1000 }, (_, i) => + makeNode(`n${i}`, Math.random() * 2000, Math.random() * 2000), + ) + expect(() => idx.rebuild(nodes)).not.toThrow() + // Should find at least some nodes + const found = idx.queryPoint(nodes[0]!.x, nodes[0]!.y) + expect(found).not.toBeNull() + }) +}) diff --git a/packages/memory-graph/src/__tests__/version-chain.test.ts b/packages/memory-graph/src/__tests__/version-chain.test.ts new file mode 100644 index 00000000..eb1bf029 --- /dev/null +++ b/packages/memory-graph/src/__tests__/version-chain.test.ts @@ -0,0 +1,406 @@ +import { describe, it, expect } from "vitest" +import { VersionChainIndex } from "../canvas/version-chain" +import type { GraphApiDocument, GraphApiMemory } from "../types" + +function makeMem( + overrides: Partial & { id: string }, +): GraphApiMemory { + return { + memory: `Memory ${overrides.id}`, + isStatic: false, + spaceId: "default", + isLatest: true, + isForgotten: false, + forgetAfter: null, + forgetReason: null, + version: 1, + parentMemoryId: null, + rootMemoryId: null, + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + ...overrides, + } +} + +function makeDoc(id: string, memories: GraphApiMemory[]): GraphApiDocument { + return { + id, + title: `Doc ${id}`, + summary: null, + documentType: "text", + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + memories, + } +} + +describe("VersionChainIndex", () => { + it("getChain returns null for standalone memory (no parent, no children)", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [makeMem({ id: "m1", version: 1 })]) + idx.rebuild([doc]) + // Single memory with no parent and no children — not a chain + expect(idx.getChain("m1")).toBeNull() + }) + + it("getChain from latest node returns full chain in version order", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + makeMem({ + id: "m3", + parentMemoryId: "m2", + rootMemoryId: "m1", + version: 3, + }), + ]) + idx.rebuild([doc]) + + // Query from the latest (version 3) — walks back m3->m2->m1, reverses to [m1,m2,m3] + const chain = idx.getChain("m3") + expect(chain).not.toBeNull() + expect(chain!.length).toBe(3) + expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"]) + }) + + it("getChain from middle element returns full chain (backward + forward)", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + makeMem({ + id: "m3", + parentMemoryId: "m2", + rootMemoryId: "m1", + version: 3, + }), + ]) + idx.rebuild([doc]) + + // Query from m2 (version 2) — walks back to m1, forward to m3 + const chain = idx.getChain("m2") + expect(chain).not.toBeNull() + expect(chain!.length).toBe(3) + expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"]) + }) + + it("caches chain results for all entries in the chain", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + ]) + idx.rebuild([doc]) + + const chain1 = idx.getChain("m2") + // After querying m2, m1 should also be cached (same chain object) + const chain2 = idx.getChain("m1") + // m1 is version 1, but it was cached as part of m2's chain + expect(chain2).toBe(chain1) // same reference + }) + + it("getChain from v1 root with children returns full chain", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + makeMem({ + id: "m3", + parentMemoryId: "m2", + rootMemoryId: "m1", + version: 3, + }), + ]) + idx.rebuild([doc]) + + // Query from v1 root — walks forward to m2, m3 + const chain = idx.getChain("m1") + expect(chain).not.toBeNull() + expect(chain!.length).toBe(3) + expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"]) + }) + + it("getChain returns null for unknown ID", () => { + const idx = new VersionChainIndex() + idx.rebuild([makeDoc("d1", [makeMem({ id: "m1", version: 1 })])]) + expect(idx.getChain("nonexistent")).toBeNull() + }) + + it("handles empty documents array", () => { + const idx = new VersionChainIndex() + expect(() => idx.rebuild([])).not.toThrow() + expect(idx.getChain("anything")).toBeNull() + }) + + it("rebuild clears previous chains (new array reference)", () => { + const idx = new VersionChainIndex() + const doc1 = makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + ]) + idx.rebuild([doc1]) + expect(idx.getChain("m2")).not.toBeNull() + + // Rebuild with different data (new array reference) + const doc2 = makeDoc("d2", [makeMem({ id: "m3", version: 1 })]) + idx.rebuild([doc2]) + expect(idx.getChain("m2")).toBeNull() + expect(idx.getChain("m1")).toBeNull() + }) + + it("rebuild skips if same array reference", () => { + const idx = new VersionChainIndex() + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + ]), + ] + idx.rebuild(docs) + const chain1 = idx.getChain("m2") + + // Same reference — rebuild is a no-op + idx.rebuild(docs) + const chain2 = idx.getChain("m2") + expect(chain2).toBe(chain1) + }) + + it("handles multiple independent chains across documents", () => { + const idx = new VersionChainIndex() + const docs = [ + makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + ]), + makeDoc("d2", [ + makeMem({ id: "m3", version: 1 }), + makeMem({ + id: "m4", + parentMemoryId: "m3", + rootMemoryId: "m3", + version: 2, + }), + ]), + ] + idx.rebuild(docs) + + const chain1 = idx.getChain("m2") + const chain2 = idx.getChain("m4") + expect(chain1).not.toBeNull() + expect(chain2).not.toBeNull() + expect(chain1!.map((e) => e.id)).toEqual(["m1", "m2"]) + expect(chain2!.map((e) => e.id)).toEqual(["m3", "m4"]) + }) + + it("handles circular parent references without infinite loop", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1, parentMemoryId: "m2" }), + makeMem({ id: "m2", version: 2, parentMemoryId: "m1" }), + ]) + idx.rebuild([doc]) + + // Cycle: m1->m2->m1. The visited set prevents infinite loops. + const chain = idx.getChain("m1") + expect(chain).not.toBeNull() + expect(chain!.length).toBe(2) + }) + + it("branching children: follows first child by document order", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2a", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + makeMem({ + id: "m2b", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + ]) + idx.rebuild([doc]) + + // m1 has two children; forward walk picks the first (m2a) + const chain = idx.getChain("m1") + expect(chain).not.toBeNull() + expect(chain!.length).toBe(2) + expect(chain!.map((e) => e.id)).toEqual(["m1", "m2a"]) + }) + + it("chain entries have correct fields", () => { + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1, isForgotten: true, isLatest: false }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + isLatest: true, + }), + ]) + idx.rebuild([doc]) + + const chain = idx.getChain("m2") + expect(chain).not.toBeNull() + expect(chain![0]).toEqual({ + id: "m1", + version: 1, + memory: "Memory m1", + isForgotten: true, + isLatest: false, + }) + expect(chain![1]).toEqual({ + id: "m2", + version: 2, + memory: "Memory m2", + isForgotten: false, + isLatest: true, + }) + }) + + // --- Additional edge cases --- + + it("getChain returns null for orphaned non-root memory (v2+, no parent in index, no children)", () => { + const idx = new VersionChainIndex() + // m2 claims version 2 and has a parentMemoryId, but that parent is not in any document. + // The backward walk reaches a dead end after m2 itself (parent not in memoryMap). + // all.length === 1 → returns null, same as a standalone v1. + const doc = makeDoc("d1", [ + makeMem({ + id: "m2", + version: 2, + parentMemoryId: "m_ghost", + rootMemoryId: "m_ghost", + }), + ]) + idx.rebuild([doc]) + expect(idx.getChain("m2")).toBeNull() + }) + + it("cross-document chain: parent in doc1, child in doc2 resolves correctly", () => { + // rebuild() walks all documents in one pass, so parentMemoryId references + // are resolved across document boundaries. This mirrors production usage where + // chainIndex.current.rebuild(limitedDocuments) receives all documents at once. + const idx = new VersionChainIndex() + const docs = [ + makeDoc("d1", [makeMem({ id: "m1", version: 1 })]), + makeDoc("d2", [ + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + ]), + ] + idx.rebuild(docs) + + // Querying from child (in d2) should walk back to parent (in d1) + const chainFromChild = idx.getChain("m2") + expect(chainFromChild).not.toBeNull() + expect(chainFromChild!.map((e) => e.id)).toEqual(["m1", "m2"]) + + // Querying from parent (in d1) should walk forward to child (in d2) + const chainFromParent = idx.getChain("m1") + expect(chainFromParent).not.toBeNull() + expect(chainFromParent!.map((e) => e.id)).toEqual(["m1", "m2"]) + }) + + it("circular reference: both IDs present in result (order is undefined for malformed cycles)", () => { + // The existing circular test asserts length=2 but not membership. + // Circular data (m1.parent=m2, m2.parent=m1) is malformed and will never + // appear in production — the visited set merely guarantees termination. + // Order is intentionally unspecified because the backward-walk start node + // determines which ID appears first, which has no semantic meaning for corrupt data. + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1, parentMemoryId: "m2" }), + makeMem({ id: "m2", version: 2, parentMemoryId: "m1" }), + ]) + idx.rebuild([doc]) + + const chain = idx.getChain("m1") + expect(chain).not.toBeNull() + expect(chain!.length).toBe(2) + // Both nodes must appear — order is implementation-defined for cycles + const ids = chain!.map((e) => e.id) + expect(ids).toContain("m1") + expect(ids).toContain("m2") + }) + + it("getChain from middle node with cold cache exercises real backward+forward traversal", () => { + // This test deliberately queries the MIDDLE node first (cache is empty at that point) + // to confirm the combined backward+forward traversal is the live code path being + // exercised — not merely the cache fast-path from a prior call to another node. + const idx = new VersionChainIndex() + const doc = makeDoc("d1", [ + makeMem({ id: "m1", version: 1 }), + makeMem({ + id: "m2", + parentMemoryId: "m1", + rootMemoryId: "m1", + version: 2, + }), + makeMem({ + id: "m3", + parentMemoryId: "m2", + rootMemoryId: "m1", + version: 3, + }), + ]) + idx.rebuild([doc]) + + // Cold cache: getChain("m2") must walk backward to m1 AND forward to m3. + const chain = idx.getChain("m2") + expect(chain).not.toBeNull() + expect(chain!.length).toBe(3) + expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"]) + + // After the traversal, neighboring nodes must return the same cached reference — + // confirming that the cache-population loop ran for all three entries. + expect(idx.getChain("m1")).toBe(chain) + expect(idx.getChain("m3")).toBe(chain) + }) +}) diff --git a/packages/memory-graph/src/__tests__/viewport.test.ts b/packages/memory-graph/src/__tests__/viewport.test.ts new file mode 100644 index 00000000..aee0b14b --- /dev/null +++ b/packages/memory-graph/src/__tests__/viewport.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect } from "vitest" +import { ViewportState } from "../canvas/viewport" +import type { GraphNode } from "../types" + +function makeNode(id: string, x: number, y: number): GraphNode { + return { + id, + type: "document", + x, + y, + size: 50, + borderColor: "#fff", + isHovered: false, + isDragging: false, + data: { + id, + title: id, + summary: null, + type: "text", + createdAt: "2024-01-01", + updatedAt: "2024-01-01", + memories: [], + }, + } +} + +/** Run tick() until animation converges (or max iterations) */ +function tickUntilSettled(vp: ViewportState, maxIter = 500): void { + for (let i = 0; i < maxIter; i++) { + if (!vp.tick()) break + } +} + +describe("ViewportState", () => { + it("constructor sets initial values", () => { + const vp = new ViewportState() + expect(vp.panX).toBe(0) + expect(vp.panY).toBe(0) + expect(vp.zoom).toBe(0.5) // default initial zoom + }) + + it("constructor accepts custom initial values", () => { + const vp = new ViewportState(10, 20, 1.5) + expect(vp.panX).toBe(10) + expect(vp.panY).toBe(20) + expect(vp.zoom).toBe(1.5) + }) + + it("worldToScreen and screenToWorld are inverse operations", () => { + const vp = new ViewportState(100, 50, 1.5) + + const worldX = 300 + const worldY = 400 + const screen = vp.worldToScreen(worldX, worldY) + const world = vp.screenToWorld(screen.x, screen.y) + + expect(world.x).toBeCloseTo(worldX, 5) + expect(world.y).toBeCloseTo(worldY, 5) + }) + + it("worldToScreen applies zoom and pan: screen = world * zoom + pan", () => { + const vp = new ViewportState(10, 20, 2) + + const screen = vp.worldToScreen(100, 200) + expect(screen.x).toBe(100 * 2 + 10) // 210 + expect(screen.y).toBe(200 * 2 + 20) // 420 + }) + + it("screenToWorld reverses: world = (screen - pan) / zoom", () => { + const vp = new ViewportState(10, 20, 2) + + const world = vp.screenToWorld(210, 420) + expect(world.x).toBeCloseTo(100, 5) + expect(world.y).toBeCloseTo(200, 5) + }) + + it("pan offsets correctly and accumulates", () => { + const vp = new ViewportState(0, 0, 1) + vp.pan(50, 30) + expect(vp.panX).toBe(50) + expect(vp.panY).toBe(30) + + vp.pan(10, 20) + expect(vp.panX).toBe(60) + expect(vp.panY).toBe(50) + }) + + it("pan cancels any animated pan target", () => { + const vp = new ViewportState(0, 0, 1) + vp.centerOn(500, 500, 800, 600) // sets targetPanX/Y + vp.pan(10, 10) // should cancel the target + // After pan, tick should return false (no animation) + expect(vp.tick()).toBe(false) + }) + + it("zoomImmediate multiplies current zoom by delta", () => { + const vp = new ViewportState(0, 0, 1) + const initialZoom = vp.zoom + vp.zoomImmediate(2, 0, 0) + expect(vp.zoom).toBeCloseTo(initialZoom * 2) + }) + + it("zoomImmediate preserves world point under anchor", () => { + const vp = new ViewportState(100, 50, 1) + const anchorX = 400 + const anchorY = 300 + + // Get world point under anchor before zoom + const worldBefore = vp.screenToWorld(anchorX, anchorY) + vp.zoomImmediate(2, anchorX, anchorY) + // After zoom, same screen point should map to same world point + const worldAfter = vp.screenToWorld(anchorX, anchorY) + + expect(worldAfter.x).toBeCloseTo(worldBefore.x, 3) + expect(worldAfter.y).toBeCloseTo(worldBefore.y, 3) + }) + + it("zoomImmediate clamps to MIN_ZOOM (0.1)", () => { + const vp = new ViewportState(0, 0, 0.5) + // Try to zoom way down: 0.5 * 0.01 = 0.005, should clamp to 0.1 + vp.zoomImmediate(0.01, 0, 0) + expect(vp.zoom).toBeCloseTo(0.1) + }) + + it("zoomImmediate clamps to MAX_ZOOM (5.0)", () => { + const vp = new ViewportState(0, 0, 2) + // Try to zoom way up: 2 * 100 = 200, should clamp to 5 + vp.zoomImmediate(100, 0, 0) + expect(vp.zoom).toBeCloseTo(5.0) + }) + + it("zoomTo sets target zoom (animated via tick)", () => { + const vp = new ViewportState(0, 0, 0.5) + vp.zoomTo(2, 400, 300) + // Zoom hasn't changed yet — it's animated + expect(vp.zoom).toBe(0.5) + // After ticking, zoom should approach target + tickUntilSettled(vp) + expect(vp.zoom).toBeCloseTo(2, 1) + }) + + it("tick returns false when no animation is active", () => { + const vp = new ViewportState() + expect(vp.tick()).toBe(false) + }) + + it("tick returns true during inertia", () => { + const vp = new ViewportState() + vp.releaseWithVelocity(10, 10) + expect(vp.tick()).toBe(true) + }) + + it("tick returns true during zoom animation", () => { + const vp = new ViewportState(0, 0, 0.5) + vp.zoomTo(2, 0, 0) + expect(vp.tick()).toBe(true) + }) + + it("tick returns true during pan animation", () => { + const vp = new ViewportState(0, 0, 1) + vp.centerOn(500, 500, 800, 600) + expect(vp.tick()).toBe(true) + }) + + it("fitToNodes centers and scales to fit all nodes", () => { + const vp = new ViewportState(0, 0, 0.5) + const nodes = [ + makeNode("a", 0, 0), + makeNode("b", 1000, 0), + makeNode("c", 0, 1000), + makeNode("d", 1000, 1000), + ] + vp.fitToNodes(nodes, 800, 600) + tickUntilSettled(vp) + + // After fitting, all nodes should be visible within the viewport + for (const node of nodes) { + const screen = vp.worldToScreen(node.x, node.y) + expect(screen.x).toBeGreaterThan(-100) + expect(screen.x).toBeLessThan(900) + expect(screen.y).toBeGreaterThan(-100) + expect(screen.y).toBeLessThan(700) + } + }) + + it("fitToNodes handles single node without throwing", () => { + const vp = new ViewportState() + expect(() => + vp.fitToNodes([makeNode("a", 500, 500)], 800, 600), + ).not.toThrow() + }) + + it("fitToNodes handles empty nodes array without throwing", () => { + const vp = new ViewportState() + const zoomBefore = vp.zoom + vp.fitToNodes([], 800, 600) + // Should be a no-op + expect(vp.zoom).toBe(zoomBefore) + }) + + it("centerOn animates pan to center a world point on screen", () => { + const vp = new ViewportState(0, 0, 1) + vp.centerOn(500, 300, 800, 600) + tickUntilSettled(vp) + + // After settling, world point (500, 300) should map to screen center (400, 300) + const screen = vp.worldToScreen(500, 300) + expect(screen.x).toBeCloseTo(400, 0) + expect(screen.y).toBeCloseTo(300, 0) + }) + + it("inertia decays to zero", () => { + const vp = new ViewportState() + vp.releaseWithVelocity(100, 100) + tickUntilSettled(vp) + // After settling, tick should return false + expect(vp.tick()).toBe(false) + }) +}) diff --git a/packages/memory-graph/src/api-types.ts b/packages/memory-graph/src/api-types.ts index 0ebc86ee..20f00059 100644 --- a/packages/memory-graph/src/api-types.ts +++ b/packages/memory-graph/src/api-types.ts @@ -1,71 +1,42 @@ -// Standalone TypeScript types for Memory Graph -// These mirror the API response types from @repo/validation/api +export type MemoryRelation = "updates" | "extends" | "derives" 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 | null - embedding?: number[] | null - embeddingModel?: string | null - tokenCount?: number | null - createdAt: string | Date - updatedAt: string | Date - // Fields from join relationship - sourceAddedAt?: Date | null + memory: string + content?: string | null + createdAt: string + updatedAt: string + spaceId?: string | null + embedding?: number[] + isStatic?: boolean + isForgotten?: boolean + forgetAfter?: string | null + forgetReason?: string | null + version?: number + parentMemoryId?: string | null + rootMemoryId?: string | null + isLatest?: boolean + // Relation fields from backend + relation?: MemoryRelation | null + updatesMemoryId?: string | null + nextVersionId?: string | null + memoryRelations?: Record | null + // Source/join fields + sourceAddedAt?: string | null sourceRelevanceScore?: number | null sourceMetadata?: Record | null spaceContainerTag?: string | null - // Version chain fields - updatesMemoryId?: string | null - nextVersionId?: string | null - relation?: "updates" | "extends" | "derives" | null - // Memory status fields - isForgotten?: boolean - forgetAfter?: Date | string | null - isLatest?: boolean - // Space/container fields - spaceId?: string | null - // Legacy fields - memory?: string | null - memoryRelations?: Array<{ - 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 + title: string | null + url: string | null + documentType: string + createdAt: string + updatedAt: string summary?: string | null - url?: string | null - source?: string | null - type?: string | null - status: "pending" | "processing" | "done" | "failed" - metadata?: Record | null - processingMetadata?: Record | 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[] + memories: MemoryEntry[] } export interface DocumentsResponse { diff --git a/packages/memory-graph/src/assets/icons.tsx b/packages/memory-graph/src/assets/icons.tsx deleted file mode 100644 index 5eb38b42..00000000 --- a/packages/memory-graph/src/assets/icons.tsx +++ /dev/null @@ -1,208 +0,0 @@ -export const OneDrive = ({ className }: { className?: string }) => ( - - OneDrive - - - - - -) - -export const GoogleDrive = ({ className }: { className?: string }) => ( - - Google Drive - - - - - - - -) - -export const Notion = ({ className }: { className?: string }) => ( - - Notion - - - -) - -export const GoogleDocs = ({ className }: { className?: string }) => ( - - Google Docs - - -) - -export const GoogleSheets = ({ className }: { className?: string }) => ( - - Google Sheets - - -) - -export const GoogleSlides = ({ className }: { className?: string }) => ( - - Google Slides - - -) - -export const NotionDoc = ({ className }: { className?: string }) => ( - - Notion Doc - - -) - -export const MicrosoftWord = ({ className }: { className?: string }) => ( - - Microsoft Word - - -) - -export const MicrosoftExcel = ({ className }: { className?: string }) => ( - - Microsoft Excel - - -) - -export const MicrosoftPowerpoint = ({ className }: { className?: string }) => ( - - Microsoft PowerPoint - - -) - -export const MicrosoftOneNote = ({ className }: { className?: string }) => ( - - Microsoft OneNote - - -) - -export const PDF = ({ className }: { className?: string }) => ( - - PDF - - - - -) diff --git a/packages/memory-graph/src/canvas/document-icons.ts b/packages/memory-graph/src/canvas/document-icons.ts new file mode 100644 index 00000000..03df4003 --- /dev/null +++ b/packages/memory-graph/src/canvas/document-icons.ts @@ -0,0 +1,402 @@ +/** + * Canvas-native document type icon drawing functions. + * + * Each function draws a small vector icon at the given (x, y) center + * within a bounding box of `size` pixels. All drawing uses the current + * canvas fill/stroke styles set by the caller. + */ + +/** Shared rounded-rectangle path helper (also used by the renderer). */ +export function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number, +): void { + ctx.beginPath() + ctx.moveTo(x + r, y) + ctx.lineTo(x + w - r, y) + ctx.arcTo(x + w, y, x + w, y + r, r) + ctx.lineTo(x + w, y + h - r) + ctx.arcTo(x + w, y + h, x + w - r, y + h, r) + ctx.lineTo(x + r, y + h) + ctx.arcTo(x, y + h, x, y + h - r, r) + ctx.lineTo(x, y + r) + ctx.arcTo(x, y, x + r, y, r) + ctx.closePath() +} + +/** + * Draw the appropriate document-type icon on the canvas. + * + * Wraps the drawing in save/restore so callers don't need to worry about + * state leaking. Only `iconColor` is needed from the theme. + */ +export function drawDocIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + type: string, + iconColor: string, +): void { + ctx.save() + ctx.fillStyle = iconColor + ctx.strokeStyle = iconColor + ctx.lineWidth = Math.max(1, size / 12) + ctx.lineCap = "round" + ctx.lineJoin = "round" + + switch (type) { + case "webpage": + case "url": + drawGlobeIcon(ctx, x, y, size) + break + case "pdf": + drawTextLabel(ctx, x, y, size, "PDF", 0.35) + break + case "md": + case "markdown": + drawTextLabel(ctx, x, y, size, "MD", 0.3) + break + case "doc": + case "docx": + case "word": + case "microsoft_word": + drawTextLabel(ctx, x, y, size, "W", 0.4) + break + case "csv": + case "excel": + case "microsoft_excel": + case "google_sheet": + drawGridIcon(ctx, x, y, size) + break + case "json": + drawBracesIcon(ctx, x, y, size) + break + case "notion": + case "notion_doc": + drawNotionIcon(ctx, x, y, size) + break + case "google_doc": + drawGoogleDocIcon(ctx, x, y, size) + break + case "google_slide": + case "powerpoint": + case "microsoft_powerpoint": + drawSlidesIcon(ctx, x, y, size) + break + case "google_drive": + case "onedrive": + drawCloudIcon(ctx, x, y, size) + break + case "tweet": + drawXIcon(ctx, x, y, size) + break + case "youtube": + case "video": + drawPlayIcon(ctx, x, y, size) + break + case "image": + drawImageIcon(ctx, x, y, size) + break + case "text": + case "note": + drawTextNoteIcon(ctx, x, y, size) + break + case "onenote": + case "microsoft_onenote": + drawTextLabel(ctx, x, y, size, "N", 0.4) + break + case "mcp": + drawTextLabel(ctx, x, y, size, "MCP", 0.25) + break + default: + drawDocOutline(ctx, x, y, size) + break + } + + ctx.restore() +} + +// --------------------------------------------------------------------------- +// Individual icon drawing helpers +// --------------------------------------------------------------------------- + +function drawTextLabel( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + text: string, + fontRatio: number, +): void { + ctx.font = `bold ${size * fontRatio}px sans-serif` + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.fillText(text, x, y) +} + +function drawGlobeIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const r = size * 0.4 + ctx.beginPath() + ctx.arc(x, y, r, 0, Math.PI * 2) + ctx.stroke() + ctx.beginPath() + ctx.ellipse(x, y, r * 0.4, r, 0, 0, Math.PI * 2) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(x - r, y) + ctx.lineTo(x + r, y) + ctx.stroke() +} + +function drawGridIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.7 + const h = size * 0.7 + ctx.strokeRect(x - w / 2, y - h / 2, w, h) + ctx.beginPath() + ctx.moveTo(x, y - h / 2) + ctx.lineTo(x, y + h / 2) + ctx.moveTo(x - w / 2, y) + ctx.lineTo(x + w / 2, y) + ctx.stroke() +} + +function drawBracesIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.6 + const h = size * 0.8 + ctx.beginPath() + ctx.moveTo(x - w / 4, y - h / 2) + ctx.quadraticCurveTo(x - w / 2, y - h / 3, x - w / 2, y) + ctx.quadraticCurveTo(x - w / 2, y + h / 3, x - w / 4, y + h / 2) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(x + w / 4, y - h / 2) + ctx.quadraticCurveTo(x + w / 2, y - h / 3, x + w / 2, y) + ctx.quadraticCurveTo(x + w / 2, y + h / 3, x + w / 4, y + h / 2) + ctx.stroke() +} + +function drawDocOutline( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.7 + const h = size * 0.85 + const fold = size * 0.2 + ctx.beginPath() + ctx.moveTo(x - w / 2, y - h / 2) + ctx.lineTo(x + w / 2 - fold, y - h / 2) + ctx.lineTo(x + w / 2, y - h / 2 + fold) + ctx.lineTo(x + w / 2, y + h / 2) + ctx.lineTo(x - w / 2, y + h / 2) + ctx.closePath() + ctx.stroke() + const sp = size * 0.15 + const lw = size * 0.4 + ctx.beginPath() + ctx.moveTo(x - lw / 2, y - sp) + ctx.lineTo(x + lw / 2, y - sp) + ctx.moveTo(x - lw / 2, y) + ctx.lineTo(x + lw / 2, y) + ctx.moveTo(x - lw / 2, y + sp) + ctx.lineTo(x + lw / 2, y + sp) + ctx.stroke() +} + +/** Draw a simplified Notion "N" logo mark */ +function drawNotionIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.5 + const h = size * 0.6 + const r = size * 0.08 + ctx.lineWidth = Math.max(1, size / 14) + roundRect(ctx, x - w / 2, y - h / 2, w, h, r) + ctx.stroke() + // Inner "N" shape + const inset = size * 0.12 + const left = x - w / 2 + inset + const right = x + w / 2 - inset + const top = y - h / 2 + inset + const bottom = y + h / 2 - inset + ctx.beginPath() + ctx.moveTo(left, top) + ctx.lineTo(left, bottom) + ctx.moveTo(left, top) + ctx.lineTo(right, bottom) + ctx.moveTo(right, top) + ctx.lineTo(right, bottom) + ctx.stroke() +} + +/** Draw a Google Docs icon (document with lines) */ +function drawGoogleDocIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.55 + const h = size * 0.7 + const fold = size * 0.15 + ctx.beginPath() + ctx.moveTo(x - w / 2, y - h / 2) + ctx.lineTo(x + w / 2 - fold, y - h / 2) + ctx.lineTo(x + w / 2, y - h / 2 + fold) + ctx.lineTo(x + w / 2, y + h / 2) + ctx.lineTo(x - w / 2, y + h / 2) + ctx.closePath() + ctx.stroke() + const lineW = w * 0.6 + const sp = size * 0.1 + ctx.beginPath() + ctx.moveTo(x - lineW / 2, y - sp) + ctx.lineTo(x + lineW / 2, y - sp) + ctx.moveTo(x - lineW / 2, y + sp * 0.3) + ctx.lineTo(x + lineW / 2, y + sp * 0.3) + ctx.moveTo(x - lineW / 2, y + sp * 1.6) + ctx.lineTo(x + lineW / 3, y + sp * 1.6) + ctx.stroke() +} + +/** Draw a slides/presentation icon (rectangle with play triangle) */ +function drawSlidesIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.7 + const h = size * 0.5 + ctx.strokeRect(x - w / 2, y - h / 2, w, h) + const triSize = size * 0.15 + ctx.beginPath() + ctx.moveTo(x - triSize * 0.5, y - triSize * 0.7) + ctx.lineTo(x - triSize * 0.5, y + triSize * 0.7) + ctx.lineTo(x + triSize * 0.7, y) + ctx.closePath() + ctx.fill() +} + +/** Draw a cloud icon for drive/storage types */ +function drawCloudIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const s = size * 0.35 + ctx.beginPath() + ctx.arc(x - s * 0.3, y + s * 0.1, s * 0.5, Math.PI * 0.7, Math.PI * 1.9) + ctx.arc(x + s * 0.1, y - s * 0.3, s * 0.55, Math.PI * 1.1, Math.PI * 0.3) + ctx.arc(x + s * 0.5, y + s * 0.1, s * 0.4, Math.PI * 1.4, Math.PI * 0.6) + ctx.lineTo(x - s * 0.7, y + s * 0.45) + ctx.closePath() + ctx.stroke() +} + +/** Draw an X (formerly Twitter) icon */ +function drawXIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const s = size * 0.3 + ctx.lineWidth = Math.max(1.5, size / 10) + ctx.beginPath() + ctx.moveTo(x - s, y - s) + ctx.lineTo(x + s, y + s) + ctx.moveTo(x + s, y - s) + ctx.lineTo(x - s, y + s) + ctx.stroke() +} + +/** Draw a play button icon for video/youtube */ +function drawPlayIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const r = size * 0.38 + const w = r * 2.2 + const h = r * 1.5 + const cr = size * 0.08 + roundRect(ctx, x - w / 2, y - h / 2, w, h, cr) + ctx.stroke() + const triH = size * 0.22 + ctx.beginPath() + ctx.moveTo(x - triH * 0.45, y - triH) + ctx.lineTo(x - triH * 0.45, y + triH) + ctx.lineTo(x + triH * 0.7, y) + ctx.closePath() + ctx.fill() +} + +/** Draw an image/photo icon (landscape with mountain) */ +function drawImageIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.7 + const h = size * 0.55 + ctx.strokeRect(x - w / 2, y - h / 2, w, h) + ctx.beginPath() + ctx.moveTo(x - w / 2 + w * 0.1, y + h / 2 - h * 0.1) + ctx.lineTo(x - w * 0.05, y - h * 0.05) + ctx.lineTo(x + w * 0.15, y + h * 0.15) + ctx.lineTo(x + w * 0.25, y - h * 0.02) + ctx.lineTo(x + w / 2 - w * 0.1, y + h / 2 - h * 0.1) + ctx.stroke() + const sunR = size * 0.06 + ctx.beginPath() + ctx.arc(x - w * 0.15, y - h * 0.15, sunR, 0, Math.PI * 2) + ctx.fill() +} + +/** Draw a text/note icon (lines of text) */ +function drawTextNoteIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.55 + const h = size * 0.6 + const sp = h / 5 + ctx.beginPath() + for (let i = 0; i < 4; i++) { + const lineY = y - h / 2 + sp * (i + 0.5) + const lineW = i === 3 ? w * 0.6 : w + ctx.moveTo(x - w / 2, lineY) + ctx.lineTo(x - w / 2 + lineW, lineY) + } + ctx.stroke() +} diff --git a/apps/web/components/memory-graph/canvas/hit-test.ts b/packages/memory-graph/src/canvas/hit-test.ts similarity index 73% rename from apps/web/components/memory-graph/canvas/hit-test.ts rename to packages/memory-graph/src/canvas/hit-test.ts index 88a40678..ac20bca7 100644 --- a/apps/web/components/memory-graph/canvas/hit-test.ts +++ b/packages/memory-graph/src/canvas/hit-test.ts @@ -27,15 +27,14 @@ export class SpatialIndex { const cx = Math.floor(worldX / this.cellSize) const cy = Math.floor(worldY / this.cellSize) - // Check current cell + 8 neighbors for (let dx = -1; dx <= 1; dx++) { for (let dy = -1; dy <= 1; dy++) { const cell = this.grid.get(`${cx + dx},${cy + dy}`) if (!cell) continue for (let i = cell.length - 1; i >= 0; i--) { - const node = cell[i]! - if (this.hitTest(node, worldX, worldY)) return node + const node = cell[i] + if (node && this.hitTest(node, worldX, worldY)) return node } } } @@ -46,13 +45,11 @@ export class SpatialIndex { const halfSize = node.size * 0.5 if (node.type === "document") { - // AABB rectangle hit test (50x50 node) return ( Math.abs(wx - node.x) <= halfSize && Math.abs(wy - node.y) <= halfSize ) } - // Circular hit test for hexagon memory nodes const dx = wx - node.x const dy = wy - node.y return dx * dx + dy * dy <= halfSize * halfSize @@ -61,9 +58,16 @@ export class SpatialIndex { private computeHash(nodes: GraphNode[]): number { let hash = nodes.length for (const n of nodes) { - // Round to nearest integer to avoid false rebuilds from tiny physics jitter - hash = (hash * 31 + (Math.round(n.x) | 0)) | 0 - hash = (hash * 31 + (Math.round(n.y) | 0)) | 0 + // Use finer granularity (10x) to detect sub-pixel movements + // and incorporate a simple string hash of the ID to avoid + // false matches when nodes swap positions + let idHash = 0 + for (let i = 0; i < n.id.length; i++) { + idHash = ((idHash << 5) - idHash + n.id.charCodeAt(i)) | 0 + } + hash = (hash * 31 + idHash) | 0 + hash = (hash * 31 + (Math.round(n.x * 10) | 0)) | 0 + hash = (hash * 31 + (Math.round(n.y * 10) | 0)) | 0 } return hash } diff --git a/apps/web/components/memory-graph/canvas/input-handler.ts b/packages/memory-graph/src/canvas/input-handler.ts similarity index 90% rename from apps/web/components/memory-graph/canvas/input-handler.ts rename to packages/memory-graph/src/canvas/input-handler.ts index a1a6fbf7..9f0afc19 100644 --- a/apps/web/components/memory-graph/canvas/input-handler.ts +++ b/packages/memory-graph/src/canvas/input-handler.ts @@ -1,6 +1,6 @@ -import type { ViewportState } from "./viewport" -import type { SpatialIndex } from "./hit-test" import type { GraphNode } from "../types" +import type { SpatialIndex } from "./hit-test" +import type { ViewportState } from "./viewport" interface InputCallbacks { onNodeHover: (id: string | null) => void @@ -20,22 +20,17 @@ export class InputHandler { private lastMouseX = 0 private lastMouseY = 0 - // Ring buffer for velocity tracking private posHistory: Array<{ x: number; y: number; t: number }> = [] private draggingNode: GraphNode | null = null - private dragStartX = 0 - private dragStartY = 0 private didDrag = false private currentHoveredId: string | null = null - // Touch state private lastTouchDistance = 0 private lastTouchCenter = { x: 0, y: 0 } private isTouchGesture = false - // Bound handlers for cleanup private boundMouseDown: (e: MouseEvent) => void private boundMouseMove: (e: MouseEvent) => void private boundMouseUp: (e: MouseEvent) => void @@ -128,8 +123,6 @@ export class InputHandler { if (node) { this.draggingNode = node - this.dragStartX = x - this.dragStartY = y node.fx = node.x node.fy = node.y this.callbacks.onNodeDragStart(node.id, node) @@ -162,7 +155,6 @@ export class InputHandler { this.lastMouseY = y this.didDrag = true - // Track positions for velocity (keep last 4) const now = performance.now() this.posHistory.push({ x, y, t: now }) if (this.posHistory.length > 4) this.posHistory.shift() @@ -171,7 +163,6 @@ export class InputHandler { return } - // Hover detection const world = this.viewport.screenToWorld(x, y) const node = this.spatialIndex.queryPoint(world.x, world.y) const id = node?.id ?? null @@ -196,13 +187,13 @@ export class InputHandler { if (this.isPanning) { this.isPanning = false - // Calculate release velocity from position history if (this.posHistory.length >= 2) { - const newest = this.posHistory[this.posHistory.length - 1]! - const oldest = this.posHistory[0]! + const newest = this.posHistory[this.posHistory.length - 1] + const oldest = this.posHistory[0] + if (!newest || !oldest) return const dt = newest.t - oldest.t if (dt > 0 && dt < 200) { - const vx = ((newest.x - oldest.x) / dt) * 16 // scale to ~60fps frame + const vx = ((newest.x - oldest.x) / dt) * 16 const vy = ((newest.y - oldest.y) / dt) * 16 this.viewport.releaseWithVelocity(vx, vy) } @@ -233,28 +224,26 @@ export class InputHandler { const { x, y } = this.canvasXY(e) - // Horizontal scroll -> pan if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { this.viewport.pan(-e.deltaX, 0) this.callbacks.onRequestRender() return } - // Vertical scroll -> zoom const factor = e.deltaY > 0 ? 0.97 : 1.03 this.viewport.zoomImmediate(factor, x, y) this.callbacks.onRequestRender() } - // Touch handling private onTouchStart(e: TouchEvent): void { e.preventDefault() const touches = e.touches if (touches.length >= 2) { this.isTouchGesture = true - const t0 = touches[0]! - const t1 = touches[1]! + const t0 = touches[0] + const t1 = touches[1] + if (!t0 || !t1) return this.lastTouchDistance = Math.hypot( t1.clientX - t0.clientX, t1.clientY - t0.clientY, @@ -263,9 +252,9 @@ export class InputHandler { x: (t0.clientX + t1.clientX) / 2, y: (t0.clientY + t1.clientY) / 2, } - } else if (touches.length === 1) { + } else if (touches.length === 1 && touches[0]) { this.isTouchGesture = false - const t = touches[0]! + const t = touches[0] const rect = this.canvas.getBoundingClientRect() this.lastMouseX = t.clientX - rect.left this.lastMouseY = t.clientY - rect.top @@ -278,8 +267,9 @@ export class InputHandler { const touches = e.touches if (touches.length >= 2 && this.isTouchGesture) { - const t0 = touches[0]! - const t1 = touches[1]! + const t0 = touches[0] + const t1 = touches[1] + if (!t0 || !t1) return const dist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY) const center = { x: (t0.clientX + t1.clientX) / 2, @@ -289,11 +279,9 @@ export class InputHandler { const cx = center.x - rect.left const cy = center.y - rect.top - // Pinch zoom const scale = dist / this.lastTouchDistance this.viewport.zoomImmediate(scale, cx, cy) - // Pan from center movement const dx = center.x - this.lastTouchCenter.x const dy = center.y - this.lastTouchCenter.y this.viewport.pan(dx, dy) @@ -301,8 +289,13 @@ export class InputHandler { this.lastTouchDistance = dist this.lastTouchCenter = center this.callbacks.onRequestRender() - } else if (touches.length === 1 && this.isPanning && !this.isTouchGesture) { - const t = touches[0]! + } else if ( + touches.length === 1 && + this.isPanning && + !this.isTouchGesture && + touches[0] + ) { + const t = touches[0] const rect = this.canvas.getBoundingClientRect() const x = t.clientX - rect.left const y = t.clientY - rect.top diff --git a/packages/memory-graph/src/canvas/renderer.ts b/packages/memory-graph/src/canvas/renderer.ts new file mode 100644 index 00000000..3c0d66b1 --- /dev/null +++ b/packages/memory-graph/src/canvas/renderer.ts @@ -0,0 +1,661 @@ +import type { + DocumentNodeData, + GraphEdge, + GraphNode, + GraphThemeColors, + MemoryNodeData, +} from "../types" +import type { ViewportState } from "./viewport" +import { drawDocIcon, roundRect } from "./document-icons" + +export interface RenderState { + selectedNodeId: string | null + hoveredNodeId: string | null + highlightIds: Set + dimProgress: number +} + +// Module-level reusable batch map – cleared each frame instead of reallocating +const edgeBatches = new Map() + +/** Group items by their `color` property into batches for efficient canvas drawing */ +function groupByColor( + items: T[], +): Map { + const map = new Map() + for (const item of items) { + let batch = map.get(item.color) + if (!batch) { + batch = [] + map.set(item.color, batch) + } + batch.push(item) + } + return map +} + +// Cache for lightenColor results to avoid per-frame hex parsing +let _lightenCache: { input: string; amount: number; result: string } | null = + null + +export function renderFrame( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + edges: GraphEdge[], + viewport: ViewportState, + width: number, + height: number, + state: RenderState, + nodeMap: Map, + colors: GraphThemeColors, +): void { + ctx.clearRect(0, 0, width, height) + drawEdges(ctx, edges, viewport, width, height, state, nodeMap, colors) + drawNodes(ctx, nodes, viewport, width, height, state, colors) +} + +function edgeStyle( + edge: GraphEdge, + colors: GraphThemeColors, +): { color: string; width: number; opacity: number } { + if (edge.edgeType === "derives") + return { color: colors.edgeDerives, width: 1.2, opacity: 0.4 } + if (edge.edgeType === "updates") + return { color: colors.edgeUpdates, width: 2, opacity: 0.7 } + // "extends" and any unknown edge types + return { color: colors.edgeExtends, width: 1.5, opacity: 0.55 } +} + +function batchKey(style: { + color: string + width: number + opacity: number +}): string { + return `${style.color}|${style.width}|${style.opacity}` +} + +interface PreparedEdge { + startX: number + startY: number + endX: number + endY: number + connected: boolean + style: { color: string; width: number; opacity: number } + edgeType: string + arrowSize: number +} + +function drawEdges( + ctx: CanvasRenderingContext2D, + edges: GraphEdge[], + viewport: ViewportState, + width: number, + height: number, + state: RenderState, + nodeMap: Map, + colors: GraphThemeColors, +): void { + const margin = 100 + const hasDim = state.selectedNodeId !== null && state.dimProgress > 0 + + const prepared: PreparedEdge[] = [] + + for (const edge of edges) { + // Zoom-based edge culling for extends edges at very low zoom + if (edge.edgeType === "extends") { + if (viewport.zoom < 0.08) continue + } + + const src = + typeof edge.source === "string" ? nodeMap.get(edge.source) : edge.source + const tgt = + typeof edge.target === "string" ? nodeMap.get(edge.target) : edge.target + if (!src || !tgt) continue + + if (edge.edgeType === "derives") { + const mem = src.type === "memory" ? src : tgt + if (mem.size * viewport.zoom < 3) continue + } + + const s = viewport.worldToScreen(src.x, src.y) + const t = viewport.worldToScreen(tgt.x, tgt.y) + + if ( + (s.x < -margin && t.x < -margin) || + (s.x > width + margin && t.x > width + margin) || + (s.y < -margin && t.y < -margin) || + (s.y > height + margin && t.y > height + margin) + ) + continue + + const dx = t.x - s.x + const dy = t.y - s.y + const dist = Math.sqrt(dx * dx + dy * dy) + if (dist < 1) continue + + const ux = dx / dist + const uy = dy / dist + const sr = src.size * viewport.zoom * 0.5 + const tr = tgt.size * viewport.zoom * 0.5 + + let connected = true + if (hasDim) { + const srcId = + typeof edge.source === "string" ? edge.source : edge.source.id + const tgtId = + typeof edge.target === "string" ? edge.target : edge.target.id + connected = + srcId === state.selectedNodeId || tgtId === state.selectedNodeId + } + + prepared.push({ + startX: s.x + ux * sr, + startY: s.y + uy * sr, + endX: t.x - ux * tr, + endY: t.y - uy * tr, + connected, + style: edgeStyle(edge, colors), + edgeType: edge.edgeType ?? "derives", + arrowSize: + edge.edgeType === "updates" ? Math.max(6, 8 * viewport.zoom) : 0, + }) + } + + // Reuse module-level batch map + edgeBatches.clear() + for (const e of prepared) { + const dimKey = hasDim ? (e.connected ? "|c" : "|d") : "" + const key = `${e.edgeType}|${batchKey(e.style)}${dimKey}` + let batch = edgeBatches.get(key) + if (!batch) { + batch = [] + edgeBatches.set(key, batch) + } + batch.push(e) + } + + ctx.setLineDash([]) + for (const [key, batch] of edgeBatches) { + const first = batch[0] + if (!first) continue + const isDimmed = key.endsWith("|d") + const batchEdgeType = first.edgeType + + // Draw glow pass behind all edge types for luminous aesthetic + if (!isDimmed) { + const glowAlpha = + batchEdgeType === "updates" + ? first.style.opacity * 0.4 + : first.style.opacity * 0.3 + const glowWidth = + batchEdgeType === "updates" + ? first.style.width + 2 + : first.style.width + 1.5 + ctx.save() + ctx.globalAlpha = glowAlpha + ctx.strokeStyle = first.style.color + ctx.lineWidth = glowWidth + if (batchEdgeType === "extends") ctx.setLineDash([6, 4]) + ctx.beginPath() + for (const e of batch) { + ctx.moveTo(e.startX, e.startY) + ctx.lineTo(e.endX, e.endY) + } + ctx.stroke() + ctx.restore() + } + + const baseAlpha = first.style.opacity + ctx.globalAlpha = isDimmed + ? baseAlpha * (1 - state.dimProgress * 0.8) + : baseAlpha + ctx.strokeStyle = first.style.color + ctx.lineWidth = first.style.width + + // Extends edges use dashed lines + if (batchEdgeType === "extends") ctx.setLineDash([6, 4]) + + ctx.beginPath() + for (const e of batch) { + ctx.moveTo(e.startX, e.startY) + ctx.lineTo(e.endX, e.endY) + } + ctx.stroke() + + if (batchEdgeType === "extends") ctx.setLineDash([]) + + // Arrowheads for updates edges + if (batchEdgeType === "updates") { + ctx.globalAlpha = isDimmed + ? first.style.opacity * 0.6 * (1 - state.dimProgress * 0.8) + : first.style.opacity * 0.6 + ctx.fillStyle = first.style.color + for (const e of batch) { + drawArrowHead(ctx, e.startX, e.startY, e.endX, e.endY, e.arrowSize) + } + } + } + + ctx.globalAlpha = 1 +} + +function drawArrowHead( + ctx: CanvasRenderingContext2D, + fromX: number, + fromY: number, + toX: number, + toY: number, + size: number, +): void { + const angle = Math.atan2(toY - fromY, toX - fromX) + ctx.beginPath() + ctx.moveTo(toX, toY) + ctx.lineTo( + toX - size * Math.cos(angle - Math.PI / 6), + toY - size * Math.sin(angle - Math.PI / 6), + ) + ctx.lineTo( + toX - size * Math.cos(angle + Math.PI / 6), + toY - size * Math.sin(angle + Math.PI / 6), + ) + ctx.closePath() + ctx.fill() +} + +function drawNodes( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + viewport: ViewportState, + width: number, + height: number, + state: RenderState, + colors: GraphThemeColors, +): void { + const margin = 60 + const memDots: { + x: number + y: number + r: number + color: string + dimmed: boolean + }[] = [] + const docDots: { x: number; y: number; s: number }[] = [] + + for (const node of nodes) { + const screen = viewport.worldToScreen(node.x, node.y) + const screenSize = node.size * viewport.zoom + + const cullSize = Math.max(screenSize, 2) + if ( + screen.x + cullSize < -margin || + screen.x - cullSize > width + margin || + screen.y + cullSize < -margin || + screen.y - cullSize > height + margin + ) + continue + + const isSelected = node.id === state.selectedNodeId + const isHovered = node.id === state.hoveredNodeId + const isHighlighted = state.highlightIds.has(node.id) + + if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) { + if (node.type === "document") { + docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) }) + } else { + const md = node.data as MemoryNodeData + memDots.push({ + x: screen.x, + y: screen.y, + r: Math.max(2, screenSize * 0.45), + color: node.borderColor || colors.memStrokeDefault, + dimmed: md.isLatest === false, + }) + } + continue + } + + let alpha = 1 + if (state.selectedNodeId && state.dimProgress > 0 && !isSelected) { + alpha = 1 - state.dimProgress * 0.7 + } + ctx.globalAlpha = alpha + + if (node.type === "document") { + drawDocumentNode( + ctx, + screen.x, + screen.y, + screenSize, + node, + isSelected, + isHovered, + isHighlighted, + colors, + ) + } else { + drawMemoryNode( + ctx, + screen.x, + screen.y, + screenSize, + node, + isSelected, + isHovered, + isHighlighted, + colors, + ) + } + + if (isSelected || isHighlighted || isHovered) { + drawGlow( + ctx, + screen.x, + screen.y, + screenSize, + node.type, + colors, + isHovered && !isSelected, + ) + } + } + + const dimAlpha = + state.selectedNodeId && state.dimProgress > 0 + ? 1 - state.dimProgress * 0.7 + : 1 + + if (docDots.length > 0) { + ctx.fillStyle = colors.docFill + ctx.strokeStyle = colors.docStroke + ctx.lineWidth = 1 + ctx.globalAlpha = dimAlpha + for (const d of docDots) { + const h = d.s * 0.5 + ctx.fillRect(d.x - h, d.y - h, d.s, d.s) + ctx.strokeRect(d.x - h, d.y - h, d.s, d.s) + } + } + + if (memDots.length > 0) { + // Draw normal (latest) memory dots + const normalDots = memDots.filter((d) => !d.dimmed) + const dimmedDots = memDots.filter((d) => d.dimmed) + + if (normalDots.length > 0) { + // Subtle glow behind memory dots for luminous effect + ctx.globalAlpha = dimAlpha * 0.25 + for (const [color, batch] of groupByColor(normalDots)) { + ctx.fillStyle = color + ctx.beginPath() + for (const d of batch) { + ctx.moveTo(d.x + d.r * 2.5, d.y) + ctx.arc(d.x, d.y, d.r * 2.5, 0, Math.PI * 2) + } + ctx.fill() + } + + // Filled dot + ctx.globalAlpha = dimAlpha + ctx.fillStyle = colors.memFill + ctx.beginPath() + for (const d of normalDots) { + ctx.moveTo(d.x + d.r, d.y) + ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) + } + ctx.fill() + + // Colored border + ctx.lineWidth = 1.5 + for (const [color, batch] of groupByColor(normalDots)) { + ctx.strokeStyle = color + ctx.beginPath() + for (const d of batch) { + ctx.moveTo(d.x + d.r, d.y) + ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) + } + ctx.stroke() + } + } + + // Draw dimmed (superseded) memory dots at reduced opacity + if (dimmedDots.length > 0) { + ctx.globalAlpha = dimAlpha * 0.5 + ctx.fillStyle = colors.memFill + ctx.beginPath() + for (const d of dimmedDots) { + ctx.moveTo(d.x + d.r, d.y) + ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) + } + ctx.fill() + + ctx.lineWidth = 1 + for (const [color, batch] of groupByColor(dimmedDots)) { + ctx.strokeStyle = color + ctx.beginPath() + for (const d of batch) { + ctx.moveTo(d.x + d.r, d.y) + ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) + } + ctx.stroke() + } + } + } + + ctx.globalAlpha = 1 +} + +function drawDocumentNode( + ctx: CanvasRenderingContext2D, + sx: number, + sy: number, + size: number, + node: GraphNode, + isSelected: boolean, + isHovered: boolean, + isHighlighted: boolean, + colors: GraphThemeColors, +): void { + const half = size * 0.5 + const cornerR = 8 * (size / 50) + + // Drop shadow for selected/hovered nodes + if (isSelected || isHovered) { + ctx.save() + ctx.shadowColor = colors.accent + ctx.shadowBlur = isSelected ? 16 : 10 + ctx.shadowOffsetX = 0 + ctx.shadowOffsetY = 0 + } + + // Subtle gradient fill for document nodes + const grad = ctx.createLinearGradient( + sx - half, + sy - half, + sx + half, + sy + half, + ) + grad.addColorStop(0, colors.docFill) + grad.addColorStop(1, lightenColor(colors.docFill, 0.08)) + ctx.fillStyle = grad + + ctx.strokeStyle = + isSelected || isHighlighted || isHovered ? colors.accent : colors.docStroke + ctx.lineWidth = isSelected || isHighlighted ? 2.5 : isHovered ? 1.5 : 1 + roundRect(ctx, sx - half, sy - half, size, size, cornerR) + ctx.fill() + ctx.stroke() + + if (isSelected || isHovered) { + ctx.restore() + } + + const innerSize = size * 0.72 + const innerHalf = innerSize * 0.5 + const innerR = 6 * (size / 50) + ctx.fillStyle = colors.docInnerFill + roundRect(ctx, sx - innerHalf, sy - innerHalf, innerSize, innerSize, innerR) + ctx.fill() + + const iconSize = size * 0.35 + const docType = + node.type === "document" ? (node.data as DocumentNodeData).type : "text" + drawDocIcon(ctx, sx, sy, iconSize, docType || "text", colors.iconColor) +} + +function drawMemoryNode( + ctx: CanvasRenderingContext2D, + sx: number, + sy: number, + size: number, + node: GraphNode, + isSelected: boolean, + isHovered: boolean, + _isHighlighted: boolean, + colors: GraphThemeColors, +): void { + const memData = node.data as MemoryNodeData + const isSuperseded = memData.isLatest === false + const isForgotten = memData.isForgotten + const radius = size * 0.5 + + // Dim superseded (non-latest) memory nodes with strikethrough effect + if (isSuperseded && !isSelected && !isHovered) { + const prevAlpha = ctx.globalAlpha + ctx.globalAlpha = prevAlpha * 0.5 + ctx.fillStyle = colors.memFill + drawHexagon(ctx, sx, sy, radius) + ctx.fill() + ctx.strokeStyle = node.borderColor || colors.memStrokeDefault + ctx.lineWidth = 1 + ctx.setLineDash([3, 3]) + ctx.stroke() + ctx.setLineDash([]) + + // Draw diagonal strikethrough for superseded nodes (visual clarity) + const strikeR = radius * 0.55 + ctx.beginPath() + ctx.moveTo(sx - strikeR, sy - strikeR) + ctx.lineTo(sx + strikeR, sy + strikeR) + ctx.strokeStyle = colors.textMuted + ctx.lineWidth = 1.5 + ctx.stroke() + + ctx.globalAlpha = prevAlpha + return + } + + // Drop shadow for selected/hovered memory nodes + if (isSelected || isHovered) { + ctx.save() + const shadowColor = isSelected ? colors.accent : colors.glowColor + ctx.shadowColor = shadowColor + ctx.shadowBlur = isSelected ? 18 : 12 + ctx.shadowOffsetX = 0 + ctx.shadowOffsetY = 0 + } + + ctx.fillStyle = isHovered ? colors.memFillHover : colors.memFill + drawHexagon(ctx, sx, sy, radius) + ctx.fill() + + const borderColor = node.borderColor || colors.memStrokeDefault + ctx.strokeStyle = isSelected ? colors.accent : borderColor + ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5 + ctx.stroke() + + if (isSelected || isHovered) { + ctx.restore() + } + + // Draw X icon for forgotten nodes + if (isForgotten && size > 14) { + const iconR = radius * 0.3 + ctx.save() + ctx.strokeStyle = colors.memBorderForgotten + ctx.lineWidth = Math.max(1.5, size / 20) + ctx.lineCap = "round" + ctx.globalAlpha = 0.9 + ctx.beginPath() + ctx.moveTo(sx - iconR, sy - iconR) + ctx.lineTo(sx + iconR, sy + iconR) + ctx.moveTo(sx + iconR, sy - iconR) + ctx.lineTo(sx - iconR, sy + iconR) + ctx.stroke() + ctx.restore() + } +} + +function drawGlow( + ctx: CanvasRenderingContext2D, + sx: number, + sy: number, + size: number, + nodeType: "document" | "memory", + colors: GraphThemeColors, + isHoverOnly = false, +): void { + ctx.strokeStyle = colors.glowColor + ctx.lineWidth = isHoverOnly ? 1.5 : 2 + ctx.setLineDash(isHoverOnly ? [4, 4] : [3, 3]) + ctx.globalAlpha = isHoverOnly ? 0.5 : 0.8 + + const scale = isHoverOnly ? 1.1 : 1.15 + + if (nodeType === "document") { + const glowSize = size * scale + const half = glowSize * 0.5 + const r = 8 * (glowSize / 50) + roundRect(ctx, sx - half, sy - half, glowSize, glowSize, r) + } else { + drawHexagon(ctx, sx, sy, size * 0.5 * scale) + } + + ctx.stroke() + ctx.setLineDash([]) + ctx.globalAlpha = 1 +} + +function drawHexagon( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + radius: number, +): void { + ctx.beginPath() + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 6 + const x = cx + radius * Math.cos(angle) + const y = cy + radius * Math.sin(angle) + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y) + } + ctx.closePath() +} + +/** Lighten a 6-digit hex color by a fraction (0-1). Cached to avoid per-frame parsing. */ +export function lightenColor(hex: string, amount: number): string { + if ( + _lightenCache && + _lightenCache.input === hex && + _lightenCache.amount === amount + ) { + return _lightenCache.result + } + const h = hex.replace("#", "") + // Only handle standard 6-digit hex; return input unchanged for other formats + if (h.length !== 6) return hex + const r = Math.min( + 255, + Number.parseInt(h.substring(0, 2), 16) + Math.round(255 * amount), + ) + const g = Math.min( + 255, + Number.parseInt(h.substring(2, 4), 16) + Math.round(255 * amount), + ) + const b = Math.min( + 255, + Number.parseInt(h.substring(4, 6), 16) + Math.round(255 * amount), + ) + const result = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}` + _lightenCache = { input: hex, amount, result } + return result +} diff --git a/packages/memory-graph/src/canvas/simulation.ts b/packages/memory-graph/src/canvas/simulation.ts new file mode 100644 index 00000000..44271793 --- /dev/null +++ b/packages/memory-graph/src/canvas/simulation.ts @@ -0,0 +1,98 @@ +import * as d3 from "d3-force" +import type { GraphEdge, GraphNode } from "../types" +import { FORCE_CONFIG } from "../constants" + +export class ForceSimulation { + private sim: d3.Simulation | null = null + + init(nodes: GraphNode[], edges: GraphEdge[]): void { + this.destroy() + + try { + // Only use structural edges (derives, updates) for the force layout. + // "extends" edges are visual-only -- they connect documents sharing a + // spaceId but should not pull documents together into a single mass. + const structuralEdges = edges.filter((e) => e.edgeType !== "extends") + + this.sim = d3 + .forceSimulation(nodes) + .alphaDecay(FORCE_CONFIG.alphaDecay) + .alphaMin(FORCE_CONFIG.alphaMin) + .velocityDecay(FORCE_CONFIG.velocityDecay) + + this.sim.force( + "link", + d3 + .forceLink(structuralEdges) + .id((d) => d.id) + .distance((link) => + link.edgeType === "derives" + ? FORCE_CONFIG.docMemoryDistance + : FORCE_CONFIG.linkDistance, + ) + .strength((link) => { + if (link.edgeType === "derives") + return FORCE_CONFIG.linkStrength.docMemory + if (link.edgeType === "updates") + return FORCE_CONFIG.linkStrength.version + return FORCE_CONFIG.linkStrength.fallback + }), + ) + + this.sim.force( + "charge", + d3.forceManyBody().strength(FORCE_CONFIG.chargeStrength), + ) + + this.sim.force( + "collide", + d3 + .forceCollide() + .radius((d) => + d.type === "document" + ? FORCE_CONFIG.collisionRadius.document + : FORCE_CONFIG.collisionRadius.memory, + ) + .strength(FORCE_CONFIG.collisionStrength), + ) + + this.sim.force("x", d3.forceX().strength(FORCE_CONFIG.centeringStrength)) + this.sim.force("y", d3.forceY().strength(FORCE_CONFIG.centeringStrength)) + + this.sim.stop() + this.sim.alpha(1) + for (let i = 0; i < FORCE_CONFIG.preSettleTicks; i++) this.sim.tick() + this.sim.alphaTarget(0).restart() + } catch (e) { + console.error("ForceSimulation.init failed:", e) + this.destroy() + } + } + + update(nodes: GraphNode[], edges: GraphEdge[]): void { + if (!this.sim) return + this.sim.nodes(nodes) + const linkForce = this.sim.force>("link") + if (linkForce) + linkForce.links(edges.filter((e) => e.edgeType !== "extends")) + } + + reheat(): void { + this.sim?.alphaTarget(FORCE_CONFIG.alphaTarget).restart() + } + + coolDown(): void { + this.sim?.alphaTarget(0) + } + + isActive(): boolean { + return (this.sim?.alpha() ?? 0) > FORCE_CONFIG.alphaMin + } + + destroy(): void { + if (this.sim) { + this.sim.stop() + this.sim = null + } + } +} diff --git a/packages/memory-graph/src/canvas/version-chain.ts b/packages/memory-graph/src/canvas/version-chain.ts new file mode 100644 index 00000000..6c692fa7 --- /dev/null +++ b/packages/memory-graph/src/canvas/version-chain.ts @@ -0,0 +1,97 @@ +import type { GraphApiDocument, GraphApiMemory } from "../types" + +export interface ChainEntry { + id: string + version: number + memory: string + isForgotten: boolean + isLatest: boolean +} + +export class VersionChainIndex { + private memoryMap = new Map() + private childrenMap = new Map() + private cache = new Map() + private lastDocs: GraphApiDocument[] | null = null + + rebuild(documents: GraphApiDocument[]): void { + if (documents === this.lastDocs) return + this.lastDocs = documents + this.memoryMap.clear() + this.childrenMap.clear() + this.cache.clear() + + for (const doc of documents) { + for (const m of doc.memories) { + this.memoryMap.set(m.id, m) + if (m.parentMemoryId) { + let children = this.childrenMap.get(m.parentMemoryId) + if (!children) { + children = [] + this.childrenMap.set(m.parentMemoryId, children) + } + children.push(m.id) + } + } + } + } + + getChain(memoryId: string): ChainEntry[] | null { + const cached = this.cache.get(memoryId) + if (cached) return cached + + const mem = this.memoryMap.get(memoryId) + if (!mem) return null + + // Walk backward to root + const backward: GraphApiMemory[] = [] + const visited = new Set() + let current: GraphApiMemory | undefined = mem + while (current && !visited.has(current.id)) { + visited.add(current.id) + backward.push(current) + current = current.parentMemoryId + ? this.memoryMap.get(current.parentMemoryId) + : undefined + } + backward.reverse() + + // Walk forward from the selected node to find descendants. + // Version chains are linear (each memory has one parent), so we + // follow the first child at each step. If branching occurs, only + // the first branch (by document order) is included. + const forward: GraphApiMemory[] = [] + let cursor: GraphApiMemory | undefined = mem + while (cursor) { + const children = this.childrenMap.get(cursor.id) + if (!children || children.length === 0) break + const firstChildId = children[0] + if (!firstChildId) break + const child = this.memoryMap.get(firstChildId) + if (!child || visited.has(child.id)) break + visited.add(child.id) + forward.push(child) + cursor = child + } + + // Combine: backward (root..selected) + forward (selected+1..latest) + const all = [...backward, ...forward] + + // A single-entry chain (standalone v1 with no children) is not useful + if (all.length <= 1) return null + + const chain: ChainEntry[] = all.map((m) => ({ + id: m.id, + version: m.version, + memory: m.memory, + isForgotten: m.isForgotten, + isLatest: m.isLatest, + })) + + for (const entry of chain) { + this.cache.set(entry.id, chain) + } + + return chain + } +} diff --git a/apps/web/components/memory-graph/canvas/viewport.ts b/packages/memory-graph/src/canvas/viewport.ts similarity index 97% rename from apps/web/components/memory-graph/canvas/viewport.ts rename to packages/memory-graph/src/canvas/viewport.ts index ec2715a6..b741097b 100644 --- a/apps/web/components/memory-graph/canvas/viewport.ts +++ b/packages/memory-graph/src/canvas/viewport.ts @@ -43,7 +43,6 @@ export class ViewportState { pan(dx: number, dy: number): void { this.panX += dx this.panY += dy - // Cancel any target pan animation when user drags this.targetPanX = null this.targetPanY = null } @@ -125,7 +124,6 @@ export class ViewportState { tick(): boolean { let moving = false - // Momentum panning if (Math.abs(this.velocityX) > 0.5 || Math.abs(this.velocityY) > 0.5) { this.panX += this.velocityX this.panY += this.velocityY @@ -137,7 +135,6 @@ export class ViewportState { this.velocityY = 0 } - // Spring zoom const zoomDiff = this.targetZoom - this.zoom if (Math.abs(zoomDiff) > 0.001) { const world = this.screenToWorld(this.zoomAnchorX, this.zoomAnchorY) @@ -147,7 +144,6 @@ export class ViewportState { moving = true } - // Lerp pan animation if (this.targetPanX !== null && this.targetPanY !== null) { const dx = this.targetPanX - this.panX const dy = this.targetPanY - this.panY diff --git a/packages/memory-graph/src/components/canvas-common.css.ts b/packages/memory-graph/src/components/canvas-common.css.ts deleted file mode 100644 index 4f4a3504..00000000 --- a/packages/memory-graph/src/components/canvas-common.css.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { style } from "@vanilla-extract/css" - -/** - * Canvas wrapper/container that fills its parent - * Used by both graph-canvas and graph-webgl-canvas - */ -export const canvasWrapper = style({ - position: "absolute", - inset: 0, -}) diff --git a/packages/memory-graph/src/components/graph-canvas.tsx b/packages/memory-graph/src/components/graph-canvas.tsx index f4cede9f..f195e0ba 100644 --- a/packages/memory-graph/src/components/graph-canvas.tsx +++ b/packages/memory-graph/src/components/graph-canvas.tsx @@ -1,1053 +1,315 @@ -"use client" +import { memo, useEffect, useLayoutEffect, useRef } from "react" +import { SpatialIndex } from "../canvas/hit-test" +import { InputHandler } from "../canvas/input-handler" +import { renderFrame } from "../canvas/renderer" +import { ViewportState } from "../canvas/viewport" +import { GRAPH_SETTINGS } from "../constants" +import type { GraphCanvasProps, GraphNode, GraphThemeColors } from "../types" +import type { ForceSimulation } from "../canvas/simulation" -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react" -import { colors, ANIMATION } from "@/constants" -import type { - DocumentWithMemories, - GraphCanvasProps, - GraphNode, - MemoryEntry, -} from "@/types" -import { drawDocumentIcon } from "@/utils/document-icons" -import { canvasWrapper } from "./canvas-common.css" +export interface ExtendedGraphCanvasProps extends GraphCanvasProps { + showFps?: boolean + variant?: "console" | "consumer" + colors: GraphThemeColors +} -export const GraphCanvas = memo( - ({ +export const GraphCanvas = memo(function GraphCanvas({ + nodes, + edges, + width, + height, + colors, + highlightDocumentIds, + selectedNodeId = null, + onNodeHover, + onNodeClick, + onNodeDragStart, + onNodeDragEnd, + onViewportChange, + canvasRef: externalCanvasRef, + simulation, + viewportRef: externalViewportRef, + showFps = false, + variant = "console", +}) { + const internalCanvasRef = useRef(null) + const canvasRef = externalCanvasRef || internalCanvasRef + + // Engine instances — mutable, never trigger re-renders + const viewportRef = useRef(null) + const spatialRef = useRef(new SpatialIndex()) + const inputRef = useRef(null) + const rafRef = useRef(0) + const renderNeeded = useRef(true) + const nodeMapRef = useRef(new Map()) + + // FPS tracking refs + const showFpsRef = useRef(showFps) + showFpsRef.current = showFps + const fpsFrames = useRef(0) + const fpsLastTime = useRef(performance.now()) + const fpsValue = useRef(0) + + // DPR ref for FPS overlay + const dprRef = useRef( + typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1, + ) + + // All mutable render state in a single ref — the rAF loop reads from here + const s = useRef({ nodes, edges, - panX, - panY, - zoom, width, height, + colors, + selectedNodeId, + hoveredNodeId: null as string | null, + highlightIds: new Set(highlightDocumentIds ?? []), + dimProgress: 0, + dimTarget: selectedNodeId ? 1 : 0, + }) + + // Sync incoming props to mutable state (no re-renders) + s.current.nodes = nodes + s.current.edges = edges + s.current.width = width + s.current.height = height + s.current.colors = colors + + // Stable callback refs so InputHandler never needs recreation + const cb = useRef({ onNodeHover, onNodeClick, onNodeDragStart, - onNodeDragMove, onNodeDragEnd, - onPanStart, - onPanMove, - onPanEnd, - onWheel, - onDoubleClick, - onTouchStart, - onTouchMove, - onTouchEnd, - draggingNodeId, - highlightDocumentIds, - isSimulationActive = false, - selectedNodeId = null, - }) => { - const canvasRef = useRef(null) - const animationRef = useRef(0) - const startTimeRef = useRef(Date.now()) - const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) - const currentHoveredNode = useRef(null) - const dimProgress = useRef(selectedNodeId ? 1 : 0) - const dimAnimationRef = useRef(0) - const [, forceRender] = useState(0) + onViewportChange, + simulation: simulation as ForceSimulation | undefined, + }) + cb.current = { + onNodeHover, + onNodeClick, + onNodeDragStart, + onNodeDragEnd, + onViewportChange, + simulation, + } - // Initialize start time once - useEffect(() => { - startTimeRef.current = Date.now() - }, []) + // Rebuild nodeMap + spatial index when nodes change + useEffect(() => { + const map = nodeMapRef.current + map.clear() + for (const n of nodes) map.set(n.id, n) + spatialRef.current.rebuild(nodes) + renderNeeded.current = true + }, [nodes]) - // Initialize canvas quality settings once - useLayoutEffect(() => { - const canvas = canvasRef.current - if (!canvas) return - const ctx = canvas.getContext("2d") - if (!ctx) return + useEffect(() => { + s.current.highlightIds = new Set(highlightDocumentIds ?? []) + renderNeeded.current = true + }, [highlightDocumentIds]) - // Set high quality rendering once instead of every frame + useEffect(() => { + s.current.selectedNodeId = selectedNodeId + s.current.dimTarget = selectedNodeId ? 1 : 0 + renderNeeded.current = true + }, [selectedNodeId]) + + // Create viewport + input handler (once per variant) + // biome-ignore lint/correctness/useExhaustiveDependencies: canvasRef and externalViewportRef are refs — mutations do not trigger re-renders, intentionally omitted + useLayoutEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const cfg = GRAPH_SETTINGS[variant] + const vp = new ViewportState( + cfg.initialPanX, + cfg.initialPanY, + cfg.initialZoom, + ) + viewportRef.current = vp + if (externalViewportRef) { + ;( + externalViewportRef as React.MutableRefObject + ).current = vp + } + + const handler = new InputHandler(canvas, vp, spatialRef.current, { + onNodeHover: (id) => { + s.current.hoveredNodeId = id + cb.current.onNodeHover(id) + renderNeeded.current = true + }, + onNodeClick: (id) => cb.current.onNodeClick(id), + onNodeDragStart: (id, _node) => { + cb.current.onNodeDragStart(id) + cb.current.simulation?.reheat() + }, + onNodeDragEnd: () => { + cb.current.onNodeDragEnd() + cb.current.simulation?.coolDown() + }, + onRequestRender: () => { + renderNeeded.current = true + }, + }) + inputRef.current = handler + + return () => handler.destroy() + }, [variant]) + + // High-DPI canvas sizing + const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1 + dprRef.current = dpr + + // biome-ignore lint/correctness/useExhaustiveDependencies: canvasRef is a ref — mutations do not trigger re-renders, intentionally omitted + useLayoutEffect(() => { + const canvas = canvasRef.current + if (!canvas || width === 0 || height === 0) return + + const MAX = 16384 + const d = Math.min(MAX / width, MAX / height, dpr) + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + canvas.width = Math.min(width * d, MAX) + canvas.height = Math.min(height * d, MAX) + + const ctx = canvas.getContext("2d") + if (ctx) { + ctx.scale(d, d) ctx.imageSmoothingEnabled = true ctx.imageSmoothingQuality = "high" - }, []) + } + renderNeeded.current = true + }, [width, height, dpr]) - // Smooth dimming animation - useEffect(() => { - const targetDim = selectedNodeId ? 1 : 0 - const duration = ANIMATION.dimDuration // Match physics settling time - const startDim = dimProgress.current - const startTime = Date.now() + // Single render loop — runs for component lifetime, reads everything from refs + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally empty deps — all state read via refs inside tick(), not reactive props + useEffect(() => { + let prevVpX = 0 + let prevVpY = 0 + let prevVpZoom = 0 - const animate = () => { - const elapsed = Date.now() - startTime - const progress = Math.min(elapsed / duration, 1) + const tick = () => { + rafRef.current = requestAnimationFrame(tick) - // Ease-out cubic easing for smooth deceleration - const eased = 1 - (1 - progress) ** 3 - dimProgress.current = startDim + (targetDim - startDim) * eased - - // Force re-render to update canvas during animation - forceRender((prev) => prev + 1) - - if (progress < 1) { - dimAnimationRef.current = requestAnimationFrame(animate) - } - } - - if (dimAnimationRef.current) { - cancelAnimationFrame(dimAnimationRef.current) - } - animate() - - return () => { - if (dimAnimationRef.current) { - cancelAnimationFrame(dimAnimationRef.current) - } - } - }, [selectedNodeId]) - - // Spatial grid for optimized hit detection (20-25% FPS improvement for large graphs) - const spatialGrid = useMemo(() => { - const GRID_CELL_SIZE = 150 // Grid cell size in screen pixels - const grid = new Map() - - // Build spatial grid - nodes.forEach((node) => { - const screenX = node.x * zoom + panX - const screenY = node.y * zoom + panY - - // Calculate which grid cell this node belongs to - const cellX = Math.floor(screenX / GRID_CELL_SIZE) - const cellY = Math.floor(screenY / GRID_CELL_SIZE) - const cellKey = `${cellX},${cellY}` - - // Add node to grid cell - if (!grid.has(cellKey)) { - grid.set(cellKey, []) - } - grid.get(cellKey)!.push(node) - }) - - return { grid, cellSize: GRID_CELL_SIZE } - }, [nodes, panX, panY, zoom]) - - // Efficient hit detection using spatial grid - const getNodeAtPosition = useCallback( - (x: number, y: number): string | null => { - const { grid, cellSize } = spatialGrid - - // Determine which grid cell the click is in - const cellX = Math.floor(x / cellSize) - const cellY = Math.floor(y / cellSize) - const cellKey = `${cellX},${cellY}` - - // Only check nodes in the clicked cell (and neighboring cells for edge cases) - const cellsToCheck = [ - cellKey, - `${cellX - 1},${cellY}`, - `${cellX + 1},${cellY}`, - `${cellX},${cellY - 1}`, - `${cellX},${cellY + 1}`, - ] - - // Check from top-most to bottom-most: memory nodes are drawn after documents - for (const key of cellsToCheck) { - const cellNodes = grid.get(key) - if (!cellNodes) continue - - // Iterate backwards (top-most first) - for (let i = cellNodes.length - 1; i >= 0; i--) { - const node = cellNodes[i]! - const screenX = node.x * zoom + panX - const screenY = node.y * zoom + panY - const nodeSize = node.size * zoom - - if (node.type === "document") { - // Rectangular hit detection for documents (matches visual size) - const docWidth = nodeSize * 1.4 - const docHeight = nodeSize * 0.9 - const halfW = docWidth / 2 - const halfH = docHeight / 2 - - if ( - x >= screenX - halfW && - x <= screenX + halfW && - y >= screenY - halfH && - y <= screenY + halfH - ) { - return node.id - } - } else { - // Circular hit detection for memory nodes - const dx = x - screenX - const dy = y - screenY - const distance = Math.sqrt(dx * dx + dy * dy) - - if (distance <= nodeSize / 2) { - return node.id - } - } - } - } - return null - }, - [spatialGrid, panX, panY, zoom], - ) - - // Handle mouse events - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - const canvas = canvasRef.current - if (!canvas) return - - const rect = canvas.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top - - mousePos.current = { x, y } - - const nodeId = getNodeAtPosition(x, y) - if (nodeId !== currentHoveredNode.current) { - currentHoveredNode.current = nodeId - onNodeHover(nodeId) - } - - // Handle node dragging - if (draggingNodeId) { - onNodeDragMove(e) - } - }, - [getNodeAtPosition, onNodeHover, draggingNodeId, onNodeDragMove], - ) - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - const canvas = canvasRef.current - if (!canvas) return - - const rect = canvas.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top - - const nodeId = getNodeAtPosition(x, y) - if (nodeId) { - // When starting a node drag, prevent initiating pan - e.stopPropagation() - onNodeDragStart(nodeId, e) - return - } - onPanStart(e) - }, - [getNodeAtPosition, onNodeDragStart, onPanStart], - ) - - const handleClick = useCallback( - (e: React.MouseEvent) => { - const canvas = canvasRef.current - if (!canvas) return - - const rect = canvas.getBoundingClientRect() - const x = e.clientX - rect.left - const y = e.clientY - rect.top - - const nodeId = getNodeAtPosition(x, y) - if (nodeId) { - onNodeClick(nodeId) - } - }, - [getNodeAtPosition, onNodeClick], - ) - - // Memoize nodeMap to avoid rebuilding every frame - const nodeMap = useMemo(() => { - return new Map(nodes.map((node) => [node.id, node])) - }, [nodes]) - - // Professional rendering function with LOD - const render = useCallback(() => { + const vp = viewportRef.current const canvas = canvasRef.current - if (!canvas) return + if (!vp || !canvas) return const ctx = canvas.getContext("2d") if (!ctx) return - const currentTime = Date.now() - const _elapsed = currentTime - startTimeRef.current + const cur = s.current - // Level-of-detail optimization based on zoom - const useSimplifiedRendering = zoom < 0.3 + // 1. Viewport momentum / spring zoom / lerp pan + const vpMoving = vp.tick() - // Clear canvas - ctx.clearRect(0, 0, width, height) - - // Draw minimal background grid - ctx.strokeStyle = "rgba(148, 163, 184, 0.03)" // Very subtle grid - ctx.lineWidth = 1 - const gridSpacing = 100 * zoom - const offsetX = panX % gridSpacing - const offsetY = panY % gridSpacing - - // Simple, clean grid lines - for (let x = offsetX; x < width; x += gridSpacing) { - ctx.beginPath() - ctx.moveTo(x, 0) - ctx.lineTo(x, height) - ctx.stroke() - } - for (let y = offsetY; y < height; y += gridSpacing) { - ctx.beginPath() - ctx.moveTo(0, y) - ctx.lineTo(width, y) - ctx.stroke() + // 2. Dim animation (ease toward target) + const dd = cur.dimTarget - cur.dimProgress + let dimming = false + if (Math.abs(dd) > 0.01) { + cur.dimProgress += dd * 0.1 + dimming = true + } else { + cur.dimProgress = cur.dimTarget } - // Draw enhanced edges with sophisticated styling - BATCHED BY TYPE for performance - ctx.lineCap = "round" + // 3. Simulation physics + const simActive = cb.current.simulation?.isActive() ?? false - // Group edges by type for batch rendering (reduces canvas state changes) - const docMemoryEdges: typeof edges = [] - const docDocEdges: typeof edges = [] - const versionEdges: typeof edges = [] + // 4. Spatial index rebuild (only when positions actually move) + const spatialChanged = + simActive || inputRef.current?.getDraggingNode() + ? spatialRef.current.rebuild(cur.nodes) + : false - // Categorize edges (single pass) with viewport culling - edges.forEach((edge) => { - // Handle both string IDs and node references (d3-force mutates these) - const sourceNode = - typeof edge.source === "string" - ? nodeMap.get(edge.source) - : edge.source - const targetNode = - typeof edge.target === "string" - ? nodeMap.get(edge.target) - : edge.target - - if (sourceNode && targetNode) { - const sourceX = sourceNode.x * zoom + panX - const sourceY = sourceNode.y * zoom + panY - const targetX = targetNode.x * zoom + panX - const targetY = targetNode.y * zoom + panY - - // Enhanced viewport culling with proper X and Y axis bounds checking - // Only cull edges when BOTH endpoints are off-screen in the same direction - const edgeMargin = 100 - if ( - (sourceX < -edgeMargin && targetX < -edgeMargin) || - (sourceX > width + edgeMargin && targetX > width + edgeMargin) || - (sourceY < -edgeMargin && targetY < -edgeMargin) || - (sourceY > height + edgeMargin && targetY > height + edgeMargin) - ) { - return - } - - // Skip very weak connections when zoomed out for performance - if (useSimplifiedRendering) { - if ( - edge.edgeType === "doc-memory" && - edge.visualProps.opacity < 0.3 - ) { - return // Skip very weak doc-memory edges when zoomed out - } - } - - // Sort into appropriate batch based on edge type - if (edge.edgeType === "doc-memory") { - docMemoryEdges.push(edge) - } else if (edge.edgeType === "doc-doc") { - docDocEdges.push(edge) - } else if (edge.edgeType === "version") { - versionEdges.push(edge) - } - } - }) - - // Helper function to draw a single edge path - const drawEdgePath = ( - edge: (typeof edges)[0], - sourceNode: GraphNode, - targetNode: GraphNode, - edgeShouldDim: boolean, - ) => { - const sourceX = sourceNode.x * zoom + panX - const sourceY = sourceNode.y * zoom + panY - const targetX = targetNode.x * zoom + panX - const targetY = targetNode.y * zoom + panY - - // Simplified lines when zoomed out, curved when zoomed in - if (useSimplifiedRendering) { - // Straight lines for performance - ctx.beginPath() - ctx.moveTo(sourceX, sourceY) - ctx.lineTo(targetX, targetY) - ctx.stroke() - } else { - // Regular curved line for doc-memory and doc-doc - const midX = (sourceX + targetX) / 2 - const midY = (sourceY + targetY) / 2 - const dx = targetX - sourceX - const dy = targetY - sourceY - const distance = Math.sqrt(dx * dx + dy * dy) - const controlOffset = - edge.edgeType === "doc-memory" ? 15 : Math.min(30, distance * 0.2) - - ctx.beginPath() - ctx.moveTo(sourceX, sourceY) - ctx.quadraticCurveTo( - midX + controlOffset * (dy / distance), - midY - controlOffset * (dx / distance), - targetX, - targetY, - ) - ctx.stroke() - } - } - - // Smooth edge opacity: interpolate between full and 0.05 (dimmed) - const edgeDimOpacity = 1 - dimProgress.current * 0.95 - - // BATCH 1: Draw all doc-memory edges together - if (docMemoryEdges.length > 0) { - ctx.strokeStyle = colors.connection.memory - ctx.lineWidth = 1 - ctx.setLineDash([]) - - docMemoryEdges.forEach((edge) => { - const sourceNode = - typeof edge.source === "string" - ? nodeMap.get(edge.source) - : edge.source - const targetNode = - typeof edge.target === "string" - ? nodeMap.get(edge.target) - : edge.target - - if (sourceNode && targetNode) { - const edgeShouldDim = - selectedNodeId !== null && - sourceNode.id !== selectedNodeId && - targetNode.id !== selectedNodeId - const opacity = edgeShouldDim ? edgeDimOpacity : 0.9 - - ctx.globalAlpha = opacity - drawEdgePath(edge, sourceNode, targetNode, edgeShouldDim) - } - }) - } - - // BATCH 2: Draw all doc-doc edges together (grouped by similarity strength) - if (docDocEdges.length > 0) { - const dashPattern = useSimplifiedRendering ? [] : [10, 5] - ctx.setLineDash(dashPattern) - - docDocEdges.forEach((edge) => { - const sourceNode = - typeof edge.source === "string" - ? nodeMap.get(edge.source) - : edge.source - const targetNode = - typeof edge.target === "string" - ? nodeMap.get(edge.target) - : edge.target - - if (sourceNode && targetNode) { - const edgeShouldDim = - selectedNodeId !== null && - sourceNode.id !== selectedNodeId && - targetNode.id !== selectedNodeId - const opacity = edgeShouldDim - ? edgeDimOpacity - : Math.max(0, edge.similarity * 0.5) - const lineWidth = Math.max(1, edge.similarity * 2) - - // Set color based on similarity strength - let connectionColor = colors.connection.weak - if (edge.similarity > 0.85) - connectionColor = colors.connection.strong - else if (edge.similarity > 0.725) - connectionColor = colors.connection.medium - - ctx.strokeStyle = connectionColor - ctx.lineWidth = lineWidth - ctx.globalAlpha = opacity - drawEdgePath(edge, sourceNode, targetNode, edgeShouldDim) - } - }) - } - - // BATCH 3: Draw all version edges together - if (versionEdges.length > 0) { - ctx.setLineDash([]) - - versionEdges.forEach((edge) => { - const sourceNode = - typeof edge.source === "string" - ? nodeMap.get(edge.source) - : edge.source - const targetNode = - typeof edge.target === "string" - ? nodeMap.get(edge.target) - : edge.target - - if (sourceNode && targetNode) { - const edgeShouldDim = - selectedNodeId !== null && - sourceNode.id !== selectedNodeId && - targetNode.id !== selectedNodeId - const opacity = edgeShouldDim ? edgeDimOpacity : 0.8 - const connectionColor = edge.color || colors.relations.updates - - const sourceX = sourceNode.x * zoom + panX - const sourceY = sourceNode.y * zoom + panY - const targetX = targetNode.x * zoom + panX - const targetY = targetNode.y * zoom + panY - - // Special double-line rendering for version chains - ctx.strokeStyle = connectionColor - - // First line (outer) - ctx.lineWidth = 3 - ctx.globalAlpha = opacity * 0.3 - ctx.beginPath() - ctx.moveTo(sourceX, sourceY) - ctx.lineTo(targetX, targetY) - ctx.stroke() - - // Second line (inner) - ctx.lineWidth = 1 - ctx.globalAlpha = opacity - ctx.beginPath() - ctx.moveTo(sourceX, sourceY) - ctx.lineTo(targetX, targetY) - ctx.stroke() - - // Subtle arrow head - const angle = Math.atan2(targetY - sourceY, targetX - sourceX) - const arrowLength = Math.max(6, 8 * zoom) - const arrowWidth = Math.max(8, 12 * zoom) - - 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.strokeStyle = connectionColor - ctx.lineWidth = Math.max(1, 1.5 * zoom) - ctx.globalAlpha = opacity - - ctx.beginPath() - ctx.moveTo(0, 0) - ctx.lineTo(-arrowLength, arrowWidth / 2) - ctx.moveTo(0, 0) - ctx.lineTo(-arrowLength, -arrowWidth / 2) - ctx.stroke() - - ctx.restore() - } - }) - } - - ctx.globalAlpha = 1 - ctx.setLineDash([]) - - // Prepare highlight set from provided document IDs (customId or internal) - const highlightSet = new Set(highlightDocumentIds ?? []) - - // Draw nodes with enhanced styling and LOD optimization - nodes.forEach((node) => { - const screenX = node.x * zoom + panX - const screenY = node.y * zoom + panY - const nodeSize = node.size * zoom - - // Enhanced viewport culling - const margin = nodeSize + 50 - if ( - screenX < -margin || - screenX > width + margin || - screenY < -margin || - screenY > height + margin - ) { - return - } - - const isHovered = currentHoveredNode.current === node.id - const isDragging = node.isDragging - const isSelected = selectedNodeId === node.id - const shouldDim = selectedNodeId !== null && !isSelected - // Smooth opacity: interpolate between 1 (full) and 0.1 (dimmed) based on animation progress - const nodeOpacity = shouldDim ? 1 - dimProgress.current * 0.9 : 1 - const isHighlightedDocument = (() => { - if (node.type !== "document" || highlightSet.size === 0) return false - const doc = node.data as DocumentWithMemories - if (doc.customId && highlightSet.has(doc.customId)) return true - return highlightSet.has(doc.id) - })() - - if (node.type === "document") { - // Enhanced glassmorphism document styling - const docWidth = nodeSize * 1.4 - const docHeight = nodeSize * 0.9 - - // Multi-layer glass effect - ctx.fillStyle = isDragging - ? colors.document.accent - : isHovered - ? colors.document.secondary - : colors.document.primary - ctx.globalAlpha = nodeOpacity - - // Enhanced border with subtle glow - ctx.strokeStyle = isDragging - ? colors.document.glow - : isHovered - ? colors.document.accent - : colors.document.border - ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1 - - // Rounded rectangle with enhanced styling - const radius = useSimplifiedRendering ? 6 : 12 - ctx.beginPath() - ctx.roundRect( - screenX - docWidth / 2, - screenY - docHeight / 2, - docWidth, - docHeight, - radius, - ) - ctx.fill() - ctx.stroke() - - // Subtle inner highlight for glass effect (skip when zoomed out) - if (!useSimplifiedRendering && (isHovered || isDragging)) { - ctx.strokeStyle = "rgba(255, 255, 255, 0.1)" - ctx.lineWidth = 1 - ctx.beginPath() - ctx.roundRect( - screenX - docWidth / 2 + 1, - screenY - docHeight / 2 + 1, - docWidth - 2, - docHeight - 2, - radius - 1, - ) - ctx.stroke() - } - - // Highlight ring for search hits - if (isHighlightedDocument) { - ctx.save() - ctx.globalAlpha = 0.9 - ctx.strokeStyle = colors.accent.primary - ctx.lineWidth = 3 - ctx.setLineDash([6, 4]) - // Add equal padding on all sides (15% of average dimension) - const avgDimension = (docWidth + docHeight) / 2 - const ringPadding = avgDimension * 0.1 - 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() - } - - // Draw document type icon (centered) - if (!useSimplifiedRendering) { - const doc = node.data as DocumentWithMemories - const iconSize = docHeight * 0.4 // Icon size relative to card height - - drawDocumentIcon( - ctx, - screenX, - screenY, - iconSize, - doc.type || "text", - "rgba(255, 255, 255, 0.8)", - ) - } - } else { - // Enhanced memory styling with status indicators - const mem = node.data as MemoryEntry - const isForgotten = - mem.isForgotten || - (mem.forgetAfter && - new Date(mem.forgetAfter).getTime() < Date.now()) - const isLatest = mem.isLatest - - // Check if memory is expiring soon (within 7 days) - const expiringSoon = - mem.forgetAfter && - !isForgotten && - new Date(mem.forgetAfter).getTime() - Date.now() < - 1000 * 60 * 60 * 24 * 7 - - // Check if memory is new (created within last 24 hours) - const isNew = - !isForgotten && - new Date(mem.createdAt).getTime() > Date.now() - 1000 * 60 * 60 * 24 - - // Determine colors based on status - let fillColor = colors.memory.primary - let borderColor = colors.memory.border - let glowColor = colors.memory.glow - - if (isForgotten) { - fillColor = colors.status.forgotten - borderColor = "rgba(220,38,38,0.3)" - glowColor = "rgba(220,38,38,0.2)" - } else if (expiringSoon) { - borderColor = colors.status.expiring - glowColor = colors.accent.amber - } else if (isNew) { - borderColor = colors.status.new - glowColor = colors.accent.emerald - } - - if (isDragging) { - fillColor = colors.memory.accent - borderColor = glowColor - } else if (isHovered) { - fillColor = colors.memory.secondary - } - - const radius = nodeSize / 2 - - ctx.fillStyle = fillColor - ctx.globalAlpha = shouldDim ? nodeOpacity : isLatest ? 1 : 0.4 - ctx.strokeStyle = borderColor - ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5 - - if (useSimplifiedRendering) { - // Simple circles when zoomed out for performance - ctx.beginPath() - ctx.arc(screenX, screenY, radius, 0, 2 * Math.PI) - ctx.fill() - ctx.stroke() - } else { - // HEXAGONAL memory nodes when zoomed in - const sides = 6 - ctx.beginPath() - for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 // Start from top - const x = screenX + radius * Math.cos(angle) - const y = screenY + radius * Math.sin(angle) - if (i === 0) { - ctx.moveTo(x, y) - } else { - ctx.lineTo(x, y) - } - } - ctx.closePath() - ctx.fill() - ctx.stroke() - - // Inner highlight for glass effect - if (isHovered || isDragging) { - ctx.strokeStyle = "rgba(147, 197, 253, 0.3)" - ctx.lineWidth = 1 - const innerRadius = radius - 2 - ctx.beginPath() - for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 - const x = screenX + innerRadius * Math.cos(angle) - const y = screenY + innerRadius * Math.sin(angle) - if (i === 0) { - ctx.moveTo(x, y) - } else { - ctx.lineTo(x, y) - } - } - ctx.closePath() - ctx.stroke() - } - } - - // Status indicators overlay (always preserve these as required) - if (isForgotten) { - // Cross for forgotten memories - ctx.strokeStyle = "rgba(220,38,38,0.4)" - ctx.lineWidth = 2 - const r = nodeSize * 0.25 - ctx.beginPath() - ctx.moveTo(screenX - r, screenY - r) - ctx.lineTo(screenX + r, screenY + r) - ctx.moveTo(screenX + r, screenY - r) - ctx.lineTo(screenX - r, screenY + r) - ctx.stroke() - } else if (isNew) { - // Small dot for new memories - ctx.fillStyle = colors.status.new - ctx.beginPath() - ctx.arc( - screenX + nodeSize * 0.25, - screenY - nodeSize * 0.25, - Math.max(2, nodeSize * 0.15), // Scale with node size, minimum 2px - 0, - 2 * Math.PI, - ) - ctx.fill() - } - } - - // Enhanced hover glow effect (skip when zoomed out for performance) - if (!useSimplifiedRendering && (isHovered || isDragging)) { - const glowColor = - node.type === "document" ? colors.document.glow : colors.memory.glow - - ctx.strokeStyle = glowColor - ctx.lineWidth = 1 - ctx.setLineDash([3, 3]) - ctx.globalAlpha = 0.6 - - ctx.beginPath() - if (node.type === "document") { - // Use actual document dimensions for glow - const docWidth = nodeSize * 1.4 - const docHeight = nodeSize * 0.9 - // Make glow 10% larger than document - const avgDimension = (docWidth + docHeight) / 2 - const glowPadding = avgDimension * 0.1 - ctx.roundRect( - screenX - docWidth / 2 - glowPadding, - screenY - docHeight / 2 - glowPadding, - docWidth + glowPadding * 2, - docHeight + glowPadding * 2, - 15, - ) - } else { - // Hexagonal glow for memory nodes - const glowRadius = nodeSize * 0.7 - const sides = 6 - for (let i = 0; i < sides; i++) { - const angle = (i * 2 * Math.PI) / sides - Math.PI / 2 - const x = screenX + glowRadius * Math.cos(angle) - const y = screenY + glowRadius * Math.sin(angle) - if (i === 0) { - ctx.moveTo(x, y) - } else { - ctx.lineTo(x, y) - } - } - ctx.closePath() - } - ctx.stroke() - ctx.setLineDash([]) - } - }) - - ctx.globalAlpha = 1 - }, [ - nodes, - edges, - panX, - panY, - zoom, - width, - height, - highlightDocumentIds, - nodeMap, - ]) - - // Hybrid rendering: continuous when simulation active, change-based when idle - const lastRenderParams = useRef(0) - - // Create a render key that changes when visual state changes - // Optimized: use cheap hash instead of building long strings - const renderKey = useMemo(() => { - // Hash node positions to a single number (cheaper than string concatenation) - const positionHash = nodes.reduce((hash, n) => { - // Round to 1 decimal to avoid unnecessary re-renders from tiny movements - const x = Math.round(n.x * 10) - const y = Math.round(n.y * 10) - const dragging = n.isDragging ? 1 : 0 - const hovered = currentHoveredNode.current === n.id ? 1 : 0 - // Simple XOR hash (fast and sufficient for change detection) - return hash ^ (x + y + dragging + hovered) - }, 0) - - const highlightHash = (highlightDocumentIds ?? []).reduce((hash, id) => { - return hash ^ id.length - }, 0) - - // Combine all factors into a single number - return ( - positionHash ^ - edges.length ^ - Math.round(panX) ^ - Math.round(panY) ^ - Math.round(zoom * 100) ^ - width ^ - height ^ - highlightHash + // Skip frame if nothing changed + if ( + !vpMoving && + !simActive && + !dimming && + !spatialChanged && + !renderNeeded.current ) - }, [ - nodes, - edges.length, - panX, - panY, - zoom, - width, - height, - highlightDocumentIds, - ]) + return + renderNeeded.current = false - // Render based on simulation state - useEffect(() => { - if (isSimulationActive) { - // Continuous rendering during physics simulation - const renderLoop = () => { - render() - animationRef.current = requestAnimationFrame(renderLoop) - } - renderLoop() - - return () => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current) - } + // Report viewport changes so zoom display and popover positions update. + const vpChanged = + vp.panX !== prevVpX || vp.panY !== prevVpY || vp.zoom !== prevVpZoom + if ((vpChanged || simActive) && cb.current.onViewportChange) { + cb.current.onViewportChange( + vp.zoom, + !!(cur.selectedNodeId || cur.hoveredNodeId), + ) + } + if (vpChanged) { + prevVpX = vp.panX + prevVpY = vp.panY + prevVpZoom = vp.zoom + } + + renderFrame( + ctx, + cur.nodes, + cur.edges, + vp, + cur.width, + cur.height, + { + selectedNodeId: cur.selectedNodeId, + hoveredNodeId: cur.hoveredNodeId, + highlightIds: cur.highlightIds, + dimProgress: cur.dimProgress, + }, + nodeMapRef.current, + cur.colors, + ) + + // FPS counter overlay + if (showFpsRef.current) { + const now = performance.now() + fpsFrames.current++ + if (now - fpsLastTime.current >= 1000) { + fpsValue.current = fpsFrames.current + fpsFrames.current = 0 + fpsLastTime.current = now } + ctx.save() + ctx.resetTransform() + // Scale for DPR + const d = Math.min( + 16384 / cur.width, + 16384 / cur.height, + dprRef.current, + ) + ctx.scale(d, d) + ctx.fillStyle = "rgba(0,0,0,0.7)" + ctx.fillRect(8, 8, 140, 52) + ctx.fillStyle = "#00ff00" + ctx.font = "bold 14px monospace" + ctx.fillText(`FPS: ${fpsValue.current}`, 16, 28) + ctx.fillStyle = "#ffffff" + ctx.font = "11px monospace" + ctx.fillText(`Nodes: ${cur.nodes.length}`, 16, 44) + ctx.fillText(`Edges: ${cur.edges.length}`, 16, 56) + ctx.restore() } - // Change-based rendering when simulation is idle - if (renderKey !== lastRenderParams.current) { - lastRenderParams.current = renderKey - render() - } - }, [isSimulationActive, renderKey, render]) + } - // Cleanup any existing animation frames - useEffect(() => { - return () => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current) - } - } - }, []) + rafRef.current = requestAnimationFrame(tick) + return () => cancelAnimationFrame(rafRef.current) + }, []) - // Add native wheel event listener to prevent browser zoom - useEffect(() => { - const canvas = canvasRef.current - if (!canvas) return + const canvasStyle: React.CSSProperties = { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + touchAction: "none", + userSelect: "none", + } - const handleNativeWheel = (e: WheelEvent) => { - e.preventDefault() - e.stopPropagation() - - // Call the onWheel handler with a synthetic-like event - // @ts-expect-error - partial WheelEvent object - onWheel({ - deltaY: e.deltaY, - deltaX: e.deltaX, - clientX: e.clientX, - clientY: e.clientY, - currentTarget: canvas, - nativeEvent: e, - preventDefault: () => {}, - stopPropagation: () => {}, - } as React.WheelEvent) - } - - // Add listener with passive: false to ensure preventDefault works - canvas.addEventListener("wheel", handleNativeWheel, { passive: false }) - - // Also prevent gesture events for touch devices - const handleGesture = (e: Event) => { - e.preventDefault() - } - - canvas.addEventListener("gesturestart", handleGesture, { - passive: false, - }) - canvas.addEventListener("gesturechange", handleGesture, { - passive: false, - }) - canvas.addEventListener("gestureend", handleGesture, { passive: false }) - - return () => { - canvas.removeEventListener("wheel", handleNativeWheel) - canvas.removeEventListener("gesturestart", handleGesture) - canvas.removeEventListener("gesturechange", handleGesture) - canvas.removeEventListener("gestureend", handleGesture) - } - }, [onWheel]) - - // High-DPI handling -------------------------------------------------- - const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1 - - useLayoutEffect(() => { - const canvas = canvasRef.current - if (!canvas) return - - // Maximum safe canvas size (most browsers support up to 16384px) - const MAX_CANVAS_SIZE = 16384 - - // Calculate effective DPR that keeps us within safe limits - // Prevent division by zero by checking for valid dimensions - const maxDpr = - width > 0 && height > 0 - ? Math.min(MAX_CANVAS_SIZE / width, MAX_CANVAS_SIZE / height, dpr) - : dpr - - // upscale backing store with clamped dimensions - canvas.style.width = `${width}px` - canvas.style.height = `${height}px` - canvas.width = Math.min(width * maxDpr, MAX_CANVAS_SIZE) - canvas.height = Math.min(height * maxDpr, MAX_CANVAS_SIZE) - - const ctx = canvas.getContext("2d") - ctx?.scale(maxDpr, maxDpr) - }, [width, height, dpr]) - // ----------------------------------------------------------------------- - - return ( - { - if (draggingNodeId) { - onNodeDragEnd() - } else { - onPanEnd() - } - }} - onMouseMove={(e) => { - handleMouseMove(e) - if (!draggingNodeId) { - onPanMove(e) - } - }} - onMouseUp={() => { - if (draggingNodeId) { - onNodeDragEnd() - } else { - onPanEnd() - } - }} - onTouchStart={onTouchStart} - onTouchMove={onTouchMove} - onTouchEnd={onTouchEnd} - ref={canvasRef} - style={{ - cursor: draggingNodeId - ? "grabbing" - : currentHoveredNode.current - ? "grab" - : "move", - touchAction: "none", - userSelect: "none", - WebkitUserSelect: "none", - }} - /> - ) - }, -) - -GraphCanvas.displayName = "GraphCanvas" + return +}) diff --git a/packages/memory-graph/src/components/legend.css.ts b/packages/memory-graph/src/components/legend.css.ts deleted file mode 100644 index 120afa49..00000000 --- a/packages/memory-graph/src/components/legend.css.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { style, styleVariants, globalStyle } from "@vanilla-extract/css" -import { themeContract } from "../styles/theme.css" - -/** - * Legend container base - */ -const legendContainerBase = style({ - position: "absolute", - zIndex: 20, // Above most elements but below node detail panel - borderRadius: themeContract.radii.xl, - overflow: "hidden", - width: "fit-content", - height: "fit-content", - maxHeight: "calc(100vh - 2rem)", // Prevent overflow -}) - -/** - * Legend container variants for positioning - * Console: Bottom-right (doesn't conflict with anything) - * Consumer: Bottom-right (moved from top to avoid conflicts) - */ -export const legendContainer = styleVariants({ - consoleDesktop: [ - legendContainerBase, - { - bottom: themeContract.space[4], - right: themeContract.space[4], - }, - ], - consoleMobile: [ - legendContainerBase, - { - bottom: themeContract.space[4], - right: themeContract.space[4], - "@media": { - "screen and (max-width: 767px)": { - display: "none", - }, - }, - }, - ], - consumerDesktop: [ - legendContainerBase, - { - // Changed from top to bottom to avoid overlap with node detail panel - bottom: themeContract.space[4], - right: themeContract.space[4], - }, - ], - consumerMobile: [ - legendContainerBase, - { - bottom: themeContract.space[4], - right: themeContract.space[4], - "@media": { - "screen and (max-width: 767px)": { - display: "none", - }, - }, - }, - ], -}) - -/** - * Mobile size variants - */ -export const mobileSize = styleVariants({ - expanded: { - maxWidth: "20rem", // max-w-xs - }, - collapsed: { - width: "4rem", // w-16 - height: "3rem", // h-12 - }, -}) - -/** - * Legend content wrapper - */ -export const legendContent = style({ - position: "relative", - zIndex: 10, -}) - -/** - * Collapsed trigger button - */ -export const collapsedTrigger = style({ - width: "100%", - height: "100%", - padding: themeContract.space[2], - display: "flex", - alignItems: "center", - justifyContent: "center", - transition: themeContract.transitions.normal, - - selectors: { - "&:hover": { - backgroundColor: "rgba(255, 255, 255, 0.05)", - }, - }, -}) - -export const collapsedContent = style({ - display: "flex", - flexDirection: "column", - alignItems: "center", - gap: themeContract.space[1], -}) - -export const collapsedText = style({ - fontSize: themeContract.typography.fontSize.xs, - color: themeContract.colors.text.secondary, - fontWeight: themeContract.typography.fontWeight.medium, -}) - -export const collapsedIcon = style({ - width: "0.75rem", - height: "0.75rem", - color: themeContract.colors.text.muted, -}) - -/** - * Header - */ -export const legendHeader = style({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - paddingLeft: themeContract.space[4], - paddingRight: themeContract.space[4], - paddingTop: themeContract.space[3], - paddingBottom: themeContract.space[3], - borderBottom: "1px solid rgba(71, 85, 105, 0.5)", // slate-600/50 -}) - -export const legendTitle = style({ - fontSize: themeContract.typography.fontSize.sm, - fontWeight: themeContract.typography.fontWeight.medium, - color: themeContract.colors.text.primary, -}) - -export const headerTrigger = style({ - padding: themeContract.space[1], - borderRadius: themeContract.radii.sm, - transition: themeContract.transitions.normal, - - selectors: { - "&:hover": { - backgroundColor: "rgba(255, 255, 255, 0.1)", - }, - }, -}) - -export const headerIcon = style({ - width: "1rem", - height: "1rem", - color: themeContract.colors.text.muted, -}) - -/** - * Content sections - */ -export const sectionsContainer = style({ - fontSize: themeContract.typography.fontSize.xs, - color: themeContract.colors.text.secondary, - paddingLeft: themeContract.space[4], - paddingRight: themeContract.space[4], - paddingTop: themeContract.space[3], - paddingBottom: themeContract.space[3], -}) - -export const sectionWrapper = style({ - marginTop: themeContract.space[3], - selectors: { - "&:first-child": { - marginTop: 0, - }, - }, -}) - -export const sectionTitle = style({ - fontSize: themeContract.typography.fontSize.xs, - fontWeight: themeContract.typography.fontWeight.medium, - color: themeContract.colors.text.secondary, - textTransform: "uppercase", - letterSpacing: "0.05em", - marginBottom: themeContract.space[2], -}) - -export const itemsList = style({ - display: "flex", - flexDirection: "column", - gap: "0.375rem", // gap-1.5 -}) - -export const legendItem = style({ - display: "flex", - alignItems: "center", - gap: themeContract.space[2], -}) - -export const legendIcon = style({ - width: "0.75rem", - height: "0.75rem", - flexShrink: 0, -}) - -export const legendText = style({ - fontSize: themeContract.typography.fontSize.xs, -}) - -/** - * Shape styles - */ -export const documentNode = style({ - width: "1rem", - height: "0.75rem", - background: "rgba(255, 255, 255, 0.21)", - border: "1px solid rgba(255, 255, 255, 0.6)", - borderRadius: themeContract.radii.sm, - flexShrink: 0, -}) - -// Hexagon shapes using SVG background (matching graph's flat-top hexagon) -// Points calculated: angle = (i * 2π / 6) - π/2, center (6,6), radius 4.5 -const hexagonPoints = "6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75" - -export const memoryNode = style({ - width: "1rem", - height: "1rem", - flexShrink: 0, - backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(147,197,253,0.21)' stroke='rgba(147,196,253,0.6)' stroke-width='1'/%3E%3C/svg%3E")`, - backgroundSize: "contain", - backgroundRepeat: "no-repeat", -}) - -export const memoryNodeOlder = style({ - opacity: 0.4, - width: "1rem", - height: "1rem", - flexShrink: 0, - backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(147,197,253,0.21)' stroke='rgba(147,196,253,0.6)' stroke-width='1'/%3E%3C/svg%3E")`, - backgroundSize: "contain", - backgroundRepeat: "no-repeat", -}) - -export const forgottenNode = style({ - width: "1rem", - height: "1rem", - flexShrink: 0, - position: "relative", - backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(239,68,68,0.3)' stroke='rgba(239,68,68,0.8)' stroke-width='1'/%3E%3C/svg%3E")`, - backgroundSize: "contain", - backgroundRepeat: "no-repeat", -}) - -export const expiringNode = style({ - width: "1rem", - height: "1rem", - flexShrink: 0, - backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(147,197,253,0.1)' stroke='rgb(245,158,11)' stroke-width='1.5'/%3E%3C/svg%3E")`, - backgroundSize: "contain", - backgroundRepeat: "no-repeat", -}) - -export const newNode = style({ - width: "1rem", - height: "1rem", - flexShrink: 0, - position: "relative", - backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpolygon points='${hexagonPoints}' fill='rgba(147,197,253,0.1)' stroke='rgb(16,185,129)' stroke-width='1.5'/%3E%3C/svg%3E")`, - backgroundSize: "contain", - backgroundRepeat: "no-repeat", -}) - -export const forgottenIcon = style({ - position: "absolute", - inset: 0, - display: "flex", - alignItems: "center", - justifyContent: "center", - color: "rgb(248, 113, 113)", - fontSize: themeContract.typography.fontSize.xs, - lineHeight: "1", - pointerEvents: "none", -}) - -export const newBadge = style({ - position: "absolute", - top: "-0.25rem", - right: "-0.25rem", - width: "0.5rem", - height: "0.5rem", - backgroundColor: "rgb(16, 185, 129)", - borderRadius: themeContract.radii.full, -}) - -export const connectionLine = style({ - width: "1rem", - height: 0, - borderTop: "1px solid rgb(148, 163, 184, 0.5)", - flexShrink: 0, -}) - -export const similarityLine = style({ - width: "1rem", - height: 0, - borderTop: "2px dashed rgba(35, 189, 255, 0.6)", - flexShrink: 0, -}) - -export const relationLine = style({ - width: "1rem", - height: 0, - borderTop: "2px solid", - flexShrink: 0, -}) - -export const weakSimilarity = style({ - width: "0.75rem", - height: "0.75rem", - borderRadius: themeContract.radii.full, - background: "rgba(79, 255, 226, 0.3)", - flexShrink: 0, -}) - -export const strongSimilarity = style({ - width: "0.75rem", - height: "0.75rem", - borderRadius: themeContract.radii.full, - background: "rgba(79, 255, 226, 0.7)", - flexShrink: 0, -}) - -export const gradientCircle = style({ - width: "0.75rem", - height: "0.75rem", - background: - "linear-gradient(to right, rgb(148, 163, 184), rgb(96, 165, 250))", - borderRadius: themeContract.radii.full, -}) diff --git a/packages/memory-graph/src/components/legend.tsx b/packages/memory-graph/src/components/legend.tsx index db06da10..0290226e 100644 --- a/packages/memory-graph/src/components/legend.tsx +++ b/packages/memory-graph/src/components/legend.tsx @@ -1,276 +1,442 @@ -"use client" +import { memo, useState } from "react" +import type { GraphEdge, GraphNode, GraphThemeColors } from "../types" -import { useIsMobile } from "@/hooks/use-mobile" -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/ui/collapsible" -import { GlassMenuEffect } from "@/ui/glass-effect" -import { Brain, ChevronDown, ChevronUp, FileText } from "lucide-react" -import { memo, useEffect, useState } from "react" -import { colors } from "@/constants" -import type { GraphEdge, GraphNode, LegendProps } from "@/types" -import * as styles from "./legend.css" - -// Cookie utility functions for legend state -const setCookie = (name: string, value: string, days = 365) => { - if (typeof document === "undefined") return - const expires = new Date() - expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000) - document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/` -} - -const getCookie = (name: string): string | null => { - if (typeof document === "undefined") return null - const nameEQ = `${name}=` - const ca = document.cookie.split(";") - for (let i = 0; i < ca.length; i++) { - let c = ca[i] - if (!c) continue - while (c.charAt(0) === " ") c = c.substring(1, c.length) - if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length) - } - return null -} - -interface ExtendedLegendProps extends LegendProps { - id?: string +interface LegendProps { nodes?: GraphNode[] edges?: GraphEdge[] isLoading?: boolean + colors: GraphThemeColors +} + +function HexagonIcon({ + fill, + stroke, + size = 12, +}: { + fill: string + stroke: string + size?: number +}) { + return ( + + ) +} + +function LineIcon({ + color, + dashed = false, +}: { + color: string + dashed?: boolean +}) { + return ( +
+
+
+ ) +} + +function ChevronDownIcon({ color }: { color: string }) { + return ( + + ) +} + +function ChevronRightIcon({ color }: { color: string }) { + return ( + + ) +} + +function StatRow({ + icon, + label, + count, + expandable = false, + expanded = false, + onToggle, + children, + colors, +}: { + icon: React.ReactNode + label: string + count: number + expandable?: boolean + expanded?: boolean + onToggle?: () => void + children?: React.ReactNode + colors: GraphThemeColors +}) { + const buttonStyle: React.CSSProperties = { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + width: "100%", + padding: 0, + outline: "none", + background: "none", + border: "none", + cursor: expandable ? "pointer" : "default", + } + + const leftStyle: React.CSSProperties = { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 8, + } + + const labelStyle: React.CSSProperties = { + fontSize: 12, + color: colors.textPrimary, + fontWeight: 400, + } + + const countStyle: React.CSSProperties = { + fontSize: 12, + color: colors.textMuted, + } + + const childrenContainerStyle: React.CSSProperties = { + paddingLeft: 10, + paddingTop: 6, + display: "flex", + flexDirection: "column", + gap: 6, + } + + return ( +
+ + {expandable && expanded && children && ( +
{children}
+ )} +
+ ) } export const Legend = memo(function Legend({ - variant = "console", - id, nodes = [], edges = [], - isLoading = false, -}: ExtendedLegendProps) { - const isMobile = useIsMobile() - const [isExpanded, setIsExpanded] = useState(true) - const [isInitialized, setIsInitialized] = useState(false) + isLoading: _isLoading = false, + colors, +}: LegendProps) { + const [isExpanded, setIsExpanded] = useState(false) + const [connectionsExpanded, setConnectionsExpanded] = useState(true) - // Load saved preference on client side - useEffect(() => { - if (!isInitialized) { - const savedState = getCookie("legendCollapsed") - if (savedState === "true") { - setIsExpanded(false) - } else if (savedState === "false") { - setIsExpanded(true) - } else { - // Default: collapsed on mobile, expanded on desktop - setIsExpanded(!isMobile) - } - setIsInitialized(true) - } - }, [isInitialized, isMobile]) - - // Save to cookie when state changes - const handleToggleExpanded = (expanded: boolean) => { - setIsExpanded(expanded) - setCookie("legendCollapsed", expanded ? "false" : "true") - } - - // Get container class based on variant and mobile state - const getContainerClass = () => { - if (variant === "console") { - return isMobile - ? styles.legendContainer.consoleMobile - : styles.legendContainer.consoleDesktop - } - return isMobile - ? styles.legendContainer.consumerMobile - : styles.legendContainer.consumerDesktop - } - - // Calculate stats const memoryCount = nodes.filter((n) => n.type === "memory").length const documentCount = nodes.filter((n) => n.type === "document").length + const connectionCount = edges.length - const containerClass = - isMobile && !isExpanded - ? `${getContainerClass()} ${styles.mobileSize.collapsed}` - : isMobile - ? `${getContainerClass()} ${styles.mobileSize.expanded}` - : getContainerClass() + const outerStyle: React.CSSProperties = { + position: "absolute", + zIndex: 20, + overflow: "hidden", + bottom: 16, + left: 16, + width: 214, + } + + const cardStyle: React.CSSProperties = { + borderRadius: 12, + backgroundColor: colors.controlBg, + border: `1px solid ${colors.controlBorder}`, + boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)", + } + + const headerBtnStyle: React.CSSProperties = { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 6, + width: "100%", + cursor: "pointer", + outline: "none", + background: "none", + border: "none", + padding: 0, + } + + const headerTextStyle: React.CSSProperties = { + fontSize: 14, + color: colors.textPrimary, + fontWeight: 400, + } + + const sectionLabelStyle: React.CSSProperties = { + fontSize: 12, + color: colors.textMuted, + fontWeight: 400, + textTransform: "uppercase", + letterSpacing: "0.05em", + } + + const rowStyle: React.CSSProperties = { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + } + + const rowLeftStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 8, + } + + const edgeLabelStyle: React.CSSProperties = { + fontSize: 12, + color: colors.textPrimary, + } + + const statusRowStyle: React.CSSProperties = { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 8, + } return ( -
- - {/* Glass effect background */} - +
+
+
+ -
- {/* Mobile and Desktop collapsed state */} - {!isExpanded && ( - -
-
?
- -
-
- )} - - {/* Expanded state */} {isExpanded && ( - <> - {/* Header with toggle */} -
-
Legend
- - - +
+ {/* Statistics section */} +
+ Statistics +
+ + } + label="Memories" + colors={colors} + /> + + } + label="Documents" + colors={colors} + /> + +
- -
- {/* Stats Section */} - {!isLoading && ( -
-
Statistics
-
-
- - - {memoryCount} memories - -
-
- - - {documentCount} documents - -
-
-
- - {edges.length} connections - -
-
-
- )} - - {/* Node Types */} -
-
Nodes
-
-
-
- Document -
-
-
- - Memory (latest) - -
-
-
- - Memory (older) - -
-
+ {/* Memory Status section */} +
+ Memory Status +
+
+ + Recent (< 24h)
- - {/* Status Indicators */} -
-
Status
-
-
-
-
✕
-
- Forgotten -
-
-
- Expiring soon -
-
-
-
-
- New memory -
-
+
+ + Expiring soon
- - {/* Connection Types */} -
-
Connections
-
-
-
- Doc → Memory -
-
-
- - Doc similarity - -
-
-
- - {/* Relation Types */} -
-
Relations
-
- {[ - ["updates", colors.relations.updates], - ["extends", colors.relations.extends], - ["derives", colors.relations.derives], - ].map(([label, color]) => ( -
-
- - {label} - -
- ))} -
-
- - {/* Similarity Strength */} -
-
Similarity
-
-
-
- Weak -
-
-
- Strong -
-
+
+ + Forgotten
- - +
+
)}
- +
) }) diff --git a/packages/memory-graph/src/components/loading-indicator.css.ts b/packages/memory-graph/src/components/loading-indicator.css.ts deleted file mode 100644 index 4aac7cfd..00000000 --- a/packages/memory-graph/src/components/loading-indicator.css.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { style } from "@vanilla-extract/css" -import { themeContract } from "../styles/theme.css" -import { animations } from "../styles" - -/** - * Loading indicator container - * Positioned top-left, below spaces dropdown - */ -export const loadingContainer = style({ - position: "absolute", - zIndex: 30, // High priority so it's visible when loading - borderRadius: themeContract.radii.xl, - overflow: "hidden", - top: "5.5rem", // Below spaces dropdown (~88px) - left: themeContract.space[4], -}) - -/** - * Content wrapper - */ -export const loadingContent = style({ - position: "relative", - zIndex: 10, - color: themeContract.colors.text.secondary, - paddingLeft: themeContract.space[4], - paddingRight: themeContract.space[4], - paddingTop: themeContract.space[3], - paddingBottom: themeContract.space[3], -}) - -/** - * Flex container for icon and text - */ -export const loadingFlex = style({ - display: "flex", - alignItems: "center", - gap: themeContract.space[2], -}) - -/** - * Spinning icon - */ -export const loadingIcon = style({ - width: "1rem", - height: "1rem", - animation: `${animations.spin} 1s linear infinite`, - color: themeContract.colors.memory.border, -}) - -/** - * Loading text - */ -export const loadingText = style({ - fontSize: themeContract.typography.fontSize.sm, -}) diff --git a/packages/memory-graph/src/components/loading-indicator.tsx b/packages/memory-graph/src/components/loading-indicator.tsx index bbb2312c..37ddc82e 100644 --- a/packages/memory-graph/src/components/loading-indicator.tsx +++ b/packages/memory-graph/src/components/loading-indicator.tsx @@ -1,40 +1,87 @@ -"use client" - -import { GlassMenuEffect } from "@/ui/glass-effect" -import { Sparkles } from "lucide-react" import { memo } from "react" -import type { LoadingIndicatorProps } from "@/types" -import { - loadingContainer, - loadingContent, - loadingFlex, - loadingIcon, - loadingText, -} from "./loading-indicator.css" +import type { GraphThemeColors, LoadingIndicatorProps } from "../types" -export const LoadingIndicator = memo( - ({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => { - if (!isLoading && !isLoadingMore) return null +const spinKeyframes = ` +@keyframes mg-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +` - return ( -
- {/* Glass effect background */} - +let styleInjected = false +function injectSpinStyle() { + if (styleInjected || typeof document === "undefined") return + const style = document.createElement("style") + style.textContent = spinKeyframes + document.head.appendChild(style) + styleInjected = true +} -
-
- {/*@ts-ignore */} - - - {isLoading - ? "Loading memory graph..." - : `Loading more documents... (${totalLoaded})`} - -
-
+export const LoadingIndicator = memo< + LoadingIndicatorProps & { colors?: GraphThemeColors } +>(({ isLoading, isLoadingMore, totalLoaded, colors }) => { + if (!isLoading && !isLoadingMore) return null + + injectSpinStyle() + + const containerStyle: React.CSSProperties = { + position: "absolute", + zIndex: 30, + overflow: "hidden", + top: 16, + left: 16, + borderRadius: 12, + border: `1px solid ${colors?.controlBorder ?? "#2A2F36"}`, + backgroundColor: colors?.controlBg ?? "#1a1f29", + paddingLeft: 16, + paddingRight: 16, + paddingTop: 12, + paddingBottom: 12, + boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)", + } + + const flexStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 8, + } + + const spinnerStyle: React.CSSProperties = { + width: 16, + height: 16, + animation: "mg-spin 1s linear infinite", + color: colors?.accent ?? "#3B73B8", + flexShrink: 0, + } + + const textStyle: React.CSSProperties = { + fontSize: 14, + color: colors?.textSecondary ?? "#e2e8f0", + } + + return ( +
+
+ + + {isLoading + ? "Loading memory graph..." + : `Loading more documents... (${totalLoaded})`} +
- ) - }, -) +
+ ) +}) 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 deleted file mode 100644 index 1726b741..00000000 --- a/packages/memory-graph/src/components/memory-graph.css.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { style } from "@vanilla-extract/css" -import { themeContract } from "../styles/theme.css" - -/** - * Error state container - */ -export const errorContainer = style({ - height: "100%", - display: "flex", - alignItems: "center", - justifyContent: "center", - backgroundColor: themeContract.colors.background.primary, -}) - -export const errorCard = style({ - borderRadius: themeContract.radii.xl, - overflow: "hidden", -}) - -export const errorContent = style({ - position: "relative", - zIndex: 10, - color: themeContract.colors.text.secondary, - paddingLeft: themeContract.space[6], - paddingRight: themeContract.space[6], - paddingTop: themeContract.space[4], - paddingBottom: themeContract.space[4], -}) - -/** - * Main graph container - * Position relative so absolutely positioned children position relative to this container - */ -export const mainContainer = style({ - position: "relative", - height: "100%", - borderRadius: themeContract.radii.xl, - overflow: "hidden", - backgroundColor: themeContract.colors.background.primary, -}) - -/** - * Spaces selector positioning - * Top-left corner, below most overlays - */ -export const spacesSelectorContainer = style({ - position: "absolute", - top: themeContract.space[4], - left: themeContract.space[4], - zIndex: 15, // Above base elements, below loading/panels -}) - -/** - * Graph canvas container - */ -export const graphContainer = style({ - width: "100%", - height: "100%", - position: "relative", - overflow: "hidden", - touchAction: "none", - userSelect: "none", - WebkitUserSelect: "none", -}) - -/** - * Navigation controls positioning - * Bottom-left corner - */ -export const navControlsContainer = style({ - position: "absolute", - bottom: themeContract.space[4], - left: themeContract.space[4], - zIndex: 15, // Same level as spaces dropdown -}) diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index a6c16a2e..0602ce43 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -1,795 +1,671 @@ -"use client" - -import { GlassMenuEffect } from "@/ui/glass-effect" -import { AnimatePresence } from "motion/react" -import { - useCallback, - useEffect, - useMemo, - useReducer, - useRef, - useState, -} from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { ForceSimulation } from "../canvas/simulation" +import { VersionChainIndex } from "../canvas/version-chain" +import type { ViewportState } from "../canvas/viewport" +import { useGraphData } from "../hooks/use-graph-data" +import { useGraphTheme } from "../hooks/use-graph-theme" +import type { GraphThemeColors, MemoryGraphProps } from "../types" import { GraphCanvas } from "./graph-canvas" -import { useGraphData } from "@/hooks/use-graph-data" -import { useGraphInteractions } from "@/hooks/use-graph-interactions" -import { useForceSimulation } from "@/hooks/use-force-simulation" -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 { NodePopover } from "./node-popover" -import { SpacesDropdown } from "./spaces-dropdown" -import * as styles from "./memory-graph.css" -import { defaultTheme } from "@/styles/theme.css" +import { NodeHoverPopover } from "./node-hover-popover" -import type { MemoryGraphProps } from "@/types" - -export const MemoryGraph = ({ - children, - documents, - isLoading = false, +export function MemoryGraph({ + documents = [], + isLoading: externalIsLoading = false, isLoadingMore = false, - error = null, - totalLoaded, + onLoadMore, hasMore = false, - loadMoreDocuments, - showSpacesSelector, + error: externalError = null, + children, variant = "console", - legendId, highlightDocumentIds = [], highlightsVisible = true, - occludedRightPx = 0, - autoLoadOnViewport = true, - themeClassName, - selectedSpace: externalSelectedSpace, - onSpaceChange: externalOnSpaceChange, - memoryLimit, + showFps = false, maxNodes, - isExperimental, - // Slideshow control isSlideshowActive = false, onSlideshowNodeChange, - onSlideshowStop, -}: MemoryGraphProps) => { - // Inject styles on first render (client-side only) - useEffect(() => { - injectStyles() - }, []) - - // Derive totalLoaded from documents if not provided - const effectiveTotalLoaded = totalLoaded ?? documents.length - // No-op for loadMoreDocuments if not provided - 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" - - // Internal state for controlled/uncontrolled pattern - const [internalSelectedSpace, setInternalSelectedSpace] = - useState("all") + canvasRef: externalCanvasRef, + colors: colorOverrides, + totalCount, +}: MemoryGraphProps) { + const resolvedColors = useGraphTheme(colorOverrides) + const colors = useMemo( + () => + colorOverrides + ? { ...resolvedColors, ...colorOverrides } + : resolvedColors, + [resolvedColors, colorOverrides], + ) const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) + const [containerBounds, setContainerBounds] = useState(null) const containerRef = useRef(null) + const viewportRef = useRef(null) + const simulationRef = useRef(null) + const [simulation, setSimulation] = useState(null) + const chainIndex = useRef(new VersionChainIndex()) - // Use external state if provided, otherwise use internal state - const selectedSpace = externalSelectedSpace ?? internalSelectedSpace + // React state only for things that affect DOM + const [hoveredNode, setHoveredNode] = useState(null) + const [selectedNode, setSelectedNode] = useState(null) + const [zoomDisplay, setZoomDisplay] = useState(50) + // Monotonic counter that increments on any viewport change (pan or zoom) + // Used as a dependency proxy to recalculate popover positions + const [viewportVersion, setViewportVersion] = useState(0) - // Handle space change - const handleSpaceChange = useCallback( - (spaceId: string) => { - if (externalOnSpaceChange) { - externalOnSpaceChange(spaceId) - } else { - setInternalSelectedSpace(spaceId) + // Limit documents so total node count (documents + their memories) stays under maxNodes + const limitedDocuments = useMemo(() => { + if (!maxNodes || documents.length === 0) return documents + let totalNodes = 0 + let cutoff = documents.length + for (let i = 0; i < documents.length; i++) { + const docNodes = 1 + (documents[i]?.memories?.length ?? 0) + if (totalNodes + docNodes > maxNodes) { + cutoff = i + break } - }, - [externalOnSpaceChange], - ) - - // Create data object with pagination to satisfy type requirements - const data = useMemo(() => { - return documents && documents.length > 0 - ? { - documents, - pagination: { - currentPage: 1, - limit: documents.length, - totalItems: documents.length, - totalPages: 1, - }, - } - : null - }, [documents]) - - // Graph interactions with variant-specific settings - const { - panX, - panY, - zoom, - /** hoveredNode currently unused within this component */ - hoveredNode: _hoveredNode, - selectedNode, - draggingNodeId, - nodePositions, - handlePanStart, - handlePanMove, - handlePanEnd, - handleWheel, - handleNodeHover, - handleNodeClick, - handleNodeDragStart, - handleNodeDragMove, - handleNodeDragEnd, - handleDoubleClick, - handleTouchStart, - handleTouchMove, - handleTouchEnd, - setSelectedNode, - autoFitToViewport, - centerViewportOn, - zoomIn, - zoomOut, - } = useGraphInteractions(variant) - - // Graph data - const { nodes, edges } = useGraphData( - data, - selectedSpace, - nodePositions, - draggingNodeId, - memoryLimit, - maxNodes, - ) - - // State to trigger re-renders when simulation ticks - const [, forceRender] = useReducer((x: number) => x + 1, 0) - - // Track drag state for physics integration - const dragStateRef = useRef<{ - nodeId: string | null - startX: number - startY: number - nodeStartX: number - nodeStartY: number - }>({ nodeId: null, startX: 0, startY: 0, nodeStartX: 0, nodeStartY: 0 }) - - // Force simulation - only runs during interactions (drag) - const forceSimulation = useForceSimulation( - nodes, - edges, - () => { - // On each tick, trigger a re-render - // D3 directly mutates node.x and node.y - forceRender() - }, - true, // enabled - ) - - // Auto-fit once per unique highlight set to show the full graph for context - const lastFittedHighlightKeyRef = useRef("") - useEffect(() => { - const highlightKey = highlightsVisible ? highlightDocumentIds.join("|") : "" - if ( - highlightKey && - highlightKey !== lastFittedHighlightKeyRef.current && - containerSize.width > 0 && - containerSize.height > 0 && - nodes.length > 0 - ) { - autoFitToViewport(nodes, containerSize.width, containerSize.height, { - occludedRightPx, - animate: true, - }) - lastFittedHighlightKeyRef.current = highlightKey + totalNodes += docNodes } - }, [ - highlightsVisible, - highlightDocumentIds, + return cutoff === documents.length ? documents : documents.slice(0, cutoff) + }, [documents, maxNodes]) + + const { nodes, edges } = useGraphData( + limitedDocuments, + null, containerSize.width, containerSize.height, - nodes.length, - occludedRightPx, - autoFitToViewport, - ]) + colors, + ) - // Auto-fit graph when component mounts or nodes change significantly + // Rebuild version chain index during render (not in an effect) so that + // the chain data is up-to-date when getChain() is called in useMemo below. + // rebuild() has an early-return guard (referential equality on documents) + // that makes this a no-op on re-renders where limitedDocuments hasn't changed. + chainIndex.current.rebuild(limitedDocuments) + + // Smart simulation re-init: track node ID set, only init() when IDs change + const prevSimIdsRef = useRef("") + useEffect(() => { + if (nodes.length === 0) { + simulationRef.current?.destroy() + simulationRef.current = null + setSimulation(null) + prevSimIdsRef.current = "" + return + } + + const idKey = nodes + .map((n) => n.id) + .sort() + .join(",") + + if (!simulationRef.current) { + const sim = new ForceSimulation() + simulationRef.current = sim + setSimulation(sim) + } + + if (idKey !== prevSimIdsRef.current) { + // IDs changed - full re-init + prevSimIdsRef.current = idKey + simulationRef.current.init(nodes, edges) + } else { + // Only metadata changed - update existing simulation + simulationRef.current.update(nodes, edges) + } + }, [nodes, edges]) + + // Cleanup simulation on unmount + useEffect(() => { + return () => { + simulationRef.current?.destroy() + simulationRef.current = null + setSimulation(null) + } + }, []) + + // Auto-fit when data first loads const hasAutoFittedRef = useRef(false) useEffect(() => { - // Only auto-fit once when we have nodes and container size if ( !hasAutoFittedRef.current && nodes.length > 0 && - containerSize.width > 0 && - containerSize.height > 0 + viewportRef.current && + containerSize.width > 0 ) { - // Auto-fit to show all content for both variants - // Add a small delay to ensure the canvas is fully initialized const timer = setTimeout(() => { - autoFitToViewport(nodes, containerSize.width, containerSize.height) - hasAutoFittedRef.current = true - }, 100) - - return () => clearTimeout(timer) - } - }, [nodes, containerSize.width, containerSize.height, autoFitToViewport]) - - // Reset auto-fit flag when nodes array becomes empty (switching views) - useEffect(() => { - if (nodes.length === 0) { - hasAutoFittedRef.current = false - } - }, [nodes.length]) - - // Extract unique spaces from memories and calculate counts - const { availableSpaces, spaceMemoryCounts } = useMemo(() => { - if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} } - - const spaceSet = new Set() - const counts: Record = {} - - data.documents.forEach((doc) => { - doc.memoryEntries.forEach((memory) => { - const spaceId = memory.spaceContainerTag || memory.spaceId || "default" - spaceSet.add(spaceId) - counts[spaceId] = (counts[spaceId] || 0) + 1 - }) - }) - - return { - availableSpaces: Array.from(spaceSet).sort(), - spaceMemoryCounts: counts, - } - }, [data]) - - // Handle container resize - useEffect(() => { - const updateSize = () => { - if (containerRef.current) { - const newWidth = containerRef.current.clientWidth - const newHeight = containerRef.current.clientHeight - - // Only update if size actually changed and is valid - setContainerSize((prev) => { - if (prev.width !== newWidth || prev.height !== newHeight) { - return { width: newWidth, height: newHeight } - } - return prev - }) - } - } - - // Use a slight delay to ensure DOM is fully rendered - const timer = setTimeout(updateSize, 0) - updateSize() // Also call immediately - - window.addEventListener("resize", updateSize) - - // Use ResizeObserver for more accurate container size detection - const resizeObserver = new ResizeObserver(updateSize) - if (containerRef.current) { - resizeObserver.observe(containerRef.current) - } - - return () => { - clearTimeout(timer) - window.removeEventListener("resize", updateSize) - resizeObserver.disconnect() - } - }, []) - - // Physics-enabled node drag start - const handleNodeDragStartWithNodes = useCallback( - (nodeId: string, e: React.MouseEvent) => { - // Find the node being dragged - const node = nodes.find((n) => n.id === nodeId) - if (node) { - // Store drag start state - dragStateRef.current = { - nodeId, - startX: e.clientX, - startY: e.clientY, - nodeStartX: node.x, - nodeStartY: node.y, - } - - // Pin the node at its current position (d3-force pattern) - node.fx = node.x - node.fy = node.y - - // Reheat simulation immediately (like d3 reference code) - forceSimulation.reheat() - } - - // Set dragging state (still need this for visual feedback) - handleNodeDragStart(nodeId, e, nodes) - }, - [handleNodeDragStart, nodes, forceSimulation], - ) - - // Physics-enabled node drag move - const handleNodeDragMoveWithNodes = useCallback( - (e: React.MouseEvent) => { - if (draggingNodeId && dragStateRef.current.nodeId === draggingNodeId) { - // Update the fixed position during drag (this is what d3 uses) - const node = nodes.find((n) => n.id === draggingNodeId) - if (node) { - // Calculate new position based on drag delta - const deltaX = (e.clientX - dragStateRef.current.startX) / zoom - const deltaY = (e.clientY - dragStateRef.current.startY) / zoom - - // Update subject position (matches d3 reference code pattern) - // Only update fx/fy, let simulation handle x/y - node.fx = dragStateRef.current.nodeStartX + deltaX - node.fy = dragStateRef.current.nodeStartY + deltaY - } - } - }, - [nodes, draggingNodeId, zoom], - ) - - // Physics-enabled node drag end - const handleNodeDragEndWithPhysics = useCallback(() => { - if (draggingNodeId) { - // Unpin the node (allow physics to take over) - matches d3 reference code - const node = nodes.find((n) => n.id === draggingNodeId) - if (node) { - node.fx = null - node.fy = null - } - - // Cool down the simulation (restore target alpha to 0) - forceSimulation.coolDown() - - // Reset drag state - dragStateRef.current = { - nodeId: null, - startX: 0, - startY: 0, - nodeStartX: 0, - nodeStartY: 0, - } - } - - // Call original handler to clear dragging state - handleNodeDragEnd() - }, [draggingNodeId, nodes, forceSimulation, handleNodeDragEnd]) - - // Physics-aware node click - let simulation continue naturally - const handleNodeClickWithPhysics = useCallback( - (nodeId: string) => { - // Just call original handler to update selected node state - // Don't stop the simulation - let it cool down naturally - handleNodeClick(nodeId) - }, - [handleNodeClick], - ) - - // Navigation callbacks - const handleCenter = useCallback(() => { - if (nodes.length > 0) { - // Calculate center of all nodes - let sumX = 0 - let sumY = 0 - let count = 0 - - nodes.forEach((node) => { - sumX += node.x - sumY += node.y - count++ - }) - - if (count > 0) { - const centerX = sumX / count - const centerY = sumY / count - centerViewportOn( - centerX, - centerY, + viewportRef.current?.fitToNodes( + nodes, containerSize.width, containerSize.height, ) + hasAutoFittedRef.current = true + }, 100) + return () => clearTimeout(timer) + } + }, [nodes, containerSize.width, containerSize.height]) + + useEffect(() => { + if (nodes.length === 0) hasAutoFittedRef.current = false + }, [nodes.length]) + + // Container resize observer + useEffect(() => { + const el = containerRef.current + if (!el) return + + const ro = new ResizeObserver(() => { + setContainerSize({ width: el.clientWidth, height: el.clientHeight }) + setContainerBounds(el.getBoundingClientRect()) + }) + ro.observe(el) + setContainerSize({ width: el.clientWidth, height: el.clientHeight }) + setContainerBounds(el.getBoundingClientRect()) + + return () => ro.disconnect() + }, []) + + // Callbacks for GraphCanvas + const handleNodeHover = useCallback( + (id: string | null) => setHoveredNode(id), + [], + ) + + const handleNodeClick = useCallback((id: string | null) => { + setSelectedNode((prev) => (id === null ? null : prev === id ? null : id)) + }, []) + + const handleNodeDragStart = useCallback((_id: string) => { + // Drag is handled imperatively by InputHandler + }, []) + + const handleNodeDragEnd = useCallback(() => { + // Drag end handled by InputHandler + }, []) + + const handleViewportChange = useCallback( + (zoom: number, popoverVisible: boolean) => { + setZoomDisplay(Math.round(zoom * 100)) + // Only increment viewportVersion (which triggers popover repositioning + // via activePopoverPosition useMemo) when a popover is actually visible. + // This avoids 60fps React reconciliation during plain panning/zooming. + if (popoverVisible) { + setViewportVersion((v) => v + 1) + } + }, + [], + ) + + // Navigation + const handleAutoFit = useCallback(() => { + if (nodes.length === 0 || !viewportRef.current) return + viewportRef.current.fitToNodes( + nodes, + containerSize.width, + containerSize.height, + ) + }, [nodes, containerSize.width, containerSize.height]) + + const handleCenter = useCallback(() => { + if (nodes.length === 0 || !viewportRef.current) return + let sx = 0 + let sy = 0 + for (const n of nodes) { + sx += n.x + sy += n.y + } + viewportRef.current.centerOn( + sx / nodes.length, + sy / nodes.length, + containerSize.width, + containerSize.height, + ) + }, [nodes, containerSize.width, containerSize.height]) + + const handleZoomIn = useCallback(() => { + const vp = viewportRef.current + if (!vp) return + vp.zoomTo(vp.zoom * 1.3, containerSize.width / 2, containerSize.height / 2) + }, [containerSize.width, containerSize.height]) + + const handleZoomOut = useCallback(() => { + const vp = viewportRef.current + if (!vp) return + vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, containerSize.height / 2) + }, [containerSize.width, containerSize.height]) + + // Keyboard shortcuts — using useEffect with keydown listener + useEffect(() => { + const handler = (e: KeyboardEvent) => { + // Don't handle if user is typing in an input + const target = e.target as HTMLElement + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) + return + + switch (e.key) { + case "z": + case "Z": + handleAutoFit() + break + case "c": + case "C": + handleCenter() + break + case "=": + case "+": + handleZoomIn() + break + case "-": + case "_": + handleZoomOut() + break + case "Escape": + setSelectedNode(null) + break } } - }, [nodes, centerViewportOn, containerSize.width, containerSize.height]) - const handleAutoFit = useCallback(() => { - if ( - nodes.length > 0 && - containerSize.width > 0 && - containerSize.height > 0 - ) { - autoFitToViewport(nodes, containerSize.width, containerSize.height, { - occludedRightPx, - animate: true, - }) + window.addEventListener("keydown", handler) + return () => window.removeEventListener("keydown", handler) + }, [handleAutoFit, handleCenter, handleZoomIn, handleZoomOut]) + + // Arrow key navigation through nodes + const selectAndCenter = useCallback( + (nodeId: string) => { + setSelectedNode(nodeId) + const n = nodes.find((nd) => nd.id === nodeId) + if (n && viewportRef.current) + viewportRef.current.centerOn( + n.x, + n.y, + containerSize.width, + containerSize.height, + ) + }, + [nodes, containerSize.width, containerSize.height], + ) + + const navigateUp = useCallback(() => { + if (!selectedNode) return + const chain = chainIndex.current.getChain(selectedNode) + if (chain && chain.length > 1) { + const idx = chain.findIndex((e) => e.id === selectedNode) + const prev = chain[idx - 1] + if (idx > 0 && prev) { + selectAndCenter(prev.id) + return + } } - }, [ - 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]) - - // Calculate popover position (memoized for performance) - const popoverPosition = useMemo(() => { - if (!selectedNodeData) return null - - // Calculate screen position of the node - const screenX = selectedNodeData.x * zoom + panX - const screenY = selectedNodeData.y * zoom + panY - - // Popover dimensions (estimated) - const popoverWidth = 320 - const popoverHeight = 400 - const padding = 16 - - // Calculate node dimensions to position popover with proper gap - const nodeSize = selectedNodeData.size * zoom - const nodeWidth = - selectedNodeData.type === "document" ? nodeSize * 1.4 : nodeSize - const nodeHeight = - selectedNodeData.type === "document" ? nodeSize * 0.9 : nodeSize - const gap = 20 // Gap between node and popover - - // Smart positioning: flip to other side if would go off-screen - let popoverX = screenX + nodeWidth / 2 + gap - let popoverY = screenY - popoverHeight / 2 - - // Check right edge - if (popoverX + popoverWidth > containerSize.width - padding) { - // Flip to left side of node - popoverX = screenX - nodeWidth / 2 - gap - popoverWidth + // At top of chain or no chain — go to parent document + const node = nodes.find((n) => n.id === selectedNode) + if (node?.type === "memory" && "documentId" in node.data) { + selectAndCenter(node.data.documentId) } + }, [selectedNode, nodes, selectAndCenter]) - // Check left edge - if (popoverX < padding) { - popoverX = padding + const navigateDown = useCallback(() => { + if (!selectedNode) return + // Version chain navigation + const chain = chainIndex.current.getChain(selectedNode) + if (chain && chain.length > 1) { + const idx = chain.findIndex((e) => e.id === selectedNode) + const next = chain[idx + 1] + if (idx >= 0 && idx < chain.length - 1 && next) { + selectAndCenter(next.id) + return + } } - - // Check bottom edge - if (popoverY + popoverHeight > containerSize.height - padding) { - // Move up - popoverY = containerSize.height - popoverHeight - padding - } - - // Check top edge - if (popoverY < padding) { - popoverY = padding - } - - return { x: popoverX, y: popoverY } - }, [ - selectedNodeData, - zoom, - panX, - panY, - containerSize.width, - containerSize.height, - ]) - - // Viewport-based loading: load more when most documents are visible (optional) - const checkAndLoadMore = useCallback(() => { - if ( - isLoadingMore || - !hasMore || - !data?.documents || - data.documents.length === 0 - ) - return - - // Calculate viewport bounds - const viewportBounds = { - left: -panX / zoom - 200, - right: (-panX + containerSize.width) / zoom + 200, - top: -panY / zoom - 200, - bottom: (-panY + containerSize.height) / zoom + 200, - } - - // Count visible documents - const visibleDocuments = data.documents.filter((doc) => { - const docNodes = nodes.filter( - (node) => node.type === "document" && node.data.id === doc.id, + // On a document — go to its first memory + const node = nodes.find((n) => n.id === selectedNode) + if (node?.type === "document") { + const child = nodes.find( + (n) => + n.type === "memory" && + "documentId" in n.data && + n.data.documentId === selectedNode, ) - return docNodes.some( - (node) => - node.x >= viewportBounds.left && - node.x <= viewportBounds.right && - node.y >= viewportBounds.top && - node.y <= viewportBounds.bottom, + if (child) selectAndCenter(child.id) + } + }, [selectedNode, nodes, selectAndCenter]) + + const navigateNext = useCallback(() => { + if (!selectedNode) return + const node = nodes.find((n) => n.id === selectedNode) + if (!node) return + + if (node.type === "document") { + const docs = nodes.filter((n) => n.type === "document") + const idx = docs.findIndex((n) => n.id === selectedNode) + const next = docs[(idx + 1) % docs.length] + if (next) selectAndCenter(next.id) + } else { + const docId = "documentId" in node.data ? node.data.documentId : null + const siblings = nodes.filter( + (n) => + n.type === "memory" && + "documentId" in n.data && + n.data.documentId === docId, ) - }) - - // If 80% or more of documents are visible, load more - const visibilityRatio = visibleDocuments.length / data.documents.length - if (visibilityRatio >= 0.8) { - effectiveLoadMoreDocuments() + if (siblings.length === 0) return + const idx = siblings.findIndex((n) => n.id === selectedNode) + const next = siblings[(idx + 1) % siblings.length] + if (next) selectAndCenter(next.id) } - }, [ - isLoadingMore, - hasMore, - data, - panX, - panY, - zoom, - containerSize.width, - containerSize.height, - nodes, - effectiveLoadMoreDocuments, - ]) + }, [selectedNode, nodes, selectAndCenter]) - // Throttled version to avoid excessive checks - const lastLoadCheckRef = useRef(0) - const throttledCheckAndLoadMore = useCallback(() => { - const now = Date.now() - if (now - lastLoadCheckRef.current > 1000) { - // Check at most once per second - lastLoadCheckRef.current = now - checkAndLoadMore() + const navigatePrev = useCallback(() => { + if (!selectedNode) return + const node = nodes.find((n) => n.id === selectedNode) + if (!node) return + + if (node.type === "document") { + const docs = nodes.filter((n) => n.type === "document") + const idx = docs.findIndex((n) => n.id === selectedNode) + const prev = docs[(idx - 1 + docs.length) % docs.length] + if (prev) selectAndCenter(prev.id) + } else { + const docId = "documentId" in node.data ? node.data.documentId : null + const siblings = nodes.filter( + (n) => + n.type === "memory" && + "documentId" in n.data && + n.data.documentId === docId, + ) + if (siblings.length === 0) return + const idx = siblings.findIndex((n) => n.id === selectedNode) + const prev = siblings[(idx - 1 + siblings.length) % siblings.length] + if (prev) selectAndCenter(prev.id) } - }, [checkAndLoadMore]) + }, [selectedNode, nodes, selectAndCenter]) - // Monitor viewport changes to trigger loading + // Arrow key navigation useEffect(() => { - if (!autoLoadOnViewport) return - throttledCheckAndLoadMore() - }, [throttledCheckAndLoadMore, autoLoadOnViewport]) + const handler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) + return - // Initial load trigger when graph is first rendered - useEffect(() => { - if (!autoLoadOnViewport) return - if (data?.documents && data.documents.length > 0 && hasMore) { - // Start loading more documents after initial render - setTimeout(() => { - throttledCheckAndLoadMore() - }, 500) // Small delay to allow initial layout + switch (e.key) { + case "ArrowUp": + e.preventDefault() + navigateUp() + break + case "ArrowDown": + e.preventDefault() + navigateDown() + break + case "ArrowRight": + e.preventDefault() + navigateNext() + break + case "ArrowLeft": + e.preventDefault() + navigatePrev() + break + } } - }, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]) - // Slideshow logic - simulate actual node clicks with physics - const slideshowIntervalRef = useRef(null) - const physicsTimeoutRef = useRef(null) - const lastSelectedIndexRef = useRef(-1) - const isSlideshowActiveRef = useRef(isSlideshowActive) + window.addEventListener("keydown", handler) + return () => window.removeEventListener("keydown", handler) + }, [navigateUp, navigateDown, navigateNext, navigatePrev]) - // Update slideshow active ref - useEffect(() => { - isSlideshowActiveRef.current = isSlideshowActive - }, [isSlideshowActive]) - - // Use refs to store current values without triggering re-renders + // Slideshow — use refs to avoid re-creating the interval on resize or node updates const nodesRef = useRef(nodes) - const handleNodeClickRef = useRef(handleNodeClick) - const centerViewportOnRef = useRef(centerViewportOn) + nodesRef.current = nodes const containerSizeRef = useRef(containerSize) + containerSizeRef.current = containerSize const onSlideshowNodeChangeRef = useRef(onSlideshowNodeChange) - const forceSimulationRef = useRef(forceSimulation) + onSlideshowNodeChangeRef.current = onSlideshowNodeChange - // Update refs when values change + // biome-ignore lint/correctness/useExhaustiveDependencies: reads from refs to avoid resetting interval on resize/node changes useEffect(() => { - nodesRef.current = nodes - handleNodeClickRef.current = handleNodeClick - centerViewportOnRef.current = centerViewportOn - containerSizeRef.current = containerSize - onSlideshowNodeChangeRef.current = onSlideshowNodeChange - forceSimulationRef.current = forceSimulation - }, [ - nodes, - handleNodeClick, - centerViewportOn, - containerSize, - onSlideshowNodeChange, - forceSimulation, - ]) - - useEffect(() => { - // Clear any existing interval and timeout when isSlideshowActive changes - if (slideshowIntervalRef.current) { - clearInterval(slideshowIntervalRef.current) - slideshowIntervalRef.current = null - } - if (physicsTimeoutRef.current) { - clearTimeout(physicsTimeoutRef.current) - physicsTimeoutRef.current = null - } - - if (!isSlideshowActive) { - // Close the popover when stopping slideshow - setSelectedNode(null) - // Explicitly cool down physics simulation in case timeout hasn't fired yet - forceSimulation.coolDown() + if (!isSlideshowActive || nodes.length === 0) { + if (!isSlideshowActive) { + setSelectedNode(null) + simulationRef.current?.coolDown() + } return } - // Select a random node (avoid selecting the same one twice in a row) - const selectRandomNode = () => { - // Double-check slideshow is still active - if (!isSlideshowActiveRef.current) return + let lastIdx = -1 + let coolDownTimer: ReturnType | null = null + const pick = () => { const currentNodes = nodesRef.current if (currentNodes.length === 0) return - - let randomIndex: number - // If we have more than one node, avoid selecting the same one + let idx: number if (currentNodes.length > 1) { do { - randomIndex = Math.floor(Math.random() * currentNodes.length) - } while (randomIndex === lastSelectedIndexRef.current) + idx = Math.floor(Math.random() * currentNodes.length) + } while (idx === lastIdx) } else { - randomIndex = 0 - } - - lastSelectedIndexRef.current = randomIndex - const randomNode = currentNodes[randomIndex] - - if (randomNode) { - // Smoothly pan to the node first - centerViewportOnRef.current( - randomNode.x, - randomNode.y, - containerSizeRef.current.width, - containerSizeRef.current.height, - ) - - // Simulate the actual node click (triggers dimming and popover) - handleNodeClickRef.current(randomNode.id) - - // Trigger physics animation briefly - forceSimulationRef.current.reheat() - - // Cool down physics after 1 second (cleanup old timeout first) - if (physicsTimeoutRef.current) { - clearTimeout(physicsTimeoutRef.current) - } - physicsTimeoutRef.current = setTimeout(() => { - // Only cool down if slideshow is still active or if this is cleanup - forceSimulationRef.current.coolDown() - physicsTimeoutRef.current = null - }, 1000) - - // Notify parent component - onSlideshowNodeChangeRef.current?.(randomNode.id) + idx = 0 } + lastIdx = idx + const n = currentNodes[idx]! + setSelectedNode(n.id) + const sz = containerSizeRef.current + viewportRef.current?.centerOn(n.x, n.y, sz.width, sz.height) + simulationRef.current?.reheat() + onSlideshowNodeChangeRef.current?.(n.id) + // Clear any pending coolDown before scheduling a new one + if (coolDownTimer) clearTimeout(coolDownTimer) + coolDownTimer = setTimeout(() => { + simulationRef.current?.coolDown() + coolDownTimer = null + }, 1000) } - // Start immediately - selectRandomNode() - - // Set interval for subsequent selections (3.5 seconds) - slideshowIntervalRef.current = setInterval(() => { - selectRandomNode() - }, 3500) - + pick() + const interval = setInterval(pick, 3500) return () => { - if (slideshowIntervalRef.current) { - clearInterval(slideshowIntervalRef.current) - slideshowIntervalRef.current = null - } - if (physicsTimeoutRef.current) { - clearTimeout(physicsTimeoutRef.current) - physicsTimeoutRef.current = null - } + clearInterval(interval) + if (coolDownTimer) clearTimeout(coolDownTimer) + } + }, [isSlideshowActive, nodes.length]) + + // Active node: selected takes priority, then hovered + const activeNodeId = selectedNode ?? hoveredNode + const activeNodeData = useMemo(() => { + if (!activeNodeId) return null + return nodes.find((n) => n.id === activeNodeId) ?? null + }, [activeNodeId, nodes]) + + // biome-ignore lint/correctness/useExhaustiveDependencies: viewportVersion intentionally used as proxy for viewport state changes + const activePopoverPosition = useMemo(() => { + if (!activeNodeData || !viewportRef.current) return null + const vp = viewportRef.current + const screen = vp.worldToScreen(activeNodeData.x, activeNodeData.y) + return { + screenX: screen.x, + screenY: screen.y, + nodeRadius: (activeNodeData.size * vp.zoom) / 2, + } + // viewportVersion triggers re-computation on any viewport change (pan + zoom) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeNodeData, viewportVersion]) + + // biome-ignore lint/correctness/useExhaustiveDependencies: limitedDocuments triggers re-computation after chainIndex.current.rebuild() runs with new data + const activeVersionChain = useMemo(() => { + if (!activeNodeData || activeNodeData.type !== "memory") return null + return chainIndex.current.getChain(activeNodeData.id) + }, [activeNodeData, limitedDocuments]) + + const isLoading = externalIsLoading + + if (externalError) { + const errorContainerStyle: React.CSSProperties = { + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.bg, + borderRadius: 12, + } + + const errorBoxStyle: React.CSSProperties = { + color: colors.textSecondary, + paddingLeft: 24, + paddingRight: 24, + paddingTop: 16, + paddingBottom: 16, } - }, [isSlideshowActive]) // Only depend on isSlideshowActive - if (error) { return ( -
-
- {/* Glass effect background */} - - -
- Error loading documents: {error.message} -
+
+
+ Error loading graph: {externalError.message}
) } - return ( -
- {/* Spaces selector - only shown for console variant */} - {variant === "console" && availableSpaces.length > 0 && ( -
- -
- )} + const wrapperStyle: React.CSSProperties = { + position: "relative", + height: "100%", + borderRadius: 12, + overflow: "hidden", + backgroundColor: colors.bg, + backgroundImage: `radial-gradient(circle, ${colors.textMuted} 0.5px, transparent 0.5px)`, + backgroundSize: "16px 16px", + } - {/* Loading indicator */} + const canvasContainerStyle: React.CSSProperties = { + width: "100%", + height: "100%", + position: "relative", + overflow: "hidden", + touchAction: "none", + userSelect: "none", + } + + const emptyStateStyle: React.CSSProperties = { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + } + + const navControlsStyle: React.CSSProperties = { + position: "absolute", + bottom: 72, + left: 16, + zIndex: 15, + } + + return ( +
- {/* Legend */} - - - {/* Node popover - positioned near clicked node */} - {selectedNodeData && popoverPosition && ( - setSelectedNode(null)} - containerBounds={containerRef.current?.getBoundingClientRect()} - onBackdropClick={isSlideshowActive ? onSlideshowStop : undefined} - /> + {!isLoading && hasMore && onLoadMore && ( + )} - {/* Show welcome screen when no memories exist */} - {!isLoading && (!data || !nodes.some((n) => n.type === "document")) && ( - <>{children} + {!isLoading && !nodes.some((n) => n.type === "document") && children && ( +
{children}
)} - {/* Graph container */} -
+
{containerSize.width > 0 && containerSize.height > 0 && ( )} - {/* Navigation controls */} - {containerSize.width > 0 && ( - - zoomIn(containerSize.width / 2, containerSize.height / 2) - } - onZoomOut={() => - zoomOut(containerSize.width / 2, containerSize.height / 2) - } - onAutoFit={handleAutoFit} - nodes={nodes} - className={styles.navControlsContainer} + {activeNodeData && activePopoverPosition && ( + )} + +
+ {containerSize.width > 0 && ( +
+ +
+ )} + +
) diff --git a/packages/memory-graph/src/components/navigation-controls.css.ts b/packages/memory-graph/src/components/navigation-controls.css.ts deleted file mode 100644 index 318cf067..00000000 --- a/packages/memory-graph/src/components/navigation-controls.css.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { style } from "@vanilla-extract/css" -import { themeContract } from "../styles/theme.css" - -/** - * Navigation controls container - */ -export const navContainer = style({ - display: "flex", - flexDirection: "column", - gap: themeContract.space[1], -}) - -/** - * Base button styles for navigation controls - */ -const navButtonBase = style({ - backgroundColor: "rgba(0, 0, 0, 0.2)", - backdropFilter: "blur(8px)", - WebkitBackdropFilter: "blur(8px)", - border: "1px solid rgba(255, 255, 255, 0.1)", - borderRadius: themeContract.radii.lg, - padding: themeContract.space[2], - color: "rgba(255, 255, 255, 0.7)", - fontSize: themeContract.typography.fontSize.xs, - fontWeight: themeContract.typography.fontWeight.medium, - minWidth: "64px", - cursor: "pointer", - transition: themeContract.transitions.normal, - - selectors: { - "&:hover": { - backgroundColor: "rgba(0, 0, 0, 0.3)", - borderColor: "rgba(255, 255, 255, 0.2)", - color: "rgba(255, 255, 255, 1)", - }, - }, -}) - -/** - * Standard navigation button - */ -export const navButton = navButtonBase - -/** - * Zoom controls container - */ -export const zoomContainer = style({ - display: "flex", - flexDirection: "column", -}) - -/** - * Zoom in button (top rounded) - */ -export const zoomInButton = style([ - navButtonBase, - { - borderTopLeftRadius: themeContract.radii.lg, - borderTopRightRadius: themeContract.radii.lg, - borderBottomLeftRadius: 0, - borderBottomRightRadius: 0, - borderBottom: 0, - }, -]) - -/** - * Zoom out button (bottom rounded) - */ -export const zoomOutButton = style([ - navButtonBase, - { - borderTopLeftRadius: 0, - borderTopRightRadius: 0, - borderBottomLeftRadius: themeContract.radii.lg, - borderBottomRightRadius: themeContract.radii.lg, - }, -]) diff --git a/packages/memory-graph/src/components/navigation-controls.tsx b/packages/memory-graph/src/components/navigation-controls.tsx index ce25aa5b..d697deb2 100644 --- a/packages/memory-graph/src/components/navigation-controls.tsx +++ b/packages/memory-graph/src/components/navigation-controls.tsx @@ -1,14 +1,5 @@ -"use client" - import { memo } from "react" -import type { GraphNode } from "@/types" -import { - navContainer, - navButton, - zoomContainer, - zoomInButton, - zoomOutButton, -} from "./navigation-controls.css" +import type { GraphNode, GraphThemeColors } from "../types" interface NavigationControlsProps { onCenter: () => void @@ -17,53 +8,183 @@ interface NavigationControlsProps { onAutoFit: () => void nodes: GraphNode[] className?: string + zoomLevel: number + colors: GraphThemeColors +} + +function KeyBadge({ + keys, + colors, +}: { + keys: string + colors: GraphThemeColors +}) { + const style: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + padding: "2px 6px", + borderRadius: 4, + fontSize: 10, + fontWeight: 500, + color: colors.textMuted, + backgroundColor: colors.controlBg, + border: `1px solid ${colors.controlBorder}`, + lineHeight: 1, + } + + return {keys} +} + +function NavBtn({ + onClick, + children, + colors, +}: { + onClick: () => void + children: React.ReactNode + colors: GraphThemeColors +}) { + const style: React.CSSProperties = { + display: "flex", + width: "fit-content", + gap: 12, + alignItems: "center", + justifyContent: "space-between", + paddingLeft: 12, + paddingRight: 12, + paddingTop: 8, + paddingBottom: 8, + borderRadius: 9999, + cursor: "pointer", + backgroundColor: colors.controlBg, + border: `1px solid ${colors.controlBorder}`, + boxShadow: "0 1px 2px 0 rgba(0,0,0,0.05)", + transition: "background-color 0.15s", + } + + return ( + + ) } export const NavigationControls = memo( - ({ onCenter, onZoomIn, onZoomOut, onAutoFit, nodes, className = "" }) => { - if (nodes.length === 0) { - return null + ({ + onCenter, + onZoomIn, + onZoomOut, + onAutoFit, + nodes, + className = "", + zoomLevel, + colors, + }) => { + if (nodes.length === 0) return null + + const containerStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + gap: 4, } - const containerClassName = className - ? `${navContainer} ${className}` - : navContainer + const labelStyle: React.CSSProperties = { + fontSize: 12, + fontWeight: 500, + color: colors.textPrimary, + } + + const zoomRowStyle: React.CSSProperties = { + display: "flex", + width: "fit-content", + gap: 12, + alignItems: "center", + justifyContent: "space-between", + paddingLeft: 12, + paddingRight: 12, + paddingTop: 8, + paddingBottom: 8, + borderRadius: 9999, + backgroundColor: colors.controlBg, + border: `1px solid ${colors.controlBorder}`, + boxShadow: "0 1px 2px 0 rgba(0,0,0,0.05)", + } + + const zoomBtnGroupStyle: React.CSSProperties = { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 2, + } + + const zoomBtnStyle: React.CSSProperties = { + width: 20, + height: 20, + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: 4, + backgroundColor: colors.controlBg, + border: `1px solid ${colors.controlBorder}`, + color: colors.textSecondary, + cursor: "pointer", + fontSize: 12, + padding: 0, + transition: "background-color 0.15s, color 0.15s", + } return ( -
- - -
- - +
+ + Fit + + + + + Center + + + +
+ {zoomLevel}% +
+ + +
) diff --git a/packages/memory-graph/src/components/node-detail-panel.css.ts b/packages/memory-graph/src/components/node-detail-panel.css.ts deleted file mode 100644 index 5429e2bd..00000000 --- a/packages/memory-graph/src/components/node-detail-panel.css.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { style } from "@vanilla-extract/css" -import { themeContract } from "../styles/theme.css" - -/** - * Main container (positioned absolutely) - * Highest z-index so it appears above everything when open - */ -export const container = style({ - position: "absolute", - width: "20rem", // w-80 = 320px = 20rem - borderRadius: themeContract.radii.xl, - overflow: "hidden", - zIndex: 40, // Highest priority - always on top when open - maxHeight: "calc(100vh - 2rem)", // Leave some breathing room - top: themeContract.space[4], - right: themeContract.space[4], - - // Add shadow for depth - boxShadow: - "0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3)", -}) - -/** - * Content wrapper with scrolling - */ -export const content = style({ - position: "relative", - zIndex: 10, - padding: themeContract.space[4], - overflowY: "auto", - maxHeight: "80vh", -}) - -/** - * Header section - */ -export const header = style({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - marginBottom: themeContract.space[3], -}) - -export const headerLeft = style({ - display: "flex", - alignItems: "center", - gap: themeContract.space[2], -}) - -export const headerIcon = style({ - width: "1.25rem", - height: "1.25rem", - color: themeContract.colors.text.secondary, -}) - -export const headerIconMemory = style({ - width: "1.25rem", - height: "1.25rem", - color: "rgb(96, 165, 250)", // blue-400 -}) - -export const closeButton = style({ - height: "32px", - width: "32px", - padding: 0, - color: themeContract.colors.text.secondary, - - selectors: { - "&:hover": { - color: themeContract.colors.text.primary, - }, - }, -}) - -export const closeIcon = style({ - width: "1rem", - height: "1rem", -}) - -/** - * Content sections - */ -export const sections = style({ - display: "flex", - flexDirection: "column", - gap: themeContract.space[3], -}) - -export const section = style({}) - -export const sectionLabel = style({ - fontSize: themeContract.typography.fontSize.xs, - color: themeContract.colors.text.muted, - textTransform: "uppercase", - letterSpacing: "0.05em", -}) - -export const sectionValue = style({ - fontSize: themeContract.typography.fontSize.sm, - color: themeContract.colors.text.secondary, - marginTop: themeContract.space[1], -}) - -export const sectionValueTruncated = style({ - fontSize: themeContract.typography.fontSize.sm, - color: themeContract.colors.text.secondary, - marginTop: themeContract.space[1], - overflow: "hidden", - display: "-webkit-box", - WebkitLineClamp: 3, - WebkitBoxOrient: "vertical", -}) - -export const link = style({ - fontSize: themeContract.typography.fontSize.sm, - color: "rgb(129, 140, 248)", // indigo-400 - marginTop: themeContract.space[1], - display: "flex", - alignItems: "center", - gap: themeContract.space[1], - textDecoration: "none", - transition: themeContract.transitions.normal, - - selectors: { - "&:hover": { - color: "rgb(165, 180, 252)", // indigo-300 - }, - }, -}) - -export const linkIcon = style({ - width: "0.75rem", - height: "0.75rem", -}) - -export const badge = style({ - marginTop: themeContract.space[2], -}) - -export const expiryText = style({ - fontSize: themeContract.typography.fontSize.xs, - color: themeContract.colors.text.muted, - marginTop: themeContract.space[1], -}) - -/** - * Footer section (metadata) - */ -export const footer = style({ - paddingTop: themeContract.space[2], - borderTop: "1px solid rgba(71, 85, 105, 0.5)", // slate-700/50 -}) - -export const metadata = style({ - display: "flex", - alignItems: "center", - gap: themeContract.space[4], - fontSize: themeContract.typography.fontSize.xs, - color: themeContract.colors.text.muted, -}) - -export const metadataItem = style({ - display: "flex", - alignItems: "center", - gap: themeContract.space[1], -}) - -export const metadataIcon = style({ - width: "0.75rem", - height: "0.75rem", -}) diff --git a/packages/memory-graph/src/components/node-detail-panel.tsx b/packages/memory-graph/src/components/node-detail-panel.tsx deleted file mode 100644 index e3920be2..00000000 --- a/packages/memory-graph/src/components/node-detail-panel.tsx +++ /dev/null @@ -1,248 +0,0 @@ -"use client" - -import { Badge } from "@/ui/badge" -import { Button } from "@/ui/button" -import { GlassMenuEffect } from "@/ui/glass-effect" -import { Brain, Calendar, ExternalLink, FileText, Hash, X } from "lucide-react" -import { motion } from "motion/react" -import { memo } from "react" -import { - GoogleDocs, - GoogleDrive, - GoogleSheets, - GoogleSlides, - MicrosoftExcel, - MicrosoftOneNote, - MicrosoftPowerpoint, - MicrosoftWord, - NotionDoc, - OneDrive, - PDF, -} from "@/assets/icons" -import { HeadingH3Bold } from "@/ui/heading" -import type { DocumentWithMemories, MemoryEntry } from "@/types" -import type { NodeDetailPanelProps } from "@/types" -import * as styles from "./node-detail-panel.css" - -const formatDocumentType = (type: string) => { - // Special case for PDF - if (type.toLowerCase() === "pdf") return "PDF" - - // Replace underscores with spaces and capitalize each word - return type - .split("_") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" ") -} - -const getDocumentIcon = (type: string) => { - const iconProps = { className: "w-5 h-5 text-slate-300" } - - switch (type) { - case "google_doc": - return - case "google_sheet": - return - case "google_slide": - return - case "google_drive": - return - case "notion": - case "notion_doc": - return - case "word": - case "microsoft_word": - return - case "excel": - case "microsoft_excel": - return - case "powerpoint": - case "microsoft_powerpoint": - return - case "onenote": - case "microsoft_onenote": - return - case "onedrive": - return - case "pdf": - return - default: - { - } - return - } -} - -export const NodeDetailPanel = memo(function NodeDetailPanel({ - node, - onClose, - variant = "console", -}: NodeDetailPanelProps) { - if (!node) return null - - const isDocument = node.type === "document" - const data = node.data - - return ( - - {/* Glass effect background */} - - - -
-
- {isDocument ? ( - getDocumentIcon((data as DocumentWithMemories).type ?? "") - ) : ( - - )} - {isDocument ? "Document" : "Memory"} -
- - - -
- -
- {isDocument ? ( - <> -
- Title -

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

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

- {(data as DocumentWithMemories).summary} -

-
- )} - -
- Type -

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

-
- -
- Memory Count -

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

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

- {(data as MemoryEntry).memory} -

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

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

- )} -
- -
- Space -

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

-
- - )} - -
-
- - {/* @ts-ignore */} - - {new Date(data.createdAt).toLocaleDateString()} - - - {/* @ts-ignore */} - - {node.id} - -
-
-
-
-
- ) -}) - -NodeDetailPanel.displayName = "NodeDetailPanel" diff --git a/packages/memory-graph/src/components/node-hover-popover.tsx b/packages/memory-graph/src/components/node-hover-popover.tsx new file mode 100644 index 00000000..22d44f47 --- /dev/null +++ b/packages/memory-graph/src/components/node-hover-popover.tsx @@ -0,0 +1,638 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" +import type { ChainEntry } from "../canvas/version-chain" +import type { + DocumentNodeData, + GraphNode, + GraphThemeColors, + MemoryNodeData, +} from "../types" + +export interface NodeHoverPopoverProps { + node: GraphNode + screenX: number + screenY: number + nodeRadius: number + containerBounds?: DOMRect + versionChain?: ChainEntry[] | null + colors: GraphThemeColors + onNavigateNext?: () => void + onNavigatePrev?: () => void + onNavigateUp?: () => void + onNavigateDown?: () => void + onSelectNode?: (nodeId: string) => void +} + +function useCopyToClipboard(timeout = 2000) { + const [copied, setCopied] = useState(false) + const timeoutRef = useRef>(null) + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current) + } + }, []) + + const copy = useCallback( + (value: string) => { + navigator.clipboard.writeText(value).then( + () => { + setCopied(true) + if (timeoutRef.current) clearTimeout(timeoutRef.current) + timeoutRef.current = setTimeout(() => setCopied(false), timeout) + }, + () => {}, + ) + }, + [timeout], + ) + + return { copied, copy } +} + +function KeyBadge({ + children, + colors, +}: { + children: React.ReactNode + colors: GraphThemeColors +}) { + const style: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 16, + height: 16, + borderRadius: 4, + fontSize: 10, + fontWeight: 500, + backgroundColor: colors.controlBg, + border: `1px solid ${colors.controlBorder}`, + color: colors.popoverTextMuted, + lineHeight: 1, + } + + return {children} +} + +function NavButton({ + icon, + label, + onClick, + colors, +}: { + icon: string + label: string + onClick?: () => void + colors: GraphThemeColors +}) { + const buttonStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 8, + cursor: "pointer", + background: "none", + border: "none", + padding: "2px 0", + transition: "opacity 0.15s", + } + + const labelStyle: React.CSSProperties = { + fontSize: 11, + whiteSpace: "nowrap", + color: colors.popoverTextSecondary, + } + + return ( + + ) +} + +function CopyableId({ + label, + value, + colors, +}: { + label: string + value: string + colors: GraphThemeColors +}) { + const { copied, copy } = useCopyToClipboard() + + const short = + value.length > 12 ? `${value.slice(0, 6)}...${value.slice(-4)}` : value + + const buttonStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: 6, + cursor: "pointer", + background: "none", + border: "none", + padding: 0, + } + + const labelStyle: React.CSSProperties = { + fontSize: 10, + color: colors.popoverTextSecondary, + } + + const valueStyle: React.CSSProperties = { + fontSize: 10, + fontFamily: "monospace", + color: colors.popoverTextMuted, + transition: "color 0.15s", + } + + return ( + + ) +} + +type Quadrant = "right" | "left" | "above" | "below" + +function pickBestQuadrant( + screenX: number, + screenY: number, + nodeRadius: number, + containerWidth: number, + containerHeight: number, + popoverWidth: number, + popoverHeight: number, +): Quadrant { + const gap = 24 + const spaceRight = containerWidth - (screenX + nodeRadius + gap) + const spaceLeft = screenX - nodeRadius - gap + const spaceAbove = screenY - nodeRadius - gap + const spaceBelow = containerHeight - (screenY + nodeRadius + gap) + + const fits: [Quadrant, number][] = [ + ["right", spaceRight >= popoverWidth ? spaceRight : -1], + ["left", spaceLeft >= popoverWidth ? spaceLeft : -1], + ["above", spaceAbove >= popoverHeight ? spaceAbove : -1], + ["below", spaceBelow >= popoverHeight ? spaceBelow : -1], + ] + + const preferred: Quadrant[] = ["right", "left", "below", "above"] + for (const q of preferred) { + const entry = fits.find(([dir]) => dir === q) + if (entry && entry[1] > 0) return q + } + + return fits.sort((a, b) => b[1] - a[1])[0]?.[0] ?? "right" +} + +function truncate(s: string, max: number) { + return s.length > max ? `${s.substring(0, max)}...` : s +} + +function VersionTimeline({ + chain, + currentId, + onSelect, + colors, +}: { + chain: ChainEntry[] + currentId: string + onSelect?: (id: string) => void + colors: GraphThemeColors +}) { + const containerStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + gap: 0, + maxHeight: 120, + overflowY: "auto", + } + + return ( +
+ {chain.map((entry) => { + const isCurrent = entry.id === currentId + + const entryStyle: React.CSSProperties = { + display: "flex", + alignItems: "flex-start", + gap: 8, + paddingLeft: 12, + paddingRight: 12, + paddingTop: 6, + paddingBottom: 6, + textAlign: "left", + cursor: "pointer", + transition: "background-color 0.15s", + background: isCurrent ? `${colors.accent}15` : "transparent", + border: "none", + borderLeft: isCurrent + ? `2px solid ${colors.accent}` + : `2px solid ${colors.popoverBorder}`, + } + + const versionStyle: React.CSSProperties = { + fontSize: 10, + fontWeight: 600, + flexShrink: 0, + marginTop: 1, + color: entry.isForgotten + ? colors.memBorderForgotten + : isCurrent + ? colors.accent + : colors.popoverTextSecondary, + } + + const textStyle: React.CSSProperties = { + fontSize: 11, + lineHeight: 1.35, + color: isCurrent + ? colors.popoverTextPrimary + : colors.popoverTextMuted, + } + + return ( + + ) + })} +
+ ) +} + +export const NodeHoverPopover = memo( + function NodeHoverPopover({ + node, + screenX, + screenY, + nodeRadius, + containerBounds, + versionChain, + colors, + onNavigateNext, + onNavigatePrev, + onNavigateUp, + onNavigateDown, + onSelectNode, + }) { + const CARD_W = 280 + const SHORTCUTS_W = 100 + const GAP = 24 + const TOTAL_W = CARD_W + 12 + SHORTCUTS_W + + const isMemory = node.type === "memory" + const data = node.data + + const memoryMeta = useMemo(() => { + if (!isMemory) return null + const md = data as MemoryNodeData + return { + version: md.version ?? 1, + isLatest: md.isLatest ?? false, + isForgotten: md.isForgotten ?? false, + forgetReason: md.forgetReason ?? null, + forgetAfter: md.forgetAfter ?? null, + } + }, [isMemory, data]) + + const hasChain = versionChain && versionChain.length > 1 + const hasForgetInfo = + memoryMeta && (memoryMeta.isForgotten || memoryMeta.forgetAfter) + + const CARD_H = hasChain ? 200 : hasForgetInfo ? 165 : 135 + const TOTAL_H = CARD_H + + const { popoverX, popoverY, connectorPath } = useMemo(() => { + const cw = containerBounds?.width ?? 800 + const ch = containerBounds?.height ?? 600 + + const quadrant = pickBestQuadrant( + screenX, + screenY, + nodeRadius, + cw, + ch, + TOTAL_W + GAP, + TOTAL_H, + ) + + let px: number + let py: number + let connStart: { x: number; y: number } + + switch (quadrant) { + case "right": + px = screenX + nodeRadius + GAP + py = screenY - TOTAL_H / 2 + connStart = { x: screenX + nodeRadius, y: screenY } + break + case "left": + px = screenX - nodeRadius - GAP - TOTAL_W + py = screenY - TOTAL_H / 2 + connStart = { x: screenX - nodeRadius, y: screenY } + break + case "below": + px = screenX - TOTAL_W / 2 + py = screenY + nodeRadius + GAP + connStart = { x: screenX, y: screenY + nodeRadius } + break + case "above": + px = screenX - TOTAL_W / 2 + py = screenY - nodeRadius - GAP - TOTAL_H + connStart = { x: screenX, y: screenY - nodeRadius } + break + } + + px = Math.max(8, Math.min(cw - TOTAL_W - 8, px)) + py = Math.max(8, Math.min(ch - TOTAL_H - 8, py)) + + const cardCenterX = px + CARD_W / 2 + const cardCenterY = py + TOTAL_H / 2 + const path = `M ${connStart.x} ${connStart.y} L ${cardCenterX} ${cardCenterY}` + + return { popoverX: px, popoverY: py, connectorPath: path } + }, [screenX, screenY, nodeRadius, containerBounds, TOTAL_W, TOTAL_H]) + + const content = useMemo(() => { + if (isMemory) { + const md = data as MemoryNodeData + return md.memory || md.content || "" + } + const dd = data as DocumentNodeData + return dd.summary || dd.title || "" + }, [isMemory, data]) + + const docData = !isMemory ? (data as DocumentNodeData) : null + + const overlayStyle: React.CSSProperties = { + pointerEvents: "none", + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 100, + } + + const svgStyle: React.CSSProperties = { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + overflow: "visible", + pointerEvents: "none", + } + + const popoverContainerStyle: React.CSSProperties = { + position: "absolute", + display: "flex", + gap: 12, + pointerEvents: "auto", + left: popoverX, + top: popoverY, + } + + const cardStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + borderRadius: 12, + overflow: "hidden", + border: `1px solid ${colors.popoverBorder}`, + boxShadow: + "0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1)", + width: CARD_W, + backgroundColor: colors.popoverBg, + } + + const contentPadStyle: React.CSSProperties = { + padding: 12, + } + + const contentTextStyle: React.CSSProperties = { + margin: 0, + lineHeight: "135%", + fontSize: 12, + color: colors.popoverTextSecondary, + } + + const dividerStyle: React.CSSProperties = { + borderTop: `1px solid ${colors.popoverBorder}`, + } + + const footerStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + paddingLeft: 12, + paddingRight: 12, + paddingTop: 8, + paddingBottom: 8, + ...dividerStyle, + } + + const idRowStyle: React.CSSProperties = { + paddingLeft: 12, + paddingRight: 12, + paddingTop: 6, + paddingBottom: 6, + display: "flex", + alignItems: "center", + ...dividerStyle, + } + + const shortcutsPanelStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + justifyContent: "center", + gap: 6, + paddingLeft: 12, + paddingRight: 12, + paddingTop: 8, + paddingBottom: 8, + borderRadius: 12, + border: `1px solid ${colors.popoverBorder}`, + backgroundColor: colors.popoverBg, + } + + return ( +
+ + +
+
+ {hasChain ? ( + + ) : ( +
+

+ {truncate(content, 100) || "No content"} +

+
+ )} + + {memoryMeta && hasForgetInfo && ( +
+ {memoryMeta.forgetAfter && ( + + Expires:{" "} + {new Date(memoryMeta.forgetAfter).toLocaleDateString()} + + )} + {memoryMeta.forgetReason && ( + + Reason: {memoryMeta.forgetReason} + + )} + {memoryMeta.isForgotten && !memoryMeta.forgetReason && ( + + Forgotten + + )} +
+ )} + +
+ {memoryMeta ? ( + + v{memoryMeta.version}{" "} + {memoryMeta.isForgotten + ? "Forgotten" + : memoryMeta.isLatest + ? "Latest" + : "Superseded"} + + ) : ( + <> + + {docData?.type || "document"} + + + {docData?.memories?.length ?? 0} memories + + + )} +
+ +
+ {isMemory ? ( + + ) : ( + + )} +
+
+ +
+ {isMemory && ( + + )} + {(isMemory ? hasChain : true) && ( + + )} + + +
+
+
+ ) + }, +) diff --git a/packages/memory-graph/src/components/node-popover.css.ts b/packages/memory-graph/src/components/node-popover.css.ts deleted file mode 100644 index c758f4b5..00000000 --- a/packages/memory-graph/src/components/node-popover.css.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { style } from "@vanilla-extract/css" - -// Backdrop styles -export const backdrop = style({ - position: "fixed", - zIndex: 999, - pointerEvents: "auto", - backgroundColor: "transparent", -}) - -export const backdropFullscreen = style({ - inset: 0, -}) - -// Popover container -export const popoverContainer = style({ - position: "fixed", - background: "rgba(255, 255, 255, 0.05)", - backdropFilter: "blur(12px)", - WebkitBackdropFilter: "blur(12px)", - border: "1px solid rgba(255, 255, 255, 0.25)", - borderRadius: "12px", - padding: "16px", - width: "320px", - zIndex: 1000, - pointerEvents: "auto", - boxShadow: - "0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3)", -}) - -// Layout -export const contentContainer = style({ - display: "flex", - flexDirection: "column", - gap: "12px", -}) - -export const header = style({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - marginBottom: "4px", -}) - -export const headerTitle = style({ - display: "flex", - alignItems: "center", - gap: "8px", -}) - -export const headerIcon = style({ - color: "rgba(148, 163, 184, 1)", -}) - -export const headerIconMemory = style({ - color: "rgb(96, 165, 250)", -}) - -export const title = style({ - fontSize: "16px", - fontWeight: "700", - color: "white", - margin: 0, -}) - -// Close button -export const closeButton = style({ - padding: "4px", - background: "transparent", - border: "none", - color: "rgba(148, 163, 184, 1)", - cursor: "pointer", - fontSize: "16px", - lineHeight: "1", - transition: "color 0.2s", - ":hover": { - color: "white", - }, -}) - -// Sections -export const sectionsContainer = style({ - display: "flex", - flexDirection: "column", - gap: "12px", -}) - -export const fieldLabel = style({ - fontSize: "11px", - color: "rgba(148, 163, 184, 0.8)", - textTransform: "uppercase", - letterSpacing: "0.05em", - marginBottom: "4px", -}) - -export const fieldValue = style({ - fontSize: "14px", - color: "rgba(203, 213, 225, 1)", - margin: 0, - lineHeight: "1.4", -}) - -export const summaryValue = style({ - fontSize: "14px", - color: "rgba(203, 213, 225, 1)", - margin: 0, - lineHeight: "1.4", - overflow: "hidden", - display: "-webkit-box", - WebkitLineClamp: 2, - WebkitBoxOrient: "vertical", -}) - -// Link -export const link = style({ - fontSize: "14px", - color: "rgb(129, 140, 248)", - textDecoration: "none", - display: "flex", - alignItems: "center", - gap: "4px", - transition: "color 0.2s", - ":hover": { - color: "rgb(165, 180, 252)", - }, -}) - -// Footer -export const footer = style({ - paddingTop: "12px", - borderTop: "1px solid rgba(71, 85, 105, 0.5)", - display: "flex", - alignItems: "center", - gap: "16px", - fontSize: "12px", - color: "rgba(148, 163, 184, 1)", -}) - -export const footerItem = style({ - display: "flex", - alignItems: "center", - gap: "4px", -}) - -export const footerItemId = style({ - display: "flex", - alignItems: "center", - gap: "4px", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - flex: 1, -}) - -export const idText = style({ - overflow: "hidden", - textOverflow: "ellipsis", -}) - -// Memory-specific styles -export const forgottenBadge = style({ - marginTop: "8px", - padding: "4px 8px", - background: "rgba(220, 38, 38, 0.15)", - borderRadius: "4px", - fontSize: "12px", - color: "rgba(248, 113, 113, 1)", - display: "inline-block", -}) - -export const expiresText = style({ - fontSize: "12px", - color: "rgba(148, 163, 184, 1)", - margin: "8px 0 0 0", - lineHeight: "1.4", -}) diff --git a/packages/memory-graph/src/components/node-popover.tsx b/packages/memory-graph/src/components/node-popover.tsx deleted file mode 100644 index 5570038d..00000000 --- a/packages/memory-graph/src/components/node-popover.tsx +++ /dev/null @@ -1,343 +0,0 @@ -"use client" - -import { memo, useEffect } from "react" -import type { GraphNode } from "@/types" -import * as styles from "./node-popover.css" - -export interface NodePopoverProps { - node: GraphNode - x: number // Screen X position - y: number // Screen Y position - onClose: () => void - containerBounds?: DOMRect // Optional container bounds to limit backdrop - onBackdropClick?: () => void // Optional callback when backdrop is clicked -} - -export const NodePopover = memo(function NodePopover({ - node, - x, - y, - onClose, - containerBounds, - onBackdropClick, -}) { - // Handle Escape key to close popover - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose() - } - } - - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [onClose]) - - // Calculate backdrop bounds - use container bounds if provided, otherwise full viewport - const backdropStyle = containerBounds - ? { - left: `${containerBounds.left}px`, - top: `${containerBounds.top}px`, - width: `${containerBounds.width}px`, - height: `${containerBounds.height}px`, - } - : undefined - - const backdropClassName = containerBounds - ? styles.backdrop - : `${styles.backdrop} ${styles.backdropFullscreen}` - - const handleBackdropClick = () => { - onBackdropClick?.() - onClose() - } - - return ( - <> - {/* Invisible backdrop to catch clicks outside */} -
- - {/* Popover content */} -
e.stopPropagation()} // Prevent closing when clicking inside - className={styles.popoverContainer} - style={{ - left: `${x}px`, - top: `${y}px`, - }} - > - {node.type === "document" ? ( - // Document popover -
- {/* Header */} -
-
- - - - - - - -

Document

-
- -
- - {/* Sections */} -
- {/* Title */} -
-
Title
-

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

-
- - {/* Summary - truncated to 2 lines */} - {(node.data as any).summary && ( -
-
Summary
-

- {(node.data as any).summary} -

-
- )} - - {/* Type */} -
-
Type
-

- {(node.data as any).type || "Document"} -

-
- - {/* Memory Count */} -
-
Memory Count
-

- {(node.data as any).memoryEntries?.length || 0} memories -

-
- - {/* URL */} - {((node.data as any).url || (node.data as any).customId) && ( - - )} - - {/* Footer with metadata */} -
-
- - - - - - - - {new Date( - (node.data as any).createdAt, - ).toLocaleDateString()} - -
-
- - - - - - - {node.id} -
-
-
-
- ) : ( - // Memory popover -
- {/* Header */} -
-
- - - - -

Memory

-
- -
- - {/* Sections */} -
- {/* Memory content */} -
-
Memory
-

- {(node.data as any).memory || - (node.data as any).content || - "No content"} -

- {(node.data as any).isForgotten && ( -
Forgotten
- )} - {/* Expires (inline with memory if exists) */} - {(node.data as any).forgetAfter && ( -

- Expires:{" "} - {new Date( - (node.data as any).forgetAfter, - ).toLocaleDateString()} - {(node.data as any).forgetReason && - ` - ${(node.data as any).forgetReason}`} -

- )} -
- - {/* Space */} -
-
Space
-

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

-
- - {/* Footer with metadata */} -
-
- - - - - - - - {new Date( - (node.data as any).createdAt, - ).toLocaleDateString()} - -
-
- - - - - - - {node.id} -
-
-
-
- )} -
- - ) -}) diff --git a/packages/memory-graph/src/components/spaces-dropdown.css.ts b/packages/memory-graph/src/components/spaces-dropdown.css.ts deleted file mode 100644 index 58fa73e4..00000000 --- a/packages/memory-graph/src/components/spaces-dropdown.css.ts +++ /dev/null @@ -1,270 +0,0 @@ -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 - */ -export const trigger = style({ - display: "flex", - alignItems: "center", - gap: themeContract.space[3], - paddingLeft: themeContract.space[4], - paddingRight: themeContract.space[4], - paddingTop: themeContract.space[3], - paddingBottom: themeContract.space[3], - borderRadius: themeContract.radii.xl, - border: "2px solid transparent", - backgroundImage: - "linear-gradient(#1a1f29, #1a1f29), linear-gradient(150.262deg, #A4E8F5 0%, #267FFA 26%, #464646 49%, #747474 70%, #A4E8F5 100%)", - backgroundOrigin: "border-box", - backgroundClip: "padding-box, border-box", - boxShadow: "inset 0px 2px 1px rgba(84, 84, 84, 0.15)", - backdropFilter: "blur(12px)", - WebkitBackdropFilter: "blur(12px)", - transition: themeContract.transitions.normal, - cursor: "pointer", - minWidth: "15rem", // min-w-60 = 240px = 15rem - - selectors: { - "&:hover": { - boxShadow: "inset 0px 2px 1px rgba(84, 84, 84, 0.25)", - }, - }, -}) - -export const triggerIcon = style({ - width: "1rem", - height: "1rem", - color: themeContract.colors.text.secondary, -}) - -export const triggerContent = style({ - flex: 1, - textAlign: "left", -}) - -export const triggerLabel = style({ - fontSize: themeContract.typography.fontSize.sm, - color: themeContract.colors.text.secondary, - fontWeight: themeContract.typography.fontWeight.medium, -}) - -export const triggerSubtext = style({ - fontSize: themeContract.typography.fontSize.xs, - color: themeContract.colors.text.muted, -}) - -export const triggerChevron = style({ - width: "1rem", - height: "1rem", - color: themeContract.colors.text.secondary, - transition: "transform 200ms ease", -}) - -export const triggerChevronOpen = style({ - transform: "rotate(180deg)", -}) - -/** - * Dropdown menu - */ -export const dropdown = style({ - position: "absolute", - top: "100%", - left: 0, - right: 0, - marginTop: themeContract.space[2], - background: "rgba(15, 23, 42, 0.95)", // slate-900/95 - backdropFilter: "blur(12px)", - WebkitBackdropFilter: "blur(12px)", - border: "1px solid rgba(71, 85, 105, 0.4)", // slate-700/40 - borderRadius: themeContract.radii.xl, - boxShadow: - "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", // shadow-xl - zIndex: 20, - overflow: "hidden", -}) - -export const dropdownInner = style({ - padding: themeContract.space[1], -}) - -/** - * 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 - */ -const dropdownItemBase = style({ - width: "100%", - display: "flex", - alignItems: "center", - justifyContent: "space-between", - paddingLeft: themeContract.space[3], - paddingRight: themeContract.space[3], - paddingTop: themeContract.space[2], - paddingBottom: themeContract.space[2], - borderRadius: themeContract.radii.lg, - textAlign: "left", - transition: themeContract.transitions.normal, - cursor: "pointer", - border: "none", - background: "transparent", -}) - -export const dropdownItem = style([ - dropdownItemBase, - { - color: themeContract.colors.text.secondary, - - selectors: { - "&:hover": { - backgroundColor: "rgba(51, 65, 85, 0.5)", // slate-700/50 - }, - }, - }, -]) - -export const dropdownItemActive = style([ - dropdownItemBase, - { - backgroundColor: "rgba(59, 130, 246, 0.2)", // blue-500/20 - color: "rgb(147, 197, 253)", // blue-300 - }, -]) - -export const 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, - flex: 1, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}) - -export const dropdownItemBadge = style({ - backgroundColor: "rgba(51, 65, 85, 0.5)", // slate-700/50 - color: themeContract.colors.text.secondary, - fontSize: themeContract.typography.fontSize.xs, - marginLeft: themeContract.space[2], -}) - -/** - * 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 deleted file mode 100644 index d8a56fe3..00000000 --- a/packages/memory-graph/src/components/spaces-dropdown.tsx +++ /dev/null @@ -1,247 +0,0 @@ -"use client" - -import { Badge } from "@/ui/badge" -import { ChevronDown, Eye, Search, X } from "lucide-react" -import { memo, useEffect, useRef, useState } from "react" -import type { SpacesDropdownProps } from "@/types" -import * as styles from "./spaces-dropdown.css" - -export const SpacesDropdown = memo( - ({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => { - const [isOpen, setIsOpen] = useState(false) - const [searchQuery, setSearchQuery] = useState("") - const [highlightedIndex, setHighlightedIndex] = useState(-1) - const dropdownRef = useRef(null) - const searchInputRef = useRef(null) - const itemRefs = useRef>(new Map()) - - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) - ) { - setIsOpen(false) - } - } - - document.addEventListener("mousedown", handleClickOutside) - return () => document.removeEventListener("mousedown", handleClickOutside) - }, []) - - // 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 ( -
- - - {isOpen && ( -
-
- {/* Search Input - Always show for filtering */} -
-
- {/*@ts-ignore */} - - setSearchQuery(e.target.value)} - placeholder="Search spaces..." - ref={searchInputRef} - type="text" - value={searchQuery} - /> - {searchQuery && ( - - )} -
-
- - {/* Spaces List */} -
- {/* Always show "Latest" option */} - - - {/* Show all spaces, filtered by search query */} - {filteredSpaces.length > 0 - ? filteredSpaces.map((space, index) => { - const itemIndex = index + 1 - return ( - - ) - }) - : searchQuery && ( -
- No spaces found matching "{searchQuery}" -
- )} -
-
-
- )} -
- ) - }, -) - -SpacesDropdown.displayName = "SpacesDropdown" diff --git a/packages/memory-graph/src/constants.ts b/packages/memory-graph/src/constants.ts index e6ab4a26..3361f6f2 100644 --- a/packages/memory-graph/src/constants.ts +++ b/packages/memory-graph/src/constants.ts @@ -1,138 +1,66 @@ -// Enhanced glass-morphism color palette -export const colors = { - background: { - primary: "#0f1419", // Deep dark blue-gray - secondary: "#1a1f29", // Slightly lighter - accent: "#252a35", // Card backgrounds - }, - document: { - primary: "rgba(255, 255, 255, 0.21)", // Subtle glass white - secondary: "rgba(255, 255, 255, 0.31)", // More visible - accent: "rgba(255, 255, 255, 0.31)", // Hover state - border: "rgba(255, 255, 255, 0.6)", // Sharp borders - glow: "rgba(147, 197, 253, 0.4)", // Blue glow for interaction - }, - memory: { - primary: "rgba(147, 196, 253, 0.21)", // Subtle glass blue - secondary: "rgba(147, 196, 253, 0.31)", // More visible - accent: "rgba(147, 197, 253, 0.31)", // Hover state - border: "rgba(147, 196, 253, 0.6)", // Sharp borders - glow: "rgba(147, 197, 253, 0.5)", // Blue glow for interaction - }, - connection: { - weak: "rgba(35, 189, 255, 0.3)", // subtle - memory: "rgba(148, 163, 184, 0.35)", // Very subtle - medium: "rgba(35, 189, 255, 0.6)", // Medium visibility - strong: "rgba(35, 189, 255, 0.9)", // Strong connection - }, - text: { - primary: "#ffffff", // Pure white - secondary: "#e2e8f0", // Light gray - muted: "#94a3b8", // Medium gray - }, - accent: { - primary: "rgba(59, 130, 246, 0.7)", // Clean blue - secondary: "rgba(99, 102, 241, 0.6)", // Clean purple - glow: "rgba(147, 197, 253, 0.6)", // Subtle glow - amber: "rgba(251, 165, 36, 0.8)", // Amber for expiring - emerald: "rgba(16, 185, 129, 0.4)", // Emerald for new - }, - status: { - forgotten: "rgba(220, 38, 38, 0.15)", // Red for forgotten - expiring: "rgba(251, 165, 36, 0.8)", // Amber for expiring soon - new: "rgba(16, 185, 129, 0.4)", // Emerald for new memories - }, - relations: { - updates: "rgba(147, 77, 253, 0.5)", // purple - extends: "rgba(16, 185, 129, 0.5)", // green - derives: "rgba(147, 197, 253, 0.5)", // blue - }, -} +import type { GraphThemeColors } from "./types" -export const LAYOUT_CONSTANTS = { - centerX: 400, - centerY: 300, - clusterRadius: 300, // Memory "bubble" size around a doc - smaller bubble - spaceSpacing: 1600, // How far apart the *spaces* (groups of docs) sit - push spaces way out - documentSpacing: 1000, // How far the first doc in a space sits from its space-centre - push docs way out - minDocDist: 900, // Minimum distance two documents in the **same space** are allowed to be - sets repulsion radius - memoryClusterRadius: 300, -} +export const MEMORY_BORDER_KEYS = { + forgotten: "memBorderForgotten", + expiring: "memBorderExpiring", + recent: "memBorderRecent", + default: "memStrokeDefault", +} as const -// Similarity calculation configuration -export const SIMILARITY_CONFIG = { - threshold: 0.725, // Minimum similarity (72.5%) to create edge - maxComparisonsPerDoc: 10, // k-NN: each doc compares with 10 neighbors (optimized for performance) -} - -// D3-Force simulation configuration export const FORCE_CONFIG = { - // Link force (spring between connected nodes)simil linkStrength: { - docMemory: 0.8, // Strong for doc-memory connections - version: 1.0, // Strongest for version chains - docDocBase: 0.3, // Base for doc-doc similarity + docMemory: 0.35, + version: 0.6, + docDocBase: 0.0, + fallback: 0.05, }, - linkDistance: 300, // Desired spring length - - // Charge force (repulsion between nodes) - chargeStrength: -1000, // Negative = repulsion, higher magnitude = stronger push - - // Collision force (prevents node overlap) - collisionRadius: { - document: 80, // Collision radius for document nodes - memory: 40, // Collision radius for memory nodes - }, - - // Simulation behavior - alphaDecay: 0.03, // How fast simulation cools down (higher = faster cooldown) - alphaMin: 0.001, // Threshold to stop simulation (when alpha drops below this) - velocityDecay: 0.6, // Friction/damping (0 = no friction, 1 = instant stop) - increased for less movement - alphaTarget: 0.3, // Target alpha when reheating (on drag start) + linkDistance: 300, + docMemoryDistance: 180, + chargeStrength: -2000, + collisionRadius: { document: 70, memory: 35 }, + collisionStrength: 0.7, + centeringStrength: 0.06, + alphaDecay: 0.025, + alphaMin: 0.001, + velocityDecay: 0.45, + alphaTarget: 0.3, + preSettleTicks: 150, } -// Graph view settings export const GRAPH_SETTINGS = { - console: { - initialZoom: 0.8, // Higher zoom for console - better overview - initialPanX: 0, - initialPanY: 0, - }, - consumer: { - initialZoom: 0.5, // Changed from 0.1 to 0.5 for better initial visibility - initialPanX: 400, // Pan towards center to compensate for larger layout - initialPanY: 300, // Pan towards center to compensate for larger layout - }, + console: { initialZoom: 0.8, initialPanX: 0, initialPanY: 0 }, + consumer: { initialZoom: 0.5, initialPanX: 400, initialPanY: 300 }, } -// Animation settings export const ANIMATION = { - // Dim effect duration - shortened for better UX - dimDuration: 1500, // milliseconds + dimDuration: 1500, } -// Responsive positioning for different app variants -export const POSITIONING = { - console: { - legend: { - desktop: "bottom-4 right-4", - mobile: "bottom-4 right-4", - }, - loadingIndicator: "top-20 right-4", - - spacesSelector: "top-4 left-4", - viewToggle: "", // Not used in console - nodeDetail: "top-4 right-4", - }, - consumer: { - legend: { - desktop: "top-18 right-4", - mobile: "bottom-[180px] left-4", - }, - loadingIndicator: "top-20 right-4", - - spacesSelector: "", // Hidden in consumer - viewToggle: "top-4 right-4", // Consumer has view toggle - nodeDetail: "top-4 right-4", - }, +export const DEFAULT_COLORS: GraphThemeColors = { + bg: "#0f1419", + docFill: "#1B1F24", + docStroke: "#2A2F36", + docInnerFill: "#13161A", + memFill: "#0D2034", + memFillHover: "#112840", + memStrokeDefault: "#3B73B8", + accent: "#3B73B8", + textPrimary: "#ffffff", + textSecondary: "#e2e8f0", + textMuted: "#94a3b8", + edgeDerives: "#7094B8", + edgeUpdates: "#A78BFA", + edgeExtends: "#38BDF8", + memBorderForgotten: "#EF4444", + memBorderExpiring: "#F59E0B", + memBorderRecent: "#10B981", + glowColor: "#3B73B8", + iconColor: "#3B73B8", + popoverBg: "#1a1f29", + popoverBorder: "#2A2F36", + popoverTextPrimary: "#ffffff", + popoverTextSecondary: "#e2e8f0", + popoverTextMuted: "#94a3b8", + controlBg: "#1a1f29", + controlBorder: "#2A2F36", } diff --git a/packages/memory-graph/src/hooks/use-force-simulation.ts b/packages/memory-graph/src/hooks/use-force-simulation.ts deleted file mode 100644 index 32c38962..00000000 --- a/packages/memory-graph/src/hooks/use-force-simulation.ts +++ /dev/null @@ -1,179 +0,0 @@ -"use client" - -import { useEffect, useRef, useCallback } from "react" -import * as d3 from "d3-force" -import { FORCE_CONFIG } from "@/constants" -import type { GraphNode, GraphEdge } from "@/types" - -export interface ForceSimulationControls { - /** The d3 simulation instance */ - simulation: d3.Simulation | null - /** Reheat the simulation (call on drag start) */ - reheat: () => void - /** Cool down the simulation (call on drag end) */ - coolDown: () => void - /** Check if simulation is currently active */ - isActive: () => boolean - /** Stop the simulation completely */ - stop: () => void - /** Get current alpha value */ - getAlpha: () => number -} - -/** - * Custom hook to manage d3-force simulation lifecycle - * Simulation only runs during interactions (drag) for performance - */ -export function useForceSimulation( - nodes: GraphNode[], - edges: GraphEdge[], - onTick: () => void, - enabled = true, -): ForceSimulationControls { - const simulationRef = useRef | null>(null) - - // Initialize simulation ONCE - useEffect(() => { - if (!enabled || nodes.length === 0) { - return - } - - // Only create simulation once - if (!simulationRef.current) { - const simulation = d3 - .forceSimulation(nodes) - .alphaDecay(FORCE_CONFIG.alphaDecay) - .alphaMin(FORCE_CONFIG.alphaMin) - .velocityDecay(FORCE_CONFIG.velocityDecay) - .on("tick", () => { - // Trigger re-render by calling onTick - // D3 has already mutated node.x and node.y - onTick() - }) - - // Configure forces - // 1. Link force - spring connections between nodes - simulation.force( - "link", - d3 - .forceLink(edges) - .id((d) => d.id) - .distance(FORCE_CONFIG.linkDistance) - .strength((link) => { - // Different strength based on edge type - if (link.edgeType === "doc-memory") { - return FORCE_CONFIG.linkStrength.docMemory - } - if (link.edgeType === "version") { - return FORCE_CONFIG.linkStrength.version - } - // doc-doc: variable strength based on similarity - return link.similarity * FORCE_CONFIG.linkStrength.docDocBase - }), - ) - - // 2. Charge force - repulsion between nodes - simulation.force( - "charge", - d3.forceManyBody().strength(FORCE_CONFIG.chargeStrength), - ) - - // 3. Collision force - prevent node overlap - simulation.force( - "collide", - d3 - .forceCollide() - .radius((d) => - d.type === "document" - ? FORCE_CONFIG.collisionRadius.document - : FORCE_CONFIG.collisionRadius.memory, - ) - .strength(0.7), - ) - - // 4. forceX and forceY - weak centering forces (like reference code) - simulation.force("x", d3.forceX().strength(0.05)) - simulation.force("y", d3.forceY().strength(0.05)) - - // Store reference - simulationRef.current = simulation - - // Quick pre-settle to avoid initial chaos, then animate the rest - // This gives best of both worlds: fast initial render + smooth settling - simulation.alpha(1) - for (let i = 0; i < 50; ++i) simulation.tick() // Just 50 ticks = ~5-10ms - simulation.alphaTarget(0).restart() // Continue animating to full stability - } - - // Cleanup on unmount - return () => { - if (simulationRef.current) { - simulationRef.current.stop() - simulationRef.current = null - } - } - // Only run on mount/unmount, not when nodes/edges/onTick change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled]) - - // Update simulation nodes and edges together to prevent race conditions - useEffect(() => { - if (!simulationRef.current) return - - // Update nodes - if (nodes.length > 0) { - simulationRef.current.nodes(nodes) - } - - // Update edges - if (edges.length > 0) { - const linkForce = - simulationRef.current.force>("link") - if (linkForce) { - linkForce.links(edges) - } - } - }, [nodes, edges]) - - // Reheat simulation (called on drag start) - const reheat = useCallback(() => { - if (simulationRef.current) { - simulationRef.current.alphaTarget(FORCE_CONFIG.alphaTarget).restart() - } - }, []) - - // Cool down simulation (called on drag end) - const coolDown = useCallback(() => { - if (simulationRef.current) { - simulationRef.current.alphaTarget(0) - } - }, []) - - // Check if simulation is active - const isActive = useCallback(() => { - if (!simulationRef.current) return false - return simulationRef.current.alpha() > FORCE_CONFIG.alphaMin - }, []) - - // Stop simulation completely - const stop = useCallback(() => { - if (simulationRef.current) { - simulationRef.current.stop() - } - }, []) - - // Get current alpha - const getAlpha = useCallback(() => { - if (!simulationRef.current) return 0 - return simulationRef.current.alpha() - }, []) - - return { - simulation: simulationRef.current, - reheat, - coolDown, - isActive, - stop, - getAlpha, - } -} diff --git a/packages/memory-graph/src/hooks/use-graph-data.ts b/packages/memory-graph/src/hooks/use-graph-data.ts index a94bebdc..3fe7ace0 100644 --- a/packages/memory-graph/src/hooks/use-graph-data.ts +++ b/packages/memory-graph/src/hooks/use-graph-data.ts @@ -1,471 +1,245 @@ -"use client" - -import { - calculateSemanticSimilarity, - getConnectionVisualProps, - getMagicalConnectionColor, -} from "@/lib/similarity" -import { useMemo, useRef, useEffect } from "react" -import { colors, LAYOUT_CONSTANTS, SIMILARITY_CONFIG } from "@/constants" +import { useEffect, useMemo, useRef } from "react" import type { - DocumentsResponse, - DocumentWithMemories, + DocumentNodeData, + GraphApiDocument, + GraphApiMemory, GraphEdge, GraphNode, - MemoryEntry, - MemoryRelation, -} from "@/types" + GraphThemeColors, + MemoryNodeData, +} from "../types" + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000 +const ONE_DAY_MS = 24 * 60 * 60 * 1000 +const MEMORY_ORBIT_BASE = 200 + +export function getMemoryBorderColor( + mem: GraphApiMemory, + colors: GraphThemeColors, +): string { + if (mem.isForgotten) return colors.memBorderForgotten + if (mem.forgetAfter) { + const msLeft = new Date(mem.forgetAfter).getTime() - Date.now() + if (msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring + } + const age = Date.now() - new Date(mem.createdAt).getTime() + if (age < ONE_DAY_MS) return colors.memBorderRecent + return colors.memStrokeDefault +} + +export function getEdgeVisualProps(edgeType: string) { + switch (edgeType) { + case "derives": + return { opacity: 0.4, thickness: 1.2 } + case "updates": + return { opacity: 0.7, thickness: 2 } + case "extends": + return { opacity: 0.55, thickness: 1.5 } + default: + return { opacity: 0.4, thickness: 1.2 } + } +} + +/** + * Simple deterministic hash of a string to a number in [0, 1). + * Used for initial node placement so the force simulation has a + * deterministic starting layout. + */ +function hashToUnit(str: string): number { + let h = 0 + for (let i = 0; i < str.length; i++) { + h = (Math.imul(31, h) + str.charCodeAt(i)) | 0 + } + return ((h >>> 0) % 10000) / 10000 +} + +/** + * Pure function that computes graph edges from documents. + * Extracted from the hook for testability. + */ +export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] { + if (!documents || documents.length === 0) return [] + + const result: GraphEdge[] = [] + const allNodeIds = new Set() + for (const doc of documents) { + allNodeIds.add(doc.id) + for (const mem of doc.memories) allNodeIds.add(mem.id) + } + + // 1. Derives edges: document -> memory (structural) + for (const doc of documents) { + for (const mem of doc.memories) { + result.push({ + id: `dm-${doc.id}-${mem.id}`, + source: doc.id, + target: mem.id, + visualProps: getEdgeVisualProps("derives"), + edgeType: "derives", + }) + } + } + + // 2. Memory-to-memory relation edges from backend data. + // Uses memoryRelations (Record) as primary source, + // falls back to parentMemoryId for legacy data. + for (const doc of documents) { + for (const mem of doc.memories) { + let relations: Record = {} + + // Defensive: API may return unexpected types at runtime + if ( + mem.memoryRelations && + typeof mem.memoryRelations === "object" && + Object.keys(mem.memoryRelations).length > 0 + ) { + relations = mem.memoryRelations + } else if (mem.parentMemoryId) { + // Legacy fallback: parentMemoryId implies "updates" + relations = { [mem.parentMemoryId]: "updates" } + } + + for (const [targetId, relationType] of Object.entries(relations)) { + if (!allNodeIds.has(targetId)) continue + const edgeType = + relationType === "updates" || + relationType === "extends" || + relationType === "derives" + ? relationType + : "updates" + result.push({ + id: `rel-${targetId}-${mem.id}`, + source: targetId, + target: mem.id, + visualProps: getEdgeVisualProps(edgeType), + edgeType, + }) + } + } + } + + return result +} export function useGraphData( - data: DocumentsResponse | null, - selectedSpace: string, - nodePositions: Map< - string, - { - x: number - y: number - parentDocId?: string - offsetX?: number - offsetY?: number - } - >, + documents: GraphApiDocument[], draggingNodeId: string | null, - memoryLimit?: number, - maxNodes?: number, + canvasWidth: number, + canvasHeight: number, + colors: GraphThemeColors, ) { - // Cache nodes to preserve d3-force mutations (x, y, vx, vy, fx, fy) const nodeCache = useRef>(new Map()) - // Cleanup nodeCache to prevent memory leak useEffect(() => { - if (!data?.documents) return + if (!documents || documents.length === 0) return - // Build set of current node IDs - const currentNodeIds = new Set() - data.documents.forEach((doc) => { - currentNodeIds.add(doc.id) - doc.memoryEntries.forEach((mem) => { - currentNodeIds.add(`${mem.id}`) - }) - }) + const currentIds = new Set() + for (const doc of documents) { + currentIds.add(doc.id) + for (const mem of doc.memories) currentIds.add(mem.id) + } - // Remove stale nodes from cache for (const [id] of nodeCache.current.entries()) { - if (!currentNodeIds.has(id)) { - nodeCache.current.delete(id) - } + if (!currentIds.has(id)) nodeCache.current.delete(id) } - }, [data, selectedSpace]) + }, [documents]) - // Memo 1: Filter documents by selected space and apply node limits - const filteredDocuments = useMemo(() => { - if (!data?.documents) return [] + const nodes = useMemo(() => { + if (!documents || documents.length === 0) return [] - // Sort documents by most recent first - const sortedDocs = [...data.documents].sort((a, b) => { - const dateA = new Date(a.updatedAt || a.createdAt).getTime() - const dateB = new Date(b.updatedAt || b.createdAt).getTime() - return dateB - dateA // Most recent first - }) + const result: GraphNode[] = [] + // Spiral layout: documents form a compact spiral core, memories orbit + // around their parent documents. The force simulation then gently + // pushes memories outward to create the constellation/starburst effect. + const cx = canvasWidth / 2 + const cy = canvasHeight / 2 + const docCount = documents.length + // Wide spiral so documents start well-separated. The simulation + // refines positions but the initial spread prevents clustering. + const spiralScale = Math.sqrt(docCount) * 60 + // Golden angle (~137.5 deg) produces optimal packing in a spiral + const goldenAngle = Math.PI * (3 - Math.sqrt(5)) - // Filter by space and prepare documents - let processedDocs = sortedDocs.map((doc) => { - let memories = - selectedSpace === "all" - ? doc.memoryEntries - : doc.memoryEntries.filter( - (memory) => - (memory.spaceContainerTag ?? memory.spaceId ?? "default") === - selectedSpace, - ) + for (let docIdx = 0; docIdx < docCount; docIdx++) { + const doc = documents[docIdx] + const angle = docIdx * goldenAngle + const radius = spiralScale * Math.sqrt((docIdx + 1) / docCount) + const initialX = cx + Math.cos(angle) * radius + const initialY = cy + Math.sin(angle) * radius - // Sort memories by relevance score (if available) or recency - memories = memories.sort((a, b) => { - // Prioritize sourceRelevanceScore if available - if (a.sourceRelevanceScore != null && b.sourceRelevanceScore != null) { - return b.sourceRelevanceScore - a.sourceRelevanceScore // Higher score first - } - // Fall back to most recent - const dateA = new Date(a.updatedAt || a.createdAt).getTime() - const dateB = new Date(b.updatedAt || b.createdAt).getTime() - return dateB - dateA // Most recent first - }) - - return { - ...doc, - memoryEntries: memories, + let docNode = nodeCache.current.get(doc.id) + const docData: DocumentNodeData = { + id: doc.id, + title: doc.title, + summary: doc.summary, + type: doc.documentType, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + memories: doc.memories, } - }) - // Apply maxNodes limit using Option B (dynamic cap per document) - if (maxNodes && maxNodes > 0) { - const totalDocs = processedDocs.length - if (totalDocs > 0) { - // Calculate memories per document to stay within maxNodes budget - const memoriesPerDoc = Math.floor(maxNodes / totalDocs) + if (docNode) { + docNode.data = docData + docNode.borderColor = colors.docStroke + docNode.isDragging = draggingNodeId === doc.id + } else { + docNode = { + id: doc.id, + type: "document", + x: initialX, + y: initialY, + data: docData, + size: 50, + borderColor: colors.docStroke, + isHovered: false, + isDragging: false, + } + nodeCache.current.set(doc.id, docNode) + } + result.push(docNode) - // If we need to limit, slice memories for each document - if (memoriesPerDoc > 0) { - let totalNodes = 0 - processedDocs = processedDocs.map((doc) => { - // Limit memories to calculated amount per doc - const limitedMemories = doc.memoryEntries.slice(0, memoriesPerDoc) - totalNodes += limitedMemories.length - return { - ...doc, - memoryEntries: limitedMemories, - } - }) + const memCount = doc.memories.length + for (let i = 0; i < memCount; i++) { + const mem = doc.memories[i] + if (!mem) continue + let memNode = nodeCache.current.get(mem.id) + const memData: MemoryNodeData = { + ...mem, + documentId: doc.id, + content: mem.memory, + } - // If we still have budget left, distribute remaining nodes to first docs - let remainingBudget = maxNodes - totalNodes - if (remainingBudget > 0) { - for ( - let i = 0; - i < processedDocs.length && remainingBudget > 0; - i++ - ) { - const doc = processedDocs[i] - if (!doc) continue - const originalDoc = sortedDocs.find((d) => d.id === doc.id) - if (!originalDoc) continue - - const currentMemCount = doc.memoryEntries.length - const originalMemCount = originalDoc.memoryEntries.filter( - (m) => - selectedSpace === "all" || - (m.spaceContainerTag ?? m.spaceId ?? "default") === - selectedSpace, - ).length - - // Can we add more memories to this doc? - const canAdd = originalMemCount - currentMemCount - if (canAdd > 0) { - const toAdd = Math.min(canAdd, remainingBudget) - const additionalMems = doc.memoryEntries.slice( - 0, - currentMemCount + toAdd, - ) - processedDocs[i] = { - ...doc, - memoryEntries: originalDoc.memoryEntries - .filter( - (m) => - selectedSpace === "all" || - (m.spaceContainerTag ?? m.spaceId ?? "default") === - selectedSpace, - ) - .sort((a, b) => { - if ( - a.sourceRelevanceScore != null && - b.sourceRelevanceScore != null - ) { - return b.sourceRelevanceScore - a.sourceRelevanceScore - } - const dateA = new Date( - a.updatedAt || a.createdAt, - ).getTime() - const dateB = new Date( - b.updatedAt || b.createdAt, - ).getTime() - return dateB - dateA - }) - .slice(0, currentMemCount + toAdd), - } - remainingBudget -= toAdd - } - } - } + if (memNode) { + memNode.data = memData + memNode.borderColor = getMemoryBorderColor(mem, colors) + memNode.isDragging = draggingNodeId === mem.id } else { - // If memoriesPerDoc is 0, we need to limit the number of documents shown - // Show at least 1 memory per document, up to maxNodes documents - processedDocs = processedDocs.slice(0, maxNodes).map((doc) => ({ - ...doc, - memoryEntries: doc.memoryEntries.slice(0, 1), - })) - } - } - } - // Apply legacy memoryLimit if provided and a specific space is selected - else if (selectedSpace !== "all" && memoryLimit && memoryLimit > 0) { - processedDocs = processedDocs.map((doc) => ({ - ...doc, - memoryEntries: doc.memoryEntries.slice(0, memoryLimit), - })) - } - - return processedDocs - }, [data, selectedSpace, memoryLimit, maxNodes]) - - // Memo 2: Calculate similarity edges using k-NN approach - const similarityEdges = useMemo(() => { - const edges: GraphEdge[] = [] - - // k-NN: Each document compares with k neighbors (configurable) - const { maxComparisonsPerDoc, threshold } = SIMILARITY_CONFIG - - for (let i = 0; i < filteredDocuments.length; i++) { - const docI = filteredDocuments[i] - if (!docI) continue - - // Only compare with next k documents (k-nearest neighbors approach) - const endIdx = Math.min( - i + maxComparisonsPerDoc + 1, - filteredDocuments.length, - ) - - for (let j = i + 1; j < endIdx; j++) { - const docJ = filteredDocuments[j] - if (!docJ) continue - - const sim = calculateSemanticSimilarity( - docI.summaryEmbedding ? Array.from(docI.summaryEmbedding) : null, - docJ.summaryEmbedding ? Array.from(docJ.summaryEmbedding) : null, - ) - - if (sim > threshold) { - edges.push({ - id: `doc-doc-${docI.id}-${docJ.id}`, - source: docI.id, - target: docJ.id, - similarity: sim, - visualProps: getConnectionVisualProps(sim), - color: getMagicalConnectionColor(sim, 200), - edgeType: "doc-doc", - }) - } - } - } - - return edges - }, [filteredDocuments]) - - // Memo 3: Build full graph data (nodes + edges) - return useMemo(() => { - if (!data?.documents || filteredDocuments.length === 0) { - return { nodes: [], edges: [] } - } - - const allNodes: GraphNode[] = [] - const allEdges: GraphEdge[] = [] - - // Group documents by space for better clustering - const documentsBySpace = new Map() - filteredDocuments.forEach((doc) => { - const docSpace = - doc.memoryEntries[0]?.spaceContainerTag ?? - doc.memoryEntries[0]?.spaceId ?? - "default" - if (!documentsBySpace.has(docSpace)) { - documentsBySpace.set(docSpace, []) - } - const spaceDocsArr = documentsBySpace.get(docSpace) - if (spaceDocsArr) { - spaceDocsArr.push(doc) - } - }) - - // Enhanced Layout with Space Separation - const { centerX, centerY, clusterRadius } = LAYOUT_CONSTANTS - - /* 1. Build DOCUMENT nodes with space-aware clustering */ - const documentNodes: GraphNode[] = [] - let spaceIndex = 0 - - documentsBySpace.forEach((spaceDocs) => { - spaceDocs.forEach((doc, docIndex) => { - // Simple grid-like layout that physics will naturally organize - // Start documents near the center with some random offset - const gridSize = Math.ceil(Math.sqrt(spaceDocs.length)) - const row = Math.floor(docIndex / gridSize) - const col = docIndex % gridSize - - // Loose grid spacing - physics will organize it better - const spacing = 200 - const defaultX = - centerX + (col - gridSize / 2) * spacing + (Math.random() - 0.5) * 50 - const defaultY = - centerY + (row - gridSize / 2) * spacing + (Math.random() - 0.5) * 50 - - const customPos = nodePositions.get(doc.id) - - // Check if node exists in cache (preserves d3-force mutations) - let node = nodeCache.current.get(doc.id) - if (node) { - // Update existing node's data, preserve physics properties (x, y, vx, vy, fx, fy) - node.data = doc - node.isDragging = draggingNodeId === doc.id - // Don't reset x/y - they're managed by d3-force - } else { - // Create new node with initial position - node = { - id: doc.id, - type: "document", - x: customPos?.x ?? defaultX, - y: customPos?.y ?? defaultY, - data: doc, - size: 58, - color: colors.document.primary, + // Place memories in a ring around their parent document, + // with slight randomness from hash for organic feel + const memAngle = + (i / memCount) * 2 * Math.PI + hashToUnit(mem.id) * 0.5 + const memRadius = MEMORY_ORBIT_BASE + hashToUnit(`${mem.id}-r`) * 120 + memNode = { + id: mem.id, + type: "memory", + x: docNode.x + Math.cos(memAngle) * memRadius, + y: docNode.y + Math.sin(memAngle) * memRadius, + data: memData, + size: 36, + borderColor: getMemoryBorderColor(mem, colors), isHovered: false, - isDragging: draggingNodeId === doc.id, - } satisfies GraphNode - nodeCache.current.set(doc.id, node) - } - - documentNodes.push(node) - }) - - spaceIndex++ - }) - - /* 2. Manual collision avoidance removed - now handled by d3-force simulation */ - // The initial circular layout provides good starting positions - // D3-force will handle collision avoidance and spacing dynamically - - allNodes.push(...documentNodes) - - /* 3. Add memories around documents WITH doc-memory connections */ - documentNodes.forEach((docNode) => { - const memoryNodeMap = new Map() - const doc = docNode.data as DocumentWithMemories - - doc.memoryEntries.forEach((memory, memIndex) => { - const memoryId = `${memory.id}` - const customMemPos = nodePositions.get(memoryId) - - // Simple circular positioning around parent doc - // Physics will naturally cluster them better - const angle = (memIndex / doc.memoryEntries.length) * Math.PI * 2 - const distance = clusterRadius * 1 // Closer to parent, let physics separate - - const defaultMemX = docNode.x + Math.cos(angle) * distance - const defaultMemY = docNode.y + Math.sin(angle) * distance - - // Calculate final position - let finalMemX = defaultMemX - let finalMemY = defaultMemY - - if (customMemPos) { - // If memory was manually positioned and has stored offset relative to parent - if ( - customMemPos.parentDocId === docNode.id && - customMemPos.offsetX !== undefined && - customMemPos.offsetY !== undefined - ) { - // Apply the stored offset to the current document position - finalMemX = docNode.x + customMemPos.offsetX - finalMemY = docNode.y + customMemPos.offsetY - } else { - // Fallback: use absolute position (for backward compatibility or if parent changed) - finalMemX = customMemPos.x - finalMemY = customMemPos.y + isDragging: false, } + nodeCache.current.set(mem.id, memNode) } - - if (!memoryNodeMap.has(memoryId)) { - // Check if memory node exists in cache (preserves d3-force mutations) - let memoryNode = nodeCache.current.get(memoryId) - if (memoryNode) { - // Update existing node's data, preserve physics properties - memoryNode.data = memory - memoryNode.isDragging = draggingNodeId === memoryId - // Don't reset x/y - they're managed by d3-force - } else { - // Create new node with initial position - memoryNode = { - id: memoryId, - type: "memory", - x: finalMemX, - y: finalMemY, - data: memory, - size: Math.max( - 32, - Math.min(48, (memory.memory?.length || 50) * 0.5), - ), - color: colors.memory.primary, - isHovered: false, - isDragging: draggingNodeId === memoryId, - } - nodeCache.current.set(memoryId, memoryNode) - } - memoryNodeMap.set(memoryId, memoryNode) - allNodes.push(memoryNode) - } - - // Create doc-memory edge with similarity - allEdges.push({ - id: `edge-${docNode.id}-${memory.id}`, - source: docNode.id, - target: memoryId, - similarity: 1, - visualProps: getConnectionVisualProps(1), - color: colors.connection.memory, - edgeType: "doc-memory", - }) - }) - }) - - // Build mapping of memoryId -> nodeId for version chains - const memNodeIdMap = new Map() - allNodes.forEach((n) => { - if (n.type === "memory") { - memNodeIdMap.set((n.data as MemoryEntry).id, n.id) + result.push(memNode) } - }) + } - // 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 = - (mem.memoryRelations ?? {}) as Record + return result + }, [documents, canvasWidth, canvasHeight, draggingNodeId, colors]) - if ( - mem.memoryRelations && - Array.isArray(mem.memoryRelations) && - mem.memoryRelations.length > 0 - ) { - // Convert array to Record - parentRelations = mem.memoryRelations.reduce( - (acc, rel) => { - acc[rel.targetMemoryId] = rel.relationType - return acc - }, - {} as Record, - ) - } else if (mem.parentMemoryId) { - parentRelations = { - [mem.parentMemoryId]: "updates" as MemoryRelation, - } - } - Object.entries(parentRelations).forEach(([pid, relationType]) => { - const fromId = memNodeIdMap.get(pid) - const toId = memNodeIdMap.get(mem.id) - if (fromId && toId) { - allEdges.push({ - id: `version-${fromId}-${toId}`, - source: fromId, - target: toId, - similarity: 1, - visualProps: { - opacity: 0.8, - thickness: 1, - glow: 0, - pulseDuration: 3000, - }, - // choose color based on relation type - color: colors.relations[relationType] ?? colors.relations.updates, - edgeType: "version", - relationType: relationType as MemoryRelation, - }) - } - }) - }) - }) + const edges = useMemo(() => computeEdges(documents), [documents]) - // Append similarity edges (calculated in separate memo) - allEdges.push(...similarityEdges) - - return { nodes: allNodes, edges: allEdges } - }, [data, filteredDocuments, nodePositions, draggingNodeId, similarityEdges]) + return { nodes, edges } } diff --git a/packages/memory-graph/src/hooks/use-graph-interactions.ts b/packages/memory-graph/src/hooks/use-graph-interactions.ts deleted file mode 100644 index d9044ad3..00000000 --- a/packages/memory-graph/src/hooks/use-graph-interactions.ts +++ /dev/null @@ -1,606 +0,0 @@ -"use client" - -import { useCallback, useRef, useState } from "react" -import { GRAPH_SETTINGS } from "@/constants" -import type { GraphNode } from "@/types" - -export function useGraphInteractions( - variant: "console" | "consumer" = "console", -) { - const settings = GRAPH_SETTINGS[variant] - - const [panX, setPanX] = useState(settings.initialPanX) - const [panY, setPanY] = useState(settings.initialPanY) - const [zoom, setZoom] = useState(settings.initialZoom) - const [isPanning, setIsPanning] = useState(false) - const [panStart, setPanStart] = useState({ x: 0, y: 0 }) - const [hoveredNode, setHoveredNode] = useState(null) - const [selectedNode, setSelectedNode] = useState(null) - const [draggingNodeId, setDraggingNodeId] = useState(null) - const [dragStart, setDragStart] = useState({ - x: 0, - y: 0, - nodeX: 0, - nodeY: 0, - }) - const [nodePositions, setNodePositions] = useState< - Map< - string, - { - x: number - y: number - parentDocId?: string - offsetX?: number - offsetY?: number - } - > - >(new Map()) - - // Touch gesture state - const [touchState, setTouchState] = useState<{ - touches: { id: number; x: number; y: number }[] - lastDistance: number - lastCenter: { x: number; y: number } - isGesturing: boolean - }>({ - touches: [], - lastDistance: 0, - lastCenter: { x: 0, y: 0 }, - isGesturing: false, - }) - - // Animation state for smooth transitions - const animationRef = useRef(null) - const [isAnimating, setIsAnimating] = useState(false) - - // Smooth animation helper - const animateToViewState = useCallback( - ( - targetPanX: number, - targetPanY: number, - targetZoom: number, - duration = 300, - ) => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current) - } - - const startPanX = panX - const startPanY = panY - const startZoom = zoom - const startTime = Date.now() - - setIsAnimating(true) - - const animate = () => { - const elapsed = Date.now() - startTime - const progress = Math.min(elapsed / duration, 1) - - // Ease out cubic function for smooth transitions - const easeOut = 1 - (1 - progress) ** 3 - - const currentPanX = startPanX + (targetPanX - startPanX) * easeOut - const currentPanY = startPanY + (targetPanY - startPanY) * easeOut - const currentZoom = startZoom + (targetZoom - startZoom) * easeOut - - setPanX(currentPanX) - setPanY(currentPanY) - setZoom(currentZoom) - - if (progress < 1) { - animationRef.current = requestAnimationFrame(animate) - } else { - setIsAnimating(false) - animationRef.current = null - } - } - - animate() - }, - [panX, panY, zoom], - ) - - // Node drag handlers - const handleNodeDragStart = useCallback( - (nodeId: string, e: React.MouseEvent, nodes?: GraphNode[]) => { - const node = nodes?.find((n) => n.id === nodeId) - if (!node) return - - setDraggingNodeId(nodeId) - setDragStart({ - x: e.clientX, - y: e.clientY, - nodeX: node.x, - nodeY: node.y, - }) - }, - [], - ) - - const handleNodeDragMove = useCallback( - (e: React.MouseEvent, nodes?: GraphNode[]) => { - if (!draggingNodeId) return - - const deltaX = (e.clientX - dragStart.x) / zoom - const deltaY = (e.clientY - dragStart.y) / zoom - - const newX = dragStart.nodeX + deltaX - const newY = dragStart.nodeY + deltaY - - // Find the node being dragged to determine if it's a memory - const draggedNode = nodes?.find((n) => n.id === draggingNodeId) - - if (draggedNode?.type === "memory") { - // For memory nodes, find the parent document and store relative offset - const memoryData = draggedNode.data as any // MemoryEntry type - const parentDoc = nodes?.find( - (n) => - n.type === "document" && - (n.data as any).memoryEntries?.some( - (m: any) => m.id === memoryData.id, - ), - ) - - if (parentDoc) { - // Store the offset from the parent document - const offsetX = newX - parentDoc.x - const offsetY = newY - parentDoc.y - - setNodePositions((prev) => - new Map(prev).set(draggingNodeId, { - x: newX, - y: newY, - parentDocId: parentDoc.id, - offsetX, - offsetY, - }), - ) - return - } - } - - // For document nodes or if parent not found, just store absolute position - setNodePositions((prev) => - new Map(prev).set(draggingNodeId, { x: newX, y: newY }), - ) - }, - [draggingNodeId, dragStart, zoom], - ) - - const handleNodeDragEnd = useCallback(() => { - setDraggingNodeId(null) - }, []) - - // Pan handlers - const handlePanStart = useCallback( - (e: React.MouseEvent) => { - setIsPanning(true) - setPanStart({ x: e.clientX - panX, y: e.clientY - panY }) - }, - [panX, panY], - ) - - const handlePanMove = useCallback( - (e: React.MouseEvent) => { - if (!isPanning || draggingNodeId) return - - const newPanX = e.clientX - panStart.x - const newPanY = e.clientY - panStart.y - setPanX(newPanX) - setPanY(newPanY) - }, - [isPanning, panStart, draggingNodeId], - ) - - const handlePanEnd = useCallback(() => { - setIsPanning(false) - }, []) - - // Zoom handlers - const handleWheel = useCallback( - (e: React.WheelEvent) => { - // Always prevent default to stop browser navigation - e.preventDefault() - e.stopPropagation() - - // Handle horizontal scrolling (trackpad swipe) by converting to pan - if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { - // Horizontal scroll - pan the graph instead of zooming - const panDelta = e.deltaX * 0.5 - setPanX((prev) => prev - panDelta) - return - } - - // Vertical scroll - zoom behavior - const delta = e.deltaY > 0 ? 0.97 : 1.03 - const newZoom = Math.max(0.05, Math.min(3, zoom * delta)) - - // Get mouse position relative to the viewport - let mouseX = e.clientX - let mouseY = e.clientY - - // Try to get the container bounds to make coordinates relative to the graph container - const target = e.currentTarget - if (target && "getBoundingClientRect" in target) { - const rect = target.getBoundingClientRect() - mouseX = e.clientX - rect.left - mouseY = e.clientY - rect.top - } - - // Calculate the world position of the mouse cursor - const worldX = (mouseX - panX) / zoom - const worldY = (mouseY - panY) / zoom - - // Calculate new pan to keep the mouse position stationary - const newPanX = mouseX - worldX * newZoom - const newPanY = mouseY - worldY * newZoom - - setZoom(newZoom) - setPanX(newPanX) - setPanY(newPanY) - }, - [zoom, panX, panY], - ) - - const zoomIn = useCallback( - (centerX?: number, centerY?: number, animate = true) => { - const zoomFactor = 1.2 - const newZoom = Math.min(3, zoom * zoomFactor) // Increased max zoom to 3x - - if (centerX !== undefined && centerY !== undefined) { - // Mouse-centered zoom for programmatic zoom in - const worldX = (centerX - panX) / zoom - const worldY = (centerY - panY) / zoom - const newPanX = centerX - worldX * newZoom - const newPanY = centerY - worldY * newZoom - - if (animate && !isAnimating) { - animateToViewState(newPanX, newPanY, newZoom, 200) - } else { - setZoom(newZoom) - setPanX(newPanX) - setPanY(newPanY) - } - } else { - if (animate && !isAnimating) { - animateToViewState(panX, panY, newZoom, 200) - } else { - setZoom(newZoom) - } - } - }, - [zoom, panX, panY, isAnimating, animateToViewState], - ) - - const zoomOut = useCallback( - (centerX?: number, centerY?: number, animate = true) => { - const zoomFactor = 0.8 - const newZoom = Math.max(0.05, zoom * zoomFactor) // Decreased min zoom to 0.05x - - if (centerX !== undefined && centerY !== undefined) { - // Mouse-centered zoom for programmatic zoom out - const worldX = (centerX - panX) / zoom - const worldY = (centerY - panY) / zoom - const newPanX = centerX - worldX * newZoom - const newPanY = centerY - worldY * newZoom - - if (animate && !isAnimating) { - animateToViewState(newPanX, newPanY, newZoom, 200) - } else { - setZoom(newZoom) - setPanX(newPanX) - setPanY(newPanY) - } - } else { - if (animate && !isAnimating) { - animateToViewState(panX, panY, newZoom, 200) - } else { - setZoom(newZoom) - } - } - }, - [zoom, panX, panY, isAnimating, animateToViewState], - ) - - const resetView = useCallback(() => { - setPanX(settings.initialPanX) - setPanY(settings.initialPanY) - setZoom(settings.initialZoom) - setNodePositions(new Map()) - }, [settings]) - - // Auto-fit graph to viewport - const autoFitToViewport = useCallback( - ( - nodes: GraphNode[], - viewportWidth: number, - viewportHeight: number, - options?: { occludedRightPx?: number; animate?: boolean }, - ) => { - if (nodes.length === 0) return - - // Find the bounds of all nodes - let minX = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - - nodes.forEach((node) => { - minX = Math.min(minX, node.x - node.size / 2) - maxX = Math.max(maxX, node.x + node.size / 2) - minY = Math.min(minY, node.y - node.size / 2) - maxY = Math.max(maxY, node.y + node.size / 2) - }) - - // Calculate the center of the content - const contentCenterX = (minX + maxX) / 2 - const contentCenterY = (minY + maxY) / 2 - - // Calculate the size of the content - const contentWidth = maxX - minX - const contentHeight = maxY - minY - - // Add padding (20% on each side) - const paddingFactor = 1.4 - const paddedWidth = contentWidth * paddingFactor - const paddedHeight = contentHeight * paddingFactor - - // Account for occluded area on the right (e.g., chat panel) - const occludedRightPx = Math.max(0, options?.occludedRightPx ?? 0) - const availableWidth = Math.max(1, viewportWidth - occludedRightPx) - - // Calculate the zoom needed to fit the content within available width - const zoomX = availableWidth / paddedWidth - const zoomY = viewportHeight / paddedHeight - const newZoom = Math.min(Math.max(0.05, Math.min(zoomX, zoomY)), 3) - - // Calculate pan to center the content within available area - const availableCenterX = availableWidth / 2 - const newPanX = availableCenterX - contentCenterX * newZoom - const newPanY = viewportHeight / 2 - contentCenterY * newZoom - - // Apply the new view (optional animation) - if (options?.animate) { - const steps = 8 - const durationMs = 160 // snappy - const intervalMs = Math.max(1, Math.floor(durationMs / steps)) - const startZoom = zoom - const startPanX = panX - const startPanY = panY - let i = 0 - const ease = (t: number) => 1 - (1 - t) ** 2 // ease-out quad - const timer = setInterval(() => { - i++ - const t = ease(i / steps) - setZoom(startZoom + (newZoom - startZoom) * t) - setPanX(startPanX + (newPanX - startPanX) * t) - setPanY(startPanY + (newPanY - startPanY) * t) - if (i >= steps) clearInterval(timer) - }, intervalMs) - } else { - setZoom(newZoom) - setPanX(newPanX) - setPanY(newPanY) - } - }, - [zoom, panX, panY], - ) - - // Touch gesture handlers for mobile pinch-to-zoom - const handleTouchStart = useCallback((e: React.TouchEvent) => { - const touches = Array.from(e.touches).map((touch) => ({ - id: touch.identifier, - x: touch.clientX, - y: touch.clientY, - })) - - if (touches.length >= 2) { - // Start gesture with two or more fingers - const touch1 = touches[0]! - const touch2 = touches[1]! - - const distance = Math.sqrt( - (touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2, - ) - - const center = { - x: (touch1.x + touch2.x) / 2, - y: (touch1.y + touch2.y) / 2, - } - - setTouchState({ - touches, - lastDistance: distance, - lastCenter: center, - isGesturing: true, - }) - } else { - setTouchState((prev) => ({ ...prev, touches, isGesturing: false })) - } - }, []) - - const handleTouchMove = useCallback( - (e: React.TouchEvent) => { - e.preventDefault() - - const touches = Array.from(e.touches).map((touch) => ({ - id: touch.identifier, - x: touch.clientX, - y: touch.clientY, - })) - - if (touches.length >= 2 && touchState.isGesturing) { - const touch1 = touches[0]! - const touch2 = touches[1]! - - const distance = Math.sqrt( - (touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2, - ) - - const center = { - x: (touch1.x + touch2.x) / 2, - y: (touch1.y + touch2.y) / 2, - } - - // Calculate zoom change based on pinch distance change - const distanceChange = distance / touchState.lastDistance - const newZoom = Math.max(0.05, Math.min(3, zoom * distanceChange)) - - // Get canvas bounds for center calculation - const canvas = e.currentTarget as HTMLElement - const rect = canvas.getBoundingClientRect() - const centerX = center.x - rect.left - const centerY = center.y - rect.top - - // Calculate the world position of the pinch center - const worldX = (centerX - panX) / zoom - const worldY = (centerY - panY) / zoom - - // Calculate new pan to keep the pinch center stationary - const newPanX = centerX - worldX * newZoom - const newPanY = centerY - worldY * newZoom - - // Calculate pan change based on center movement - const centerDx = center.x - touchState.lastCenter.x - const centerDy = center.y - touchState.lastCenter.y - - setZoom(newZoom) - setPanX(newPanX + centerDx) - setPanY(newPanY + centerDy) - - setTouchState({ - touches, - lastDistance: distance, - lastCenter: center, - isGesturing: true, - }) - } else if (touches.length === 1 && !touchState.isGesturing && isPanning) { - // Single finger pan (only if not in gesture mode) - const touch = touches[0]! - const newPanX = touch.x - panStart.x - const newPanY = touch.y - panStart.y - setPanX(newPanX) - setPanY(newPanY) - } - }, - [touchState, zoom, panX, panY, isPanning, panStart], - ) - - const handleTouchEnd = useCallback((e: React.TouchEvent) => { - const touches = Array.from(e.touches).map((touch) => ({ - id: touch.identifier, - x: touch.clientX, - y: touch.clientY, - })) - - if (touches.length < 2) { - setTouchState((prev) => ({ ...prev, touches, isGesturing: false })) - } else { - setTouchState((prev) => ({ ...prev, touches })) - } - - if (touches.length === 0) { - setIsPanning(false) - } - }, []) - - // Center viewport on a specific world position (with animation) - const centerViewportOn = useCallback( - ( - worldX: number, - worldY: number, - viewportWidth: number, - viewportHeight: number, - animate = true, - ) => { - const newPanX = viewportWidth / 2 - worldX * zoom - const newPanY = viewportHeight / 2 - worldY * zoom - - if (animate && !isAnimating) { - animateToViewState(newPanX, newPanY, zoom, 400) - } else { - setPanX(newPanX) - setPanY(newPanY) - } - }, - [zoom, isAnimating, animateToViewState], - ) - - // Node interaction handlers - const handleNodeHover = useCallback((nodeId: string | null) => { - setHoveredNode(nodeId) - }, []) - - const handleNodeClick = useCallback( - (nodeId: string) => { - setSelectedNode(selectedNode === nodeId ? null : nodeId) - }, - [selectedNode], - ) - - const handleDoubleClick = useCallback( - (e: React.MouseEvent) => { - // Calculate new zoom (zoom in by 1.5x) - const zoomFactor = 1.5 - const newZoom = Math.min(3, zoom * zoomFactor) - - // Get mouse position relative to the container - let mouseX = e.clientX - let mouseY = e.clientY - - // Try to get the container bounds to make coordinates relative to the graph container - const target = e.currentTarget - if (target && "getBoundingClientRect" in target) { - const rect = target.getBoundingClientRect() - mouseX = e.clientX - rect.left - mouseY = e.clientY - rect.top - } - - // Calculate the world position of the clicked point - const worldX = (mouseX - panX) / zoom - const worldY = (mouseY - panY) / zoom - - // Calculate new pan to keep the clicked point in the same screen position - const newPanX = mouseX - worldX * newZoom - const newPanY = mouseY - worldY * newZoom - - setZoom(newZoom) - setPanX(newPanX) - setPanY(newPanY) - }, - [zoom, panX, panY], - ) - - return { - // State - panX, - panY, - zoom, - hoveredNode, - selectedNode, - draggingNodeId, - nodePositions, - // Handlers - handlePanStart, - handlePanMove, - handlePanEnd, - handleWheel, - handleNodeHover, - handleNodeClick, - handleNodeDragStart, - handleNodeDragMove, - handleNodeDragEnd, - handleDoubleClick, - // Touch handlers - handleTouchStart, - handleTouchMove, - handleTouchEnd, - // Controls - zoomIn, - zoomOut, - resetView, - autoFitToViewport, - centerViewportOn, - setSelectedNode, - } -} diff --git a/packages/memory-graph/src/hooks/use-graph-theme.ts b/packages/memory-graph/src/hooks/use-graph-theme.ts new file mode 100644 index 00000000..f045830a --- /dev/null +++ b/packages/memory-graph/src/hooks/use-graph-theme.ts @@ -0,0 +1,121 @@ +import { useEffect, useMemo, useState } from "react" +import type { GraphThemeColors } from "../types" +import { DEFAULT_COLORS } from "../constants" + +function readCssVar(name: string, fallback: string): string { + if (typeof document === "undefined") return fallback + const val = getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim() + return val || fallback +} + +function resolveColors(): GraphThemeColors { + return { + bg: readCssVar("--graph-bg", DEFAULT_COLORS.bg), + docFill: readCssVar("--graph-doc-fill", DEFAULT_COLORS.docFill), + docStroke: readCssVar("--graph-doc-stroke", DEFAULT_COLORS.docStroke), + docInnerFill: readCssVar("--graph-doc-inner", DEFAULT_COLORS.docInnerFill), + memFill: readCssVar("--graph-mem-fill", DEFAULT_COLORS.memFill), + memFillHover: readCssVar( + "--graph-mem-fill-hover", + DEFAULT_COLORS.memFillHover, + ), + memStrokeDefault: readCssVar( + "--graph-mem-stroke", + DEFAULT_COLORS.memStrokeDefault, + ), + accent: readCssVar("--graph-accent", DEFAULT_COLORS.accent), + textPrimary: readCssVar("--graph-text-primary", DEFAULT_COLORS.textPrimary), + textSecondary: readCssVar( + "--graph-text-secondary", + DEFAULT_COLORS.textSecondary, + ), + textMuted: readCssVar("--graph-text-muted", DEFAULT_COLORS.textMuted), + edgeDerives: readCssVar("--graph-edge-derives", DEFAULT_COLORS.edgeDerives), + edgeUpdates: readCssVar("--graph-edge-updates", DEFAULT_COLORS.edgeUpdates), + edgeExtends: readCssVar("--graph-edge-extends", DEFAULT_COLORS.edgeExtends), + memBorderForgotten: readCssVar( + "--graph-mem-border-forgotten", + DEFAULT_COLORS.memBorderForgotten, + ), + memBorderExpiring: readCssVar( + "--graph-mem-border-expiring", + DEFAULT_COLORS.memBorderExpiring, + ), + memBorderRecent: readCssVar( + "--graph-mem-border-recent", + DEFAULT_COLORS.memBorderRecent, + ), + glowColor: readCssVar("--graph-glow", DEFAULT_COLORS.glowColor), + iconColor: readCssVar("--graph-icon", DEFAULT_COLORS.iconColor), + popoverBg: readCssVar("--graph-popover-bg", DEFAULT_COLORS.popoverBg), + popoverBorder: readCssVar( + "--graph-popover-border", + DEFAULT_COLORS.popoverBorder, + ), + popoverTextPrimary: readCssVar( + "--graph-popover-text-primary", + DEFAULT_COLORS.popoverTextPrimary, + ), + popoverTextSecondary: readCssVar( + "--graph-popover-text-secondary", + DEFAULT_COLORS.popoverTextSecondary, + ), + popoverTextMuted: readCssVar( + "--graph-popover-text-muted", + DEFAULT_COLORS.popoverTextMuted, + ), + controlBg: readCssVar("--graph-control-bg", DEFAULT_COLORS.controlBg), + controlBorder: readCssVar( + "--graph-control-border", + DEFAULT_COLORS.controlBorder, + ), + } +} + +export function useGraphTheme( + overrides?: Partial, +): GraphThemeColors { + const [colors, setColors] = useState(() => resolveColors()) + + useEffect(() => { + const update = () => setColors(resolveColors()) + + // Re-read on theme class change + const observer = new MutationObserver((mutations) => { + for (const m of mutations) { + if (m.type === "attributes" && m.attributeName === "class") { + update() + } + } + }) + observer.observe(document.documentElement, { attributes: true }) + + // Also listen for media query changes (system theme) + const mq = window.matchMedia("(prefers-color-scheme: dark)") + mq.addEventListener("change", update) + + return () => { + observer.disconnect() + mq.removeEventListener("change", update) + } + }, []) + + // Serialize overrides to a stable string key so useMemo only recomputes + // when the actual override values change, not on every render. + const overrideKey = overrides + ? Object.entries(overrides) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}:${v}`) + .join(",") + : "" + + const merged = useMemo( + () => (overrides ? { ...colors, ...overrides } : colors), + // biome-ignore lint/correctness/useExhaustiveDependencies: overrideKey tracks overrides by value + [colors, overrideKey], + ) + + return merged +} diff --git a/packages/memory-graph/src/hooks/use-mobile.ts b/packages/memory-graph/src/hooks/use-mobile.ts deleted file mode 100644 index 283bbb4c..00000000 --- a/packages/memory-graph/src/hooks/use-mobile.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from "react" - -const MOBILE_BREAKPOINT = 768 - -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - } - mql.addEventListener("change", onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener("change", onChange) - }, []) - - return !!isMobile -} diff --git a/packages/memory-graph/src/index.tsx b/packages/memory-graph/src/index.tsx index f32f29bf..1d17766a 100644 --- a/packages/memory-graph/src/index.tsx +++ b/packages/memory-graph/src/index.tsx @@ -1,25 +1,39 @@ -// Export the main component +// Components export { MemoryGraph } from "./components/memory-graph" +export { GraphCanvas } from "./components/graph-canvas" -// Export style injector for manual use if needed -export { injectStyles } from "./lib/inject-styles" +// Hooks +export { useGraphData } from "./hooks/use-graph-data" +export { useGraphTheme } from "./hooks/use-graph-theme" -// Export types for consumers -export type { MemoryGraphProps } from "./types" +// Engine classes (for advanced usage) +export { ForceSimulation } from "./canvas/simulation" +export { ViewportState } from "./canvas/viewport" +export { SpatialIndex } from "./canvas/hit-test" +export { VersionChainIndex } from "./canvas/version-chain" +// Constants +export { DEFAULT_COLORS, FORCE_CONFIG, GRAPH_SETTINGS } from "./constants" + +// Types +export type { + MemoryGraphProps, + GraphNode, + GraphEdge, + GraphThemeColors, + GraphCanvasProps, + GraphApiDocument, + GraphApiMemory, + GraphApiEdge, + DocumentNodeData, + MemoryNodeData, + ChainEntry, +} from "./types" + +// Backward-compatible API types export type { DocumentWithMemories, MemoryEntry, DocumentsResponse, -} from "./api-types" - -export type { - GraphNode, - GraphEdge, MemoryRelation, -} 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" +} from "./api-types" diff --git a/packages/memory-graph/src/lib/inject-styles.ts b/packages/memory-graph/src/lib/inject-styles.ts deleted file mode 100644 index e50b4f5e..00000000 --- a/packages/memory-graph/src/lib/inject-styles.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Runtime CSS injection for universal bundler support - * The CSS content is injected by the build plugin - */ - -// This will be replaced by the build plugin with the actual CSS content -declare const __MEMORY_GRAPH_CSS__: string - -// Track injection state -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 inject once - if (injected) return - - // Check if already injected (e.g., by another instance) - if (document.querySelector("style[data-memory-graph]")) { - injected = true - return - } - - 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) -} diff --git a/packages/memory-graph/src/lib/similarity.ts b/packages/memory-graph/src/lib/similarity.ts deleted file mode 100644 index 09d3a2cc..00000000 --- a/packages/memory-graph/src/lib/similarity.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Utility functions for calculating semantic similarity between documents and memories - -/** - * Calculate cosine similarity between two normalized vectors (unit vectors) - * Since all embeddings in this system are normalized using normalizeEmbeddingFast, - * cosine similarity equals dot product for unit vectors. - */ -export const cosineSimilarity = ( - vectorA: number[], - vectorB: number[], -): number => { - if (vectorA.length !== vectorB.length) { - throw new Error("Vectors must have the same length") - } - - let dotProduct = 0 - - for (let i = 0; i < vectorA.length; i++) { - const vectorAi = vectorA[i] - const vectorBi = vectorB[i] - if ( - typeof vectorAi !== "number" || - typeof vectorBi !== "number" || - isNaN(vectorAi) || - isNaN(vectorBi) - ) { - throw new Error("Vectors must contain only numbers") - } - dotProduct += vectorAi * vectorBi - } - - return dotProduct -} - -/** - * Calculate semantic similarity between two documents - * Returns a value between 0 and 1, where 1 is most similar - */ -export const calculateSemanticSimilarity = ( - document1Embedding: number[] | null, - document2Embedding: number[] | null, -): number => { - // If we have both embeddings, use cosine similarity - if ( - document1Embedding && - document2Embedding && - document1Embedding.length > 0 && - document2Embedding.length > 0 - ) { - const similarity = cosineSimilarity(document1Embedding, document2Embedding) - // Convert from [-1, 1] to [0, 1] range - return similarity >= 0 ? similarity : 0 - } - - return 0 -} - -/** - * Calculate semantic similarity between a document and memory entry - * Returns a value between 0 and 1, where 1 is most similar - */ -export const calculateDocumentMemorySimilarity = ( - documentEmbedding: number[] | null, - memoryEmbedding: number[] | null, - relevanceScore?: number | null, -): number => { - // If we have both embeddings, use cosine similarity - if ( - documentEmbedding && - memoryEmbedding && - documentEmbedding.length > 0 && - memoryEmbedding.length > 0 - ) { - const similarity = cosineSimilarity(documentEmbedding, memoryEmbedding) - // Convert from [-1, 1] to [0, 1] range - return similarity >= 0 ? similarity : 0 - } - - // Fall back to relevance score from database (0-100 scale) - if (relevanceScore !== null && relevanceScore !== undefined) { - return Math.max(0, Math.min(1, relevanceScore / 100)) - } - - // Default similarity for connections without embeddings or relevance scores - return 0.5 -} - -/** - * Get visual properties for connection based on similarity - */ -export const getConnectionVisualProps = (similarity: number) => { - // Ensure similarity is between 0 and 1 - const normalizedSimilarity = Math.max(0, Math.min(1, similarity)) - - return { - opacity: Math.max(0, normalizedSimilarity), // 0 to 1 range - thickness: Math.max(1, normalizedSimilarity * 4), // 1 to 4 pixels - glow: normalizedSimilarity * 0.6, // Glow intensity - pulseDuration: 2000 + (1 - normalizedSimilarity) * 3000, // Faster pulse for higher similarity - } -} - -/** - * Generate magical color based on similarity and connection type - */ -export const getMagicalConnectionColor = ( - similarity: number, - hue = 220, -): string => { - const normalizedSimilarity = Math.max(0, Math.min(1, similarity)) - const saturation = 60 + normalizedSimilarity * 40 // 60% to 100% - const lightness = 40 + normalizedSimilarity * 30 // 40% to 70% - - return `hsl(${hue}, ${saturation}%, ${lightness}%)` -} diff --git a/packages/memory-graph/src/mock-data.ts b/packages/memory-graph/src/mock-data.ts new file mode 100644 index 00000000..9cd25a1c --- /dev/null +++ b/packages/memory-graph/src/mock-data.ts @@ -0,0 +1,414 @@ +import type { GraphApiDocument, GraphApiMemory } from "./types" + +export interface MockGraphOptions { + documentCount?: number + memoriesPerDoc?: number | [number, number] + seed?: number +} + +/** + * Simple seeded PRNG (Mulberry32). + * Returns a function that produces values in [0, 1). + */ +function createSeededRandom(seed: number): () => number { + let s = seed | 0 + return () => { + s = (s + 0x6d2b79f5) | 0 + let t = Math.imul(s ^ (s >>> 15), 1 | s) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const DOCUMENT_TYPES = [ + "webpage", + "pdf", + "md", + "doc", + "csv", + "json", + "notion", + "text", + "google_doc", + "google_sheet", + "google_slide", + "tweet", + "youtube", + "image", + "note", + "google_drive", + "video", + "mcp", + "word", +] as const + +const TITLE_PREFIXES = [ + "Project Planning", + "API Documentation", + "Meeting Minutes", + "Architecture Design", + "Sprint Retrospective", + "User Research", + "Product Roadmap", + "Technical Spec", + "Onboarding Guide", + "Release Notes", + "Performance Report", + "Security Audit", + "Database Schema", + "Deployment Guide", + "Team Standup", + "Bug Triage", + "Feature Request", + "Code Review", + "Integration Test", + "Data Pipeline", +] + +const TITLE_SUFFIXES = [ + "Notes", + "Q3", + "Q4", + "v2", + "v3", + "Final", + "Draft", + "2024", + "2025", + "Summary", + "Overview", + "Detailed", + "Analysis", + "Report", + "Update", +] + +const MEMORY_TEMPLATES = [ + "The user prefers {preference} when working with {topic}", + "Key decision: {topic} will be implemented using {approach}", + "Important constraint: {topic} must handle {requirement}", + "The {component} module depends on {dependency} for {purpose}", + "Performance target: {metric} should be under {threshold}", + "The team agreed to {action} before {deadline}", + "User feedback indicates {observation} about {feature}", + "Best practice: always {action} when {condition}", + "Known issue: {component} has {issue} under {condition}", + "Migration plan: move from {oldThing} to {newThing} by {deadline}", +] + +const TEMPLATE_FILLS: Record = { + preference: [ + "TypeScript", + "dark mode", + "keyboard shortcuts", + "minimal UI", + "detailed logs", + "async patterns", + ], + topic: [ + "authentication", + "caching", + "data sync", + "graph rendering", + "search indexing", + "memory management", + ], + approach: [ + "event sourcing", + "CQRS", + "microservices", + "edge functions", + "WebSocket streams", + "batch processing", + ], + requirement: [ + "10k concurrent users", + "sub-100ms latency", + "offline mode", + "real-time updates", + "GDPR compliance", + ], + component: [ + "auth", + "graph", + "search", + "storage", + "notification", + "analytics", + ], + dependency: [ + "Redis", + "PostgreSQL", + "S3", + "Cloudflare Workers", + "D3-force", + "WebGL", + ], + purpose: [ + "caching", + "persistence", + "rendering", + "indexing", + "scheduling", + "routing", + ], + metric: [ + "p99 latency", + "time to interactive", + "memory usage", + "CPU utilization", + "bundle size", + ], + threshold: ["200ms", "500ms", "50MB", "3 seconds", "100KB", "1GB"], + action: [ + "run benchmarks", + "update dependencies", + "write migration scripts", + "review PRs", + "deploy to staging", + ], + deadline: ["end of sprint", "next release", "Q4", "Friday", "the demo"], + observation: [ + "confusion", + "delight", + "frustration", + "efficiency gains", + "unexpected usage patterns", + ], + feature: [ + "the graph view", + "search filters", + "memory chains", + "document upload", + "sharing", + ], + condition: [ + "high load", + "cold start", + "large datasets", + "slow networks", + "concurrent edits", + ], + issue: [ + "memory leaks", + "race conditions", + "stale data", + "layout thrashing", + "connection drops", + ], + oldThing: [ + "REST API", + "MongoDB", + "class components", + "Webpack", + "manual testing", + ], + newThing: ["GraphQL", "PostgreSQL", "hooks", "Vite", "automated CI"], +} + +const SUMMARY_TEMPLATES = [ + "Documentation covering {topic} implementation details and best practices for the team.", + "Notes from the recent discussion about {topic} and the decisions made regarding {approach}.", + "Technical overview of the {component} system, including architecture and key design choices.", + "Analysis of {metric} performance data, with recommendations for optimization.", + "Guide for setting up and configuring {component} in development and production environments.", + "Collection of user feedback and research findings related to {feature}.", +] + +function fillTemplate(template: string, random: () => number): string { + return template.replace(/{(\w+)}/g, (_match, key: string) => { + const options = TEMPLATE_FILLS[key] + if (!options || options.length === 0) return key + return options[Math.floor(random() * options.length)] + }) +} + +function generateTitle(random: () => number): string { + const prefix = TITLE_PREFIXES[Math.floor(random() * TITLE_PREFIXES.length)] + if (random() > 0.5) { + const suffix = TITLE_SUFFIXES[Math.floor(random() * TITLE_SUFFIXES.length)] + return `${prefix} ${suffix}` + } + return prefix +} + +function generateSummary(random: () => number): string | null { + if (random() < 0.15) return null + const template = + SUMMARY_TEMPLATES[Math.floor(random() * SUMMARY_TEMPLATES.length)] + return fillTemplate(template, random) +} + +function generateMemoryContent(random: () => number): string { + const template = + MEMORY_TEMPLATES[Math.floor(random() * MEMORY_TEMPLATES.length)] + return fillTemplate(template, random) +} + +function generateISODate( + random: () => number, + baseMs: number, + rangeMs: number, +): string { + const ms = baseMs + Math.floor(random() * rangeMs) + return new Date(ms).toISOString() +} + +export function generateMockGraphData(options: MockGraphOptions = {}): { + documents: GraphApiDocument[] +} { + const { + documentCount = 100, + memoriesPerDoc = [2, 6] as [number, number], + seed = 42, + } = options + + const random = createSeededRandom(seed) + + // Time range: roughly Jan 2024 to Jun 2025 + const baseTime = new Date("2024-01-01T00:00:00Z").getTime() + const timeRange = 1000 * 60 * 60 * 24 * 540 // ~540 days + + const spaceIds = [ + "space-default", + "space-work", + "space-personal", + "space-research", + ] + + const documents: GraphApiDocument[] = [] + + for (let d = 0; d < documentCount; d++) { + const docId = `doc-${String(d).padStart(4, "0")}` + const docCreatedAt = generateISODate(random, baseTime, timeRange) + const docUpdatedAt = generateISODate( + random, + new Date(docCreatedAt).getTime(), + 1000 * 60 * 60 * 24 * 30, // up to 30 days after creation + ) + + // Determine memory count + let memCount: number + if (typeof memoriesPerDoc === "number") { + memCount = memoriesPerDoc + } else { + const [min, max] = memoriesPerDoc + memCount = min + Math.floor(random() * (max - min + 1)) + } + + const spaceId = spaceIds[Math.floor(random() * spaceIds.length)] + const memories: GraphApiMemory[] = [] + + // Decide if this document has a version chain (30% chance) + const hasVersionChain = random() < 0.3 && memCount >= 3 + let chainRootId: string | null = null + let chainPrevId: string | null = null + let chainVersion = 1 + + for (let m = 0; m < memCount; m++) { + const memId = `mem-${docId}-${String(m).padStart(3, "0")}` + const memCreatedAt = generateISODate( + random, + new Date(docCreatedAt).getTime(), + 1000 * 60 * 60 * 24 * 14, + ) + const memUpdatedAt = generateISODate( + random, + new Date(memCreatedAt).getTime(), + 1000 * 60 * 60 * 24 * 7, + ) + + let parentMemoryId: string | null = null + let rootMemoryId: string | null = null + let version = 1 + let isLatest = true + let isForgotten = false + let memoryRelations: Record = + {} + + // Build version chain for first few memories if applicable + if (hasVersionChain && m < 3) { + if (m === 0) { + // Root of the chain + chainRootId = memId + chainPrevId = memId + chainVersion = 1 + version = 1 + isLatest = false + isForgotten = random() < 0.3 + } else { + parentMemoryId = chainPrevId + rootMemoryId = chainRootId + chainVersion++ + version = chainVersion + chainPrevId = memId + isLatest = m === 2 // last in the 3-memory chain + isForgotten = !isLatest && random() < 0.2 + // Add "updates" relation to parent + if (parentMemoryId) { + memoryRelations = { [parentMemoryId]: "updates" } + } + } + } else { + // Standalone memory + isForgotten = random() < 0.1 + isLatest = true + version = 1 + // Randomly add extends/derives relations to earlier memories in this doc + if (m > 0 && random() < 0.2) { + const targetIdx = Math.floor(random() * m) + const targetMem = memories[targetIdx] + if (targetMem) { + const relType = random() < 0.5 ? "extends" : "derives" + memoryRelations = { + [targetMem.id]: relType, + } + } + } + } + + // Determine forgetAfter (for some non-forgotten memories, set a future expiry) + let forgetAfter: string | null = null + let forgetReason: string | null = null + if (isForgotten) { + forgetReason = random() < 0.5 ? "superseded" : "user-requested" + } else if (random() < 0.1) { + // Expiring memory + const expiryMs = + Date.now() + Math.floor(random() * 1000 * 60 * 60 * 24 * 30) + forgetAfter = new Date(expiryMs).toISOString() + } + + memories.push({ + id: memId, + memory: generateMemoryContent(random), + isStatic: random() < 0.15, + spaceId, + isLatest, + isForgotten, + forgetAfter, + forgetReason, + version, + parentMemoryId, + rootMemoryId, + createdAt: memCreatedAt, + updatedAt: memUpdatedAt, + memoryRelations: + Object.keys(memoryRelations).length > 0 ? memoryRelations : undefined, + }) + } + + documents.push({ + id: docId, + title: generateTitle(random), + summary: generateSummary(random), + documentType: + DOCUMENT_TYPES[Math.floor(random() * DOCUMENT_TYPES.length)], + createdAt: docCreatedAt, + updatedAt: docUpdatedAt, + memories, + }) + } + + return { documents } +} diff --git a/packages/memory-graph/src/styles/animations.css.ts b/packages/memory-graph/src/styles/animations.css.ts deleted file mode 100644 index 53444052..00000000 --- a/packages/memory-graph/src/styles/animations.css.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { keyframes } from "@vanilla-extract/css" - -/** - * Animation keyframes - * Used throughout the component library for consistent motion - */ - -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: { - transform: "translateX(100%)", - opacity: 0, - }, - to: { - transform: "translateX(0)", - opacity: 1, - }, -}) - -export const slideInFromLeft = keyframes({ - from: { - transform: "translateX(-100%)", - opacity: 0, - }, - to: { - transform: "translateX(0)", - opacity: 1, - }, -}) - -export const slideInFromTop = keyframes({ - from: { - transform: "translateY(-100%)", - opacity: 0, - }, - to: { - transform: "translateY(0)", - opacity: 1, - }, -}) - -export const slideInFromBottom = keyframes({ - from: { - transform: "translateY(100%)", - opacity: 0, - }, - to: { - transform: "translateY(0)", - opacity: 1, - }, -}) - -export const spin = keyframes({ - from: { transform: "rotate(0deg)" }, - to: { transform: "rotate(360deg)" }, -}) - -export const pulse = keyframes({ - "0%, 100%": { - opacity: 1, - }, - "50%": { - opacity: 0.5, - }, -}) - -export const bounce = keyframes({ - "0%, 100%": { - transform: "translateY(-25%)", - animationTimingFunction: "cubic-bezier(0.8, 0, 1, 1)", - }, - "50%": { - transform: "translateY(0)", - animationTimingFunction: "cubic-bezier(0, 0, 0.2, 1)", - }, -}) - -export const scaleIn = keyframes({ - from: { - transform: "scale(0.95)", - opacity: 0, - }, - to: { - transform: "scale(1)", - opacity: 1, - }, -}) - -export const scaleOut = keyframes({ - from: { - transform: "scale(1)", - opacity: 1, - }, - to: { - transform: "scale(0.95)", - opacity: 0, - }, -}) - -export const shimmer = keyframes({ - "0%": { - backgroundPosition: "-1000px 0", - }, - "100%": { - backgroundPosition: "1000px 0", - }, -}) diff --git a/packages/memory-graph/src/styles/effects.css.ts b/packages/memory-graph/src/styles/effects.css.ts deleted file mode 100644 index 306f912c..00000000 --- a/packages/memory-graph/src/styles/effects.css.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { style, styleVariants } from "@vanilla-extract/css" -import { themeContract } from "./theme.css" - -/** - * Base glass-morphism effect - * Provides the signature frosted glass look - */ -const glassBase = style({ - backdropFilter: "blur(12px)", - WebkitBackdropFilter: "blur(12px)", - border: `1px solid ${themeContract.colors.document.border}`, - borderRadius: themeContract.radii.lg, -}) - -/** - * Glass effect variants - */ -export const glass = styleVariants({ - /** - * Light glass effect - subtle background - */ - light: [ - glassBase, - { - background: "rgba(255, 255, 255, 0.05)", - }, - ], - - /** - * Medium glass effect - more visible - */ - medium: [ - glassBase, - { - background: "rgba(255, 255, 255, 0.08)", - }, - ], - - /** - * Dark glass effect - prominent - */ - dark: [ - glassBase, - { - background: "rgba(15, 20, 25, 0.8)", - backdropFilter: "blur(20px)", - WebkitBackdropFilter: "blur(20px)", - }, - ], -}) - -/** - * Glass panel styles for larger containers - */ -export const glassPanel = styleVariants({ - default: { - background: "rgba(15, 20, 25, 0.8)", - backdropFilter: "blur(20px)", - WebkitBackdropFilter: "blur(20px)", - border: `1px solid ${themeContract.colors.document.border}`, - borderRadius: themeContract.radii.xl, - }, - bordered: { - background: "rgba(15, 20, 25, 0.8)", - backdropFilter: "blur(20px)", - WebkitBackdropFilter: "blur(20px)", - border: `2px solid ${themeContract.colors.document.border}`, - borderRadius: themeContract.radii.xl, - }, -}) - -/** - * Focus ring styles for accessibility - */ -export const focusRing = style({ - outline: "none", - selectors: { - "&:focus-visible": { - outline: `2px solid ${themeContract.colors.accent.primary}`, - outlineOffset: "2px", - }, - }, -}) - -/** - * Transition presets - */ -export const transition = styleVariants({ - fast: { - transition: themeContract.transitions.fast, - }, - normal: { - transition: themeContract.transitions.normal, - }, - slow: { - transition: themeContract.transitions.slow, - }, - all: { - transition: `all ${themeContract.transitions.normal}`, - }, - colors: { - transition: `background-color ${themeContract.transitions.normal}, color ${themeContract.transitions.normal}, border-color ${themeContract.transitions.normal}`, - }, - transform: { - transition: `transform ${themeContract.transitions.normal}`, - }, -}) - -/** - * Hover glow effect - */ -export const hoverGlow = style({ - position: "relative", - transition: themeContract.transitions.normal, - selectors: { - "&:hover": { - 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 deleted file mode 100644 index ec08d959..00000000 --- a/packages/memory-graph/src/styles/global.css.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { globalStyle } from "@vanilla-extract/css" - -/** - * Global CSS reset and base styles - */ - -// 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", { - background: "none", - 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 deleted file mode 100644 index 15dbd1e9..00000000 --- a/packages/memory-graph/src/styles/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Style system exports - * Provides theme, sprinkles, animations, and effects for the memory-graph package - */ - -// Import global styles (side effect) -import "./global.css" - -// Theme -export { themeContract, defaultTheme } from "./theme.css" - -// Sprinkles utilities -export { sprinkles } from "./sprinkles.css" -export type { Sprinkles } from "./sprinkles.css" - -// Animations -export * as animations from "./animations.css" - -// Glass-morphism effects -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 deleted file mode 100644 index 6cb06261..00000000 --- a/packages/memory-graph/src/styles/sprinkles.css.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles" -import { themeContract } from "./theme.css" - -/** - * Responsive conditions for mobile-first design - */ -const responsiveProperties = defineProperties({ - conditions: { - mobile: {}, - tablet: { "@media": "screen and (min-width: 768px)" }, - desktop: { "@media": "screen and (min-width: 1024px)" }, - }, - defaultCondition: "mobile", - properties: { - // Display - display: ["none", "flex", "block", "inline", "inline-flex", "grid"], - - // Flexbox - flexDirection: ["row", "column", "row-reverse", "column-reverse"], - justifyContent: [ - "stretch", - "flex-start", - "center", - "flex-end", - "space-between", - "space-around", - "space-evenly", - ], - alignItems: ["stretch", "flex-start", "center", "flex-end", "baseline"], - flexWrap: ["nowrap", "wrap", "wrap-reverse"], - gap: themeContract.space, - - // Spacing - padding: themeContract.space, - paddingTop: themeContract.space, - paddingBottom: themeContract.space, - paddingLeft: themeContract.space, - paddingRight: themeContract.space, - margin: themeContract.space, - marginTop: themeContract.space, - marginBottom: themeContract.space, - marginLeft: themeContract.space, - marginRight: themeContract.space, - - // Sizing - width: { - auto: "auto", - full: "100%", - screen: "100vw", - min: "min-content", - max: "max-content", - fit: "fit-content", - }, - height: { - auto: "auto", - full: "100%", - screen: "100vh", - min: "min-content", - max: "max-content", - fit: "fit-content", - }, - minWidth: { - 0: "0", - full: "100%", - min: "min-content", - max: "max-content", - fit: "fit-content", - }, - minHeight: { - 0: "0", - full: "100%", - screen: "100vh", - }, - maxWidth: { - none: "none", - full: "100%", - min: "min-content", - max: "max-content", - fit: "fit-content", - }, - maxHeight: { - none: "none", - full: "100%", - screen: "100vh", - }, - - // Position - position: ["static", "relative", "absolute", "fixed", "sticky"], - top: themeContract.space, - bottom: themeContract.space, - left: themeContract.space, - right: themeContract.space, - inset: themeContract.space, - - // Border radius - borderRadius: themeContract.radii, - borderTopLeftRadius: themeContract.radii, - borderTopRightRadius: themeContract.radii, - borderBottomLeftRadius: themeContract.radii, - borderBottomRightRadius: themeContract.radii, - - // Text - fontSize: themeContract.typography.fontSize, - fontWeight: themeContract.typography.fontWeight, - lineHeight: themeContract.typography.lineHeight, - textAlign: ["left", "center", "right", "justify"], - - // Overflow - overflow: ["visible", "hidden", "scroll", "auto"], - overflowX: ["visible", "hidden", "scroll", "auto"], - overflowY: ["visible", "hidden", "scroll", "auto"], - - // Z-index - zIndex: themeContract.zIndex, - - // Cursor - cursor: ["auto", "pointer", "not-allowed", "grab", "grabbing"], - - // Pointer events - pointerEvents: ["auto", "none"], - - // User select - userSelect: ["auto", "none", "text", "all"], - }, -}) - -/** - * Color properties (non-responsive) - */ -const colorProperties = defineProperties({ - properties: { - color: { - primary: themeContract.colors.text.primary, - secondary: themeContract.colors.text.secondary, - muted: themeContract.colors.text.muted, - }, - backgroundColor: { - transparent: "transparent", - primary: themeContract.colors.background.primary, - secondary: themeContract.colors.background.secondary, - accent: themeContract.colors.background.accent, - documentPrimary: themeContract.colors.document.primary, - documentSecondary: themeContract.colors.document.secondary, - documentAccent: themeContract.colors.document.accent, - memoryPrimary: themeContract.colors.memory.primary, - memorySecondary: themeContract.colors.memory.secondary, - memoryAccent: themeContract.colors.memory.accent, - }, - borderColor: { - transparent: "transparent", - documentBorder: themeContract.colors.document.border, - memoryBorder: themeContract.colors.memory.border, - }, - }, -}) - -/** - * Border properties - */ -const borderProperties = defineProperties({ - properties: { - borderWidth: { - 0: "0", - 1: "1px", - 2: "2px", - 4: "4px", - }, - borderStyle: ["none", "solid", "dashed", "dotted"], - }, -}) - -/** - * Opacity properties - */ -const opacityProperties = defineProperties({ - properties: { - opacity: { - 0: "0", - 10: "0.1", - 20: "0.2", - 30: "0.3", - 40: "0.4", - 50: "0.5", - 60: "0.6", - 70: "0.7", - 80: "0.8", - 90: "0.9", - 100: "1", - }, - }, -}) - -/** - * Combined sprinkles system - * Provides Tailwind-like utility classes with full type safety - */ -export const sprinkles = createSprinkles( - responsiveProperties, - colorProperties, - borderProperties, - opacityProperties, -) - -export type Sprinkles = Parameters[0] diff --git a/packages/memory-graph/src/styles/theme.css.ts b/packages/memory-graph/src/styles/theme.css.ts deleted file mode 100644 index 0f3f5fea..00000000 --- a/packages/memory-graph/src/styles/theme.css.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { createTheme, createThemeContract } from "@vanilla-extract/css" - -/** - * Theme contract defines the structure of the design system. - * Consumers can provide custom themes that match this contract. - */ -export const themeContract = createThemeContract({ - colors: { - // Background colors - background: { - primary: null, - secondary: null, - accent: null, - }, - // Document node colors - document: { - primary: null, - secondary: null, - accent: null, - border: null, - glow: null, - }, - // Memory node colors - memory: { - primary: null, - secondary: null, - accent: null, - border: null, - glow: null, - }, - // Connection strengths - connection: { - weak: null, - memory: null, - medium: null, - strong: null, - }, - // Text colors - text: { - primary: null, - secondary: null, - muted: null, - }, - // Accent colors - accent: { - primary: null, - secondary: null, - glow: null, - amber: null, - emerald: null, - }, - // Status indicators - status: { - forgotten: null, - expiring: null, - new: null, - }, - // Relation types - relations: { - updates: null, - extends: null, - derives: null, - }, - }, - space: { - 0: null, - 1: null, - 2: null, - 3: null, - 4: null, - 5: null, - 6: null, - 8: null, - 10: null, - 12: null, - 16: null, - 20: null, - 24: null, - 32: null, - 40: null, - 48: null, - 64: null, - }, - radii: { - none: null, - sm: null, - md: null, - lg: null, - xl: null, - "2xl": null, - full: null, - }, - typography: { - fontSize: { - xs: null, - sm: null, - base: null, - lg: null, - xl: null, - "2xl": null, - "3xl": null, - }, - fontWeight: { - normal: null, - medium: null, - semibold: null, - bold: null, - }, - lineHeight: { - tight: null, - normal: null, - relaxed: null, - }, - }, - transitions: { - fast: null, - normal: null, - slow: null, - }, - zIndex: { - base: null, - dropdown: null, - overlay: null, - modal: null, - tooltip: null, - }, -}) - -/** - * Default theme implementation based on the original constants.ts colors - * This provides the glass-morphism dark theme used throughout the app. - */ -export const defaultTheme = createTheme(themeContract, { - colors: { - background: { - primary: "#0f1419", // Deep dark blue-gray - secondary: "#1a1f29", // Slightly lighter - accent: "#252a35", // Card backgrounds - }, - document: { - primary: "rgba(255, 255, 255, 0.06)", // Subtle glass white - secondary: "rgba(255, 255, 255, 0.12)", // More visible - accent: "rgba(255, 255, 255, 0.18)", // Hover state - border: "rgba(255, 255, 255, 0.25)", // Sharp borders - glow: "rgba(147, 197, 253, 0.4)", // Blue glow for interaction - }, - memory: { - primary: "rgba(147, 197, 253, 0.08)", // Subtle glass blue - secondary: "rgba(147, 197, 253, 0.16)", // More visible - accent: "rgba(147, 197, 253, 0.24)", // Hover state - border: "rgba(147, 197, 253, 0.35)", // Sharp borders - glow: "rgba(147, 197, 253, 0.5)", // Blue glow for interaction - }, - connection: { - weak: "rgba(148, 163, 184, 0)", // Very subtle - memory: "rgba(148, 163, 184, 0.3)", // Very subtle - medium: "rgba(148, 163, 184, 0.125)", // Medium visibility - strong: "rgba(148, 163, 184, 0.4)", // Strong connection - }, - text: { - primary: "#ffffff", // Pure white - secondary: "#e2e8f0", // Light gray - muted: "#94a3b8", // Medium gray - }, - accent: { - primary: "rgba(59, 130, 246, 0.7)", // Clean blue - secondary: "rgba(99, 102, 241, 0.6)", // Clean purple - glow: "rgba(147, 197, 253, 0.6)", // Subtle glow - amber: "rgba(251, 165, 36, 0.8)", // Amber for expiring - emerald: "rgba(16, 185, 129, 0.4)", // Emerald for new - }, - status: { - forgotten: "rgba(220, 38, 38, 0.15)", // Red for forgotten - expiring: "rgba(251, 165, 36, 0.8)", // Amber for expiring soon - new: "rgba(16, 185, 129, 0.4)", // Emerald for new memories - }, - relations: { - updates: "rgba(147, 77, 253, 0.5)", // purple - extends: "rgba(16, 185, 129, 0.5)", // green - derives: "rgba(147, 197, 253, 0.5)", // blue - }, - }, - space: { - 0: "0", - 1: "0.25rem", // 4px - 2: "0.5rem", // 8px - 3: "0.75rem", // 12px - 4: "1rem", // 16px - 5: "1.25rem", // 20px - 6: "1.5rem", // 24px - 8: "2rem", // 32px - 10: "2.5rem", // 40px - 12: "3rem", // 48px - 16: "4rem", // 64px - 20: "5rem", // 80px - 24: "6rem", // 96px - 32: "8rem", // 128px - 40: "10rem", // 160px - 48: "12rem", // 192px - 64: "16rem", // 256px - }, - radii: { - none: "0", - sm: "0.125rem", // 2px - md: "0.375rem", // 6px - lg: "0.5rem", // 8px - xl: "0.75rem", // 12px - "2xl": "1rem", // 16px - full: "9999px", - }, - typography: { - fontSize: { - xs: "0.75rem", // 12px - sm: "0.875rem", // 14px - base: "1rem", // 16px - lg: "1.125rem", // 18px - xl: "1.25rem", // 20px - "2xl": "1.5rem", // 24px - "3xl": "1.875rem", // 30px - }, - fontWeight: { - normal: "400", - medium: "500", - semibold: "600", - bold: "700", - }, - lineHeight: { - tight: "1.25", - normal: "1.5", - relaxed: "1.75", - }, - }, - transitions: { - fast: "150ms ease-in-out", - normal: "200ms ease-in-out", - slow: "300ms ease-in-out", - }, - zIndex: { - base: "0", - dropdown: "10", - overlay: "20", - modal: "30", - tooltip: "40", - }, -}) diff --git a/packages/memory-graph/src/types.ts b/packages/memory-graph/src/types.ts index f470223b..6fa5b9ff 100644 --- a/packages/memory-graph/src/types.ts +++ b/packages/memory-graph/src/types.ts @@ -1,148 +1,204 @@ -import type { - DocumentsResponse, - DocumentWithMemories, - MemoryEntry, -} from "./api-types" +// Graph API types matching backend response -// Re-export for convenience -export type { DocumentsResponse, DocumentWithMemories, MemoryEntry } +import type { MemoryRelation } from "./api-types" + +export interface GraphApiMemory { + id: string + memory: string + content?: string | null + isStatic: boolean + spaceId: string + isLatest: boolean + isForgotten: boolean + forgetAfter: string | null + forgetReason: string | null + version: number + parentMemoryId: string | null + rootMemoryId: string | null + createdAt: string + updatedAt: string + // Relation fields from backend + relation?: MemoryRelation | null + updatesMemoryId?: string | null + nextVersionId?: string | null + memoryRelations?: Record | null + // Source/join fields + spaceContainerTag?: string | null +} + +export interface GraphApiDocument { + id: string + title: string | null + summary: string | null + documentType: string + createdAt: string + updatedAt: string + memories: GraphApiMemory[] +} + +export interface GraphApiEdge { + source: string + target: string + edgeType: MemoryRelation +} + +// Typed node data + +export interface DocumentNodeData { + id: string + title: string | null + summary: string | null + type: string + createdAt: string + updatedAt: string + memories: GraphApiMemory[] +} + +export interface MemoryNodeData { + id: string + memory: string + content: string + documentId: string + isStatic: boolean + isLatest: boolean + isForgotten: boolean + forgetAfter: string | null + forgetReason: string | null + version: number + parentMemoryId: string | null + spaceId: string + createdAt: string + updatedAt: string + relation?: MemoryRelation | null + memoryRelations?: Record | null +} export interface GraphNode { id: string type: "document" | "memory" x: number y: number - data: DocumentWithMemories | MemoryEntry + data: DocumentNodeData | MemoryNodeData size: number - color: string + borderColor: string isHovered: boolean isDragging: boolean // D3-force simulation properties - vx?: number // velocity x - vy?: number // velocity y - fx?: number | null // fixed x position (for pinning during drag) - fy?: number | null // fixed y position (for pinning during drag) + vx?: number + vy?: number + fx?: number | null + fy?: number | null } -export type MemoryRelation = "updates" | "extends" | "derives" - export interface GraphEdge { id: string - // D3-force mutates source/target from string IDs to node references during simulation source: string | GraphNode target: string | GraphNode - similarity: number visualProps: { opacity: number thickness: number - glow: number - pulseDuration: number } - color: string - edgeType: "doc-memory" | "doc-doc" | "version" - relationType?: MemoryRelation + edgeType: MemoryRelation } -export interface SpacesDropdownProps { - selectedSpace: string - availableSpaces: string[] - spaceMemoryCounts: Record - onSpaceChange: (space: string) => void -} - -export interface NodeDetailPanelProps { - node: GraphNode | null - onClose: () => void - variant?: "console" | "consumer" +export interface GraphThemeColors { + bg: string + docFill: string + docStroke: string + docInnerFill: string + memFill: string + memFillHover: string + memStrokeDefault: string + accent: string + textPrimary: string + textSecondary: string + textMuted: string + edgeDerives: string + edgeUpdates: string + edgeExtends: string + memBorderForgotten: string + memBorderExpiring: string + memBorderRecent: string + glowColor: string + iconColor: string + popoverBg: string + popoverBorder: string + popoverTextPrimary: string + popoverTextSecondary: string + popoverTextMuted: string + controlBg: string + controlBorder: string } export interface GraphCanvasProps { nodes: GraphNode[] edges: GraphEdge[] - panX: number - panY: number - zoom: number width: number height: number - onNodeHover: (nodeId: string | null) => void - onNodeClick: (nodeId: string) => void - onNodeDragStart: (nodeId: string, e: React.MouseEvent) => void - onNodeDragMove: (e: React.MouseEvent) => void - onNodeDragEnd: () => void - onPanStart: (e: React.MouseEvent) => void - onPanMove: (e: React.MouseEvent) => void - onPanEnd: () => void - onWheel: (e: React.WheelEvent) => void - onDoubleClick: (e: React.MouseEvent) => void - onTouchStart?: (e: React.TouchEvent) => void - onTouchMove?: (e: React.TouchEvent) => void - onTouchEnd?: (e: React.TouchEvent) => void - draggingNodeId: string | null - // Optional list of document IDs (customId or internal id) to highlight + colors: GraphThemeColors highlightDocumentIds?: string[] - // Physics simulation state - isSimulationActive?: boolean - // Selected node ID - dims all other nodes and edges selectedNodeId?: string | null + onNodeHover: (nodeId: string | null) => void + onNodeClick: (nodeId: string | null) => void + onNodeDragStart: (nodeId: string) => void + onNodeDragEnd: () => void + onViewportChange?: (zoom: number, popoverVisible: boolean) => void + canvasRef?: React.RefObject + simulation?: import("./canvas/simulation").ForceSimulation + viewportRef?: React.RefObject< + import("./canvas/viewport").ViewportState | null + > } export interface MemoryGraphProps { - /** The documents to display in the graph */ - documents: DocumentWithMemories[] - /** Whether the initial data is loading */ + /** Documents to display - pass this for direct data mode */ + documents?: GraphApiDocument[] + /** Whether data is loading */ isLoading?: boolean - /** Error that occurred during data fetching */ - error?: Error | null - /** Optional children to render when no documents exist */ - children?: React.ReactNode - /** Whether more data is being loaded (for pagination) */ + /** Whether more data is being loaded */ isLoadingMore?: boolean - /** Total number of documents loaded */ - totalLoaded?: number + /** Callback to load more documents */ + onLoadMore?: () => void /** Whether there are more documents to load */ hasMore?: boolean - /** Callback to load more documents (for pagination) */ - loadMoreDocuments?: () => Promise - /** Show/hide the spaces filter dropdown */ - showSpacesSelector?: boolean - /** Visual variant - "console" for full view, "consumer" for embedded */ + /** Error from data fetching */ + error?: Error | null + /** Children to render when no documents */ + children?: React.ReactNode + /** Visual variant */ variant?: "console" | "consumer" - /** Optional ID for the legend component */ + /** Optional legend ID */ legendId?: string - /** Document IDs to highlight in the graph */ + /** Document IDs to highlight */ highlightDocumentIds?: string[] - /** Whether highlights are currently visible */ + /** Whether highlights are visible */ highlightsVisible?: boolean - /** Pixels occluded on the right side of the viewport */ - occludedRightPx?: number - /** Whether to auto-load more documents based on viewport visibility */ - autoLoadOnViewport?: boolean - /** Theme class name to apply */ - 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 - /** Maximum total number of memory nodes to display across all documents (default: unlimited) */ + /** Container tags for filtering (used by apps with their own API hooks) */ + containerTags?: string[] + /** Specific document IDs to show */ + documentIds?: string[] + /** Max nodes to display */ maxNodes?: number - - // Feature flags - /** Enable experimental features */ - isExperimental?: boolean - - // Slideshow control - /** Whether slideshow mode is currently active */ + /** Show FPS counter overlay */ + showFps?: boolean + /** Slideshow mode */ isSlideshowActive?: boolean - /** Callback when slideshow selects a new node (provides node ID) */ onSlideshowNodeChange?: (nodeId: string | null) => void - /** Callback when user clicks outside during slideshow (to stop it) */ onSlideshowStop?: () => void + /** Canvas ref for external access (e.g. screenshot export) */ + canvasRef?: React.RefObject + /** Custom theme colors - if not provided, reads from CSS variables */ + colors?: GraphThemeColors + /** Total count for loading indicator */ + totalCount?: number +} + +export interface ChainEntry { + id: string + version: number + memory: string + isForgotten: boolean + isLatest: boolean } export interface LegendProps { @@ -160,9 +216,9 @@ export interface LoadingIndicatorProps { variant?: "console" | "consumer" } -export interface ControlsProps { - onZoomIn: () => void - onZoomOut: () => void - onResetView: () => void - variant?: "console" | "consumer" -} +// Re-export api-types for backward compatibility +export type { + DocumentWithMemories, + MemoryEntry, + DocumentsResponse, +} from "./api-types" diff --git a/packages/memory-graph/src/ui/badge.css.ts b/packages/memory-graph/src/ui/badge.css.ts deleted file mode 100644 index 0f8cbc92..00000000 --- a/packages/memory-graph/src/ui/badge.css.ts +++ /dev/null @@ -1,119 +0,0 @@ -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 - */ -export const badgeIcon = style({ - width: "0.75rem", - height: "0.75rem", - pointerEvents: "none", -}) - -/** - * Badge recipe with variants - * Replaces CVA-based badge variants with vanilla-extract recipes - */ -const badgeBase = style({ - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - borderRadius: themeContract.radii.md, - border: "1px solid", - paddingLeft: themeContract.space[2], - paddingRight: themeContract.space[2], - paddingTop: "0.125rem", - paddingBottom: "0.125rem", - fontSize: themeContract.typography.fontSize.xs, - fontWeight: themeContract.typography.fontWeight.medium, - width: "fit-content", - whiteSpace: "nowrap", - flexShrink: 0, - gap: themeContract.space[1], - transition: "color 200ms ease-in-out, box-shadow 200ms ease-in-out", - overflow: "hidden", - - selectors: { - "&:focus-visible": { - borderColor: themeContract.colors.accent.primary, - boxShadow: `0 0 0 2px ${themeContract.colors.accent.primary}33`, - }, - "&[aria-invalid='true']": { - boxShadow: `0 0 0 2px ${themeContract.colors.status.forgotten}33`, - 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, - - variants: { - variant: { - default: { - borderColor: "transparent", - backgroundColor: themeContract.colors.accent.primary, - color: themeContract.colors.text.primary, - - selectors: { - "a&:hover": { - opacity: 0.9, - }, - }, - }, - - secondary: { - borderColor: "transparent", - backgroundColor: themeContract.colors.background.secondary, - color: themeContract.colors.text.secondary, - - selectors: { - "a&:hover": { - backgroundColor: themeContract.colors.background.accent, - }, - }, - }, - - destructive: { - borderColor: "transparent", - backgroundColor: themeContract.colors.status.forgotten, - color: themeContract.colors.text.primary, - - selectors: { - "a&:hover": { - opacity: 0.9, - }, - "&:focus-visible": { - boxShadow: `0 0 0 2px ${themeContract.colors.status.forgotten}33`, - }, - }, - }, - - outline: { - borderColor: themeContract.colors.document.border, - backgroundColor: "transparent", - color: themeContract.colors.text.primary, - - selectors: { - "a&:hover": { - backgroundColor: themeContract.colors.document.primary, - }, - }, - }, - }, - }, - - defaultVariants: { - variant: "default", - }, -}) - -export type BadgeVariants = RecipeVariants diff --git a/packages/memory-graph/src/ui/badge.tsx b/packages/memory-graph/src/ui/badge.tsx deleted file mode 100644 index a08578c3..00000000 --- a/packages/memory-graph/src/ui/badge.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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" - - const combinedClassName = className - ? `${badge({ variant })} ${className}` - : badge({ variant }) - - return -} - -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 deleted file mode 100644 index 375d567b..00000000 --- a/packages/memory-graph/src/ui/button.css.ts +++ /dev/null @@ -1,210 +0,0 @@ -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 - */ -export const buttonIcon = style({ - pointerEvents: "none", - flexShrink: 0, - selectors: { - "&:not([class*='size-'])": { - width: "1rem", - height: "1rem", - }, - }, -}) - -/** - * Button recipe with variants - * Replaces CVA-based button variants with vanilla-extract recipes - */ -export const button = recipe({ - base: { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - gap: themeContract.space[2], - whiteSpace: "nowrap", - borderRadius: themeContract.radii.md, - fontSize: themeContract.typography.fontSize.sm, - fontWeight: themeContract.typography.fontWeight.medium, - transition: themeContract.transitions.normal, - flexShrink: 0, - outline: "none", - border: "1px solid transparent", - cursor: "pointer", - - // SVG sizing - selectors: { - [`&:has(${buttonIcon})`]: { - // Buttons with icons get adjusted padding - }, - "&:disabled": { - pointerEvents: "none", - opacity: 0.5, - }, - "&:focus-visible": { - borderColor: themeContract.colors.accent.primary, - boxShadow: `0 0 0 2px ${themeContract.colors.accent.primary}33`, - }, - "&[aria-invalid='true']": { - boxShadow: `0 0 0 2px ${themeContract.colors.status.forgotten}`, - borderColor: themeContract.colors.status.forgotten, - }, - }, - }, - - variants: { - variant: { - default: { - backgroundColor: themeContract.colors.accent.primary, - color: themeContract.colors.text.primary, - boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", - - selectors: { - "&:hover:not(:disabled)": { - backgroundColor: themeContract.colors.accent.secondary, - }, - }, - }, - - destructive: { - backgroundColor: themeContract.colors.status.forgotten, - color: themeContract.colors.text.primary, - boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", - - selectors: { - "&:hover:not(:disabled)": { - opacity: 0.9, - }, - "&:focus-visible": { - boxShadow: `0 0 0 2px ${themeContract.colors.status.forgotten}33`, - }, - }, - }, - - outline: { - backgroundColor: themeContract.colors.background.primary, - borderColor: themeContract.colors.document.border, - color: themeContract.colors.text.primary, - boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", - - selectors: { - "&:hover:not(:disabled)": { - backgroundColor: themeContract.colors.document.primary, - }, - }, - }, - - secondary: { - backgroundColor: themeContract.colors.background.secondary, - color: themeContract.colors.text.secondary, - boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", - - selectors: { - "&:hover:not(:disabled)": { - backgroundColor: themeContract.colors.background.accent, - }, - }, - }, - - ghost: { - backgroundColor: "transparent", - color: themeContract.colors.text.primary, - - selectors: { - "&:hover:not(:disabled)": { - backgroundColor: themeContract.colors.document.primary, - }, - }, - }, - - link: { - backgroundColor: "transparent", - color: themeContract.colors.accent.primary, - textDecoration: "underline", - textUnderlineOffset: "4px", - - selectors: { - "&:hover:not(:disabled)": { - textDecoration: "underline", - }, - }, - }, - - settingsNav: { - cursor: "pointer", - borderRadius: themeContract.radii.sm, - backgroundColor: "transparent", - color: themeContract.colors.text.primary, - }, - }, - - size: { - default: { - height: "36px", - paddingLeft: themeContract.space[4], - paddingRight: themeContract.space[4], - paddingTop: themeContract.space[2], - paddingBottom: themeContract.space[2], - - selectors: { - "&:has(svg)": { - paddingLeft: themeContract.space[3], - paddingRight: themeContract.space[3], - }, - }, - }, - - sm: { - height: "32px", - borderRadius: themeContract.radii.md, - gap: themeContract.space[1], - paddingLeft: themeContract.space[3], - paddingRight: themeContract.space[3], - - selectors: { - "&:has(svg)": { - paddingLeft: themeContract.space[2], - paddingRight: themeContract.space[2], - }, - }, - }, - - lg: { - height: "40px", - borderRadius: themeContract.radii.md, - paddingLeft: themeContract.space[6], - paddingRight: themeContract.space[6], - - selectors: { - "&:has(svg)": { - paddingLeft: themeContract.space[4], - paddingRight: themeContract.space[4], - }, - }, - }, - - icon: { - width: "36px", - height: "36px", - padding: 0, - }, - - settingsNav: { - height: "32px", - gap: 0, - padding: 0, - }, - }, - }, - - defaultVariants: { - variant: "default", - size: "default", - }, -}) - -export type ButtonVariants = RecipeVariants diff --git a/packages/memory-graph/src/ui/button.tsx b/packages/memory-graph/src/ui/button.tsx deleted file mode 100644 index 89015299..00000000 --- a/packages/memory-graph/src/ui/button.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Slot } from "@radix-ui/react-slot" -import type * as React from "react" -import { button, type ButtonVariants } from "./button.css" - -function Button({ - className, - variant, - size, - asChild = false, - ...props -}: React.ComponentProps<"button"> & - ButtonVariants & { - asChild?: boolean - }) { - const Comp = asChild ? Slot : "button" - - const combinedClassName = className - ? `${button({ variant, size })} ${className}` - : button({ variant, size }) - - return -} - -export { Button, button as buttonVariants } diff --git a/packages/memory-graph/src/ui/collapsible.tsx b/packages/memory-graph/src/ui/collapsible.tsx deleted file mode 100644 index f8de4e4c..00000000 --- a/packages/memory-graph/src/ui/collapsible.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client" - -import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" - -function Collapsible({ - ...props -}: React.ComponentProps) { - return -} - -function CollapsibleTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function CollapsibleContent({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -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 deleted file mode 100644 index c7160d98..00000000 --- a/packages/memory-graph/src/ui/glass-effect.css.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { style } from "@vanilla-extract/css" -import { recipe } from "@vanilla-extract/recipes" -import { themeContract } from "../styles/theme.css" - -/** - * Glass menu effect container - */ -export const glassMenuContainer = style({ - position: "absolute", - inset: 0, -}) - -/** - * Glass menu effect with customizable border radius - */ -export const glassMenuEffect = recipe({ - base: { - position: "absolute", - inset: 0, - backdropFilter: "blur(12px)", - WebkitBackdropFilter: "blur(12px)", - background: "rgba(255, 255, 255, 0.05)", - border: `1px solid ${themeContract.colors.document.border}`, - }, - - variants: { - rounded: { - none: { - borderRadius: themeContract.radii.none, - }, - sm: { - borderRadius: themeContract.radii.sm, - }, - md: { - borderRadius: themeContract.radii.md, - }, - lg: { - borderRadius: themeContract.radii.lg, - }, - xl: { - borderRadius: themeContract.radii.xl, - }, - "2xl": { - borderRadius: themeContract.radii["2xl"], - }, - "3xl": { - borderRadius: "1.5rem", // Tailwind's rounded-3xl - }, - full: { - borderRadius: themeContract.radii.full, - }, - }, - }, - - defaultVariants: { - rounded: "3xl", - }, -}) diff --git a/packages/memory-graph/src/ui/glass-effect.tsx b/packages/memory-graph/src/ui/glass-effect.tsx deleted file mode 100644 index 5c072edf..00000000 --- a/packages/memory-graph/src/ui/glass-effect.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { glassMenuContainer, glassMenuEffect } from "./glass-effect.css" - -interface GlassMenuEffectProps { - rounded?: "none" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "full" - className?: string -} - -export function GlassMenuEffect({ - rounded = "3xl", - className = "", -}: GlassMenuEffectProps) { - return ( -
- {/* Frosted glass effect with translucent border */} -
-
- ) -} diff --git a/packages/memory-graph/src/ui/heading.css.ts b/packages/memory-graph/src/ui/heading.css.ts deleted file mode 100644 index 384e4fdb..00000000 --- a/packages/memory-graph/src/ui/heading.css.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { style } from "@vanilla-extract/css" -import { themeContract } from "../styles/theme.css" - -/** - * Responsive heading style with bold weight - */ -export const headingH3Bold = style({ - fontSize: "0.625rem", // 10px - fontWeight: themeContract.typography.fontWeight.bold, - lineHeight: "28px", - letterSpacing: "-0.4px", - - "@media": { - "screen and (min-width: 640px)": { - fontSize: themeContract.typography.fontSize.xs, // 12px - }, - "screen and (min-width: 768px)": { - fontSize: themeContract.typography.fontSize.sm, // 14px - }, - "screen and (min-width: 1024px)": { - fontSize: themeContract.typography.fontSize.base, // 16px - }, - }, -}) diff --git a/packages/memory-graph/src/ui/heading.tsx b/packages/memory-graph/src/ui/heading.tsx deleted file mode 100644 index ed9b80ef..00000000 --- a/packages/memory-graph/src/ui/heading.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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 combinedClassName = className - ? `${headingH3Bold} ${className}` - : headingH3Bold - - return -} diff --git a/packages/memory-graph/src/utils/document-icons.ts b/packages/memory-graph/src/utils/document-icons.ts deleted file mode 100644 index 2e93c22a..00000000 --- a/packages/memory-graph/src/utils/document-icons.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Canvas-based document type icon rendering utilities - * Simplified to match supported file types: PDF, TXT, MD, DOCX, DOC, RTF, CSV, JSON - */ - -export type DocumentIconType = - | "text" - | "pdf" - | "md" - | "markdown" - | "docx" - | "doc" - | "rtf" - | "csv" - | "json" - -/** - * Draws a document type icon on canvas - * @param ctx - Canvas 2D rendering context - * @param x - X position (center of icon) - * @param y - Y position (center of icon) - * @param size - Icon size (width/height) - * @param type - Document type - * @param color - Icon color (default: white) - */ -export function drawDocumentIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, - type: string, - color = "rgba(255, 255, 255, 0.9)", -): void { - ctx.save() - ctx.fillStyle = color - ctx.strokeStyle = color - ctx.lineWidth = Math.max(1, size / 12) - ctx.lineCap = "round" - ctx.lineJoin = "round" - - switch (type) { - case "pdf": - drawPdfIcon(ctx, x, y, size) - break - case "md": - case "markdown": - drawMarkdownIcon(ctx, x, y, size) - break - case "doc": - case "docx": - drawWordIcon(ctx, x, y, size) - break - case "rtf": - drawRtfIcon(ctx, x, y, size) - break - case "csv": - drawCsvIcon(ctx, x, y, size) - break - case "json": - drawJsonIcon(ctx, x, y, size) - break - case "txt": - case "text": - default: - drawTextIcon(ctx, x, y, size) - break - } - - ctx.restore() -} - -// Individual icon drawing functions - -function drawTextIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - // Simple document outline with lines - const w = size * 0.7 - const h = size * 0.85 - const cornerFold = size * 0.2 - - ctx.beginPath() - ctx.moveTo(x - w / 2, y - h / 2) - ctx.lineTo(x + w / 2 - cornerFold, y - h / 2) - ctx.lineTo(x + w / 2, y - h / 2 + cornerFold) - ctx.lineTo(x + w / 2, y + h / 2) - ctx.lineTo(x - w / 2, y + h / 2) - ctx.closePath() - ctx.stroke() - - // Text lines - const lineSpacing = size * 0.15 - const lineWidth = size * 0.4 - ctx.beginPath() - ctx.moveTo(x - lineWidth / 2, y - lineSpacing) - ctx.lineTo(x + lineWidth / 2, y - lineSpacing) - ctx.moveTo(x - lineWidth / 2, y) - ctx.lineTo(x + lineWidth / 2, y) - ctx.moveTo(x - lineWidth / 2, y + lineSpacing) - ctx.lineTo(x + lineWidth / 2, y + lineSpacing) - ctx.stroke() -} - -function drawPdfIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - // Document with "PDF" text - const w = size * 0.7 - const h = size * 0.85 - - ctx.beginPath() - ctx.rect(x - w / 2, y - h / 2, w, h) - ctx.stroke() - - // "PDF" letters (simplified) - ctx.font = `bold ${size * 0.35}px sans-serif` - ctx.textAlign = "center" - ctx.textBaseline = "middle" - ctx.fillText("PDF", x, y) -} - -function drawMarkdownIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - // Document with "MD" text - const w = size * 0.7 - const h = size * 0.85 - - ctx.beginPath() - ctx.rect(x - w / 2, y - h / 2, w, h) - ctx.stroke() - - // "MD" letters - ctx.font = `bold ${size * 0.3}px sans-serif` - ctx.textAlign = "center" - ctx.textBaseline = "middle" - ctx.fillText("MD", x, y) -} - -function drawWordIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - // Document with "DOC" text - const w = size * 0.7 - const h = size * 0.85 - - ctx.beginPath() - ctx.rect(x - w / 2, y - h / 2, w, h) - ctx.stroke() - - // "DOC" letters - ctx.font = `bold ${size * 0.28}px sans-serif` - ctx.textAlign = "center" - ctx.textBaseline = "middle" - ctx.fillText("DOC", x, y) -} - -function drawRtfIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - // Document with "RTF" text - const w = size * 0.7 - const h = size * 0.85 - - ctx.beginPath() - ctx.rect(x - w / 2, y - h / 2, w, h) - ctx.stroke() - - // "RTF" letters - ctx.font = `bold ${size * 0.3}px sans-serif` - ctx.textAlign = "center" - ctx.textBaseline = "middle" - ctx.fillText("RTF", x, y) -} - -function drawCsvIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - // Grid table for CSV - const w = size * 0.7 - const h = size * 0.85 - - ctx.strokeRect(x - w / 2, y - h / 2, w, h) - - // Grid lines (2x2) - ctx.beginPath() - // Vertical line - ctx.moveTo(x, y - h / 2) - ctx.lineTo(x, y + h / 2) - // Horizontal line - ctx.moveTo(x - w / 2, y) - ctx.lineTo(x + w / 2, y) - ctx.stroke() -} - -function drawJsonIcon( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - size: number, -): void { - // Curly braces for JSON - const w = size * 0.6 - const h = size * 0.8 - - // Left brace - ctx.beginPath() - ctx.moveTo(x - w / 4, y - h / 2) - ctx.quadraticCurveTo(x - w / 2, y - h / 3, x - w / 2, y) - ctx.quadraticCurveTo(x - w / 2, y + h / 3, x - w / 4, y + h / 2) - ctx.stroke() - - // Right brace - ctx.beginPath() - ctx.moveTo(x + w / 4, y - h / 2) - ctx.quadraticCurveTo(x + w / 2, y - h / 3, x + w / 2, y) - ctx.quadraticCurveTo(x + w / 2, y + h / 3, x + w / 4, y + h / 2) - ctx.stroke() -} diff --git a/packages/memory-graph/tsconfig.json b/packages/memory-graph/tsconfig.json index 4e7fc4ba..d82fd08d 100644 --- a/packages/memory-graph/tsconfig.json +++ b/packages/memory-graph/tsconfig.json @@ -1,26 +1,22 @@ { - "extends": "@total-typescript/tsconfig/bundler/dom/library", "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", "jsx": "react-jsx", - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"] - }, - "outDir": "./dist", - "declaration": true, - "declarationMap": true, - "emitDeclarationOnly": false, - "skipLibCheck": true, "strict": true, "esModuleInterop": true, - "moduleResolution": "bundler", - "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, + "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noEmit": false + "declaration": true, + "declarationDir": "./dist", + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": "./src", + "paths": { + "@/*": ["./src/*"] + } }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"] + "include": ["src"], + "exclude": ["node_modules", "dist"] } diff --git a/packages/memory-graph/vite.config.ts b/packages/memory-graph/vite.config.ts index 923952d4..e8a1b1ad 100644 --- a/packages/memory-graph/vite.config.ts +++ b/packages/memory-graph/vite.config.ts @@ -1,93 +1,36 @@ -import { defineConfig, type Plugin } from "vite" +import { defineConfig } from "vitest/config" import react from "@vitejs/plugin-react" -import { resolve } from "path" -import { readFileSync } from "fs" -import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin" +import { resolve } from "node:path" -/** - * Custom plugin to embed CSS content into the JS bundle for runtime injection. - * This allows the package to work with any bundler (Vite, webpack, Next.js, etc.) - */ -function injectCssPlugin(): Plugin { - let cssContent = "" - - return { - name: "inject-css-content", - enforce: "post", - generateBundle(_, bundle) { - // Find the generated CSS file - for (const [fileName, chunk] of Object.entries(bundle)) { - if (fileName.endsWith(".css") && chunk.type === "asset") { - cssContent = chunk.source as string - break - } - } - - // Replace placeholder in JS files with actual CSS content - for (const [fileName, chunk] of Object.entries(bundle)) { - if ( - (fileName.endsWith(".js") || fileName.endsWith(".cjs")) && - chunk.type === "chunk" - ) { - // Escape the CSS for embedding in JS string - const escapedCss = JSON.stringify(cssContent) - chunk.code = chunk.code.replace(/__MEMORY_GRAPH_CSS__/g, escapedCss) - } - } - }, - } -} - -// https://vitejs.dev/config/ export default defineConfig({ - plugins: [react(), vanillaExtractPlugin(), injectCssPlugin()], - build: { - lib: { - entry: resolve(__dirname, "src/index.tsx"), - name: "MemoryGraph", - formats: ["es", "cjs"], - fileName: (format) => { - if (format === "es") return "memory-graph.js" - if (format === "cjs") return "memory-graph.cjs" - return "memory-graph.js" - }, - }, - rollupOptions: { - // Externalize only peer dependencies (React) - external: ["react", "react-dom", "react/jsx-runtime"], - output: { - // Provide global variables for UMD build (if needed later) - globals: { - react: "React", - "react-dom": "ReactDOM", - "react/jsx-runtime": "react/jsx-runtime", - }, - // Preserve CSS as separate file (for manual import fallback) - assetFileNames: (assetInfo) => { - // Vanilla-extract generates index.css, rename to memory-graph.css - if ( - assetInfo.name === "index.css" || - assetInfo.name === "style.css" - ) { - return "memory-graph.css" - } - return assetInfo.name || "asset" - }, - // Don't preserve modules - bundle everything except externals - preserveModules: false, - }, - }, - // Ensure CSS is extracted - cssCodeSplit: false, - // Generate sourcemaps for debugging - sourcemap: true, - // Optimize deps - minify: "esbuild", - target: "esnext", - }, + plugins: [react()], resolve: { alias: { "@": resolve(__dirname, "./src"), }, }, + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, + build: { + lib: { + entry: { + "memory-graph": resolve(__dirname, "src/index.tsx"), + "mock-data": resolve(__dirname, "src/mock-data.ts"), + }, + formats: ["es", "cjs"], + }, + rollupOptions: { + external: ["react", "react-dom", "react/jsx-runtime"], + output: { + globals: { + react: "React", + "react-dom": "ReactDOM", + }, + }, + }, + sourcemap: true, + minify: false, + }, })