Rewrite @supermemory/memory-graph with perf optimizations and consolidate consumers

Package changes (packages/memory-graph):
- Spatial grid nearest-neighbor: O(n^2) -> O(n*k) for doc-doc edge rendering
- Zoom-based edge culling: skip low-similarity edges at low zoom levels
- Modular canvas engine from mono repo (renderer, simulation, viewport, hit-test)
- Remove vanilla-extract CSS, use inline styles only
- Add motion/react for slide-in animations
- Export mock data generator for stress testing
- Add performance and integration tests (15/15 pass)

Web app changes (apps/web):
- Remove local graph implementation (~4400 lines deleted)
- Add thin wrapper (memory-graph-wrapper.tsx) using useGraphApi hook
- Keep app-specific graph-card.tsx and use-graph-api.ts

Playground changes (apps/memory-graph-playground):
- Add stress test buttons (50/100/200/500 docs)
- Add mock data generation via package
- Convert DocumentWithMemories to GraphApiDocument format
- Show FPS counter during stress tests

All TypeScript compiles clean. Performance tests: 15/15 pass.
This commit is contained in:
Vorflux AI 2026-03-26 17:58:28 +00:00
parent 694ad8123a
commit 1c5ef7e5ed
73 changed files with 3446 additions and 10954 deletions

View file

@ -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,61 @@ interface DocumentsResponse {
}
}
/** Convert the external API format to the internal graph format */
function toGraphDocuments(docs: DocumentWithMemories[]): GraphApiDocument[] {
// Use a seeded random for deterministic positions
let seed = 42
const rand = () => {
seed = (seed * 16807 + 0) % 2147483647
return seed / 2147483647
}
return docs.map((doc) => ({
id: doc.id,
title: doc.title,
summary: doc.summary ?? null,
documentType: doc.documentType,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
x: rand() * 1000,
y: rand() * 1000,
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<DocumentWithMemories[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [error, setError] = useState<Error | null>(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<string>("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 +83,6 @@ export default function Home() {
if (page === 1) {
setIsLoading(true)
} else {
setIsLoadingMore(true)
}
setError(null)
@ -79,40 +117,38 @@ export default function Home() {
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],
similarityEdgeRatio: 0.05,
seed: 12345,
})
setMockData({ documents: data.documents })
setDocuments([])
setStressTestCount(count)
setShowGraph(true)
setError(null)
}
// Toggle slideshow
@ -122,16 +158,24 @@ 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 (
<div className="flex flex-col h-screen bg-zinc-950">
{/* Header */}
@ -165,68 +209,66 @@ export default function Home() {
</div>
</header>
{/* State Display Panel - For Testing */}
{showGraph && (
<div className="shrink-0 border-b border-zinc-800 bg-zinc-900/50 px-6 py-3">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-zinc-400">Selected Space:</span>
<span className="font-mono text-blue-400">{selectedSpace}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-zinc-400">Documents:</span>
<span className="font-mono text-emerald-400">
{documents.length}
</span>
</div>
{/* Controls Panel */}
<div className="shrink-0 border-b border-zinc-800 bg-zinc-900/50 px-6 py-3">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-zinc-400">Documents:</span>
<span className="font-mono text-emerald-400">
{displayCount}
</span>
</div>
<div className="flex items-center gap-3">
{stressTestCount > 0 && (
<span className="rounded bg-amber-900/50 px-2 py-0.5 text-xs text-amber-400">
Stress Test Mode
</span>
)}
</div>
<div className="flex items-center gap-3">
{/* Stress test buttons */}
<span className="text-zinc-500 text-xs">Stress Test:</span>
{[50, 100, 200, 500].map((count) => (
<button
onClick={handleToggleSlideshow}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5 ${
isSlideshowActive
? "bg-blue-600 text-white hover:bg-blue-700"
key={count}
type="button"
onClick={() => handleStressTest(count)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
stressTestCount === count
? "bg-amber-600 text-white"
: "border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
}`}
>
{isSlideshowActive ? (
<>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
>
<rect x="6" y="6" width="12" height="12" />
</svg>
Slideshow
</>
) : (
<>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M8 5v14l11-7z" />
</svg>
Slideshow
</>
)}
{count} docs
</button>
<div className="h-6 w-px bg-zinc-700" />
<button
onClick={handleReset}
className="rounded-lg border border-zinc-700 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:bg-zinc-800"
))}
<div className="h-6 w-px bg-zinc-700" />
<button
type="button"
onClick={handleToggleSlideshow}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5 ${
isSlideshowActive
? "bg-blue-600 text-white hover:bg-blue-700"
: "border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
}`}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
>
Reset Filters
</button>
</div>
{isSlideshowActive ? (
<rect x="6" y="6" width="12" height="12" />
) : (
<path d="M8 5v14l11-7z" />
)}
</svg>
Slideshow
</button>
</div>
</div>
)}
</div>
{/* Main content */}
<main className="flex-1 overflow-hidden">
@ -252,21 +294,20 @@ export default function Home() {
Get Started
</h2>
<p className="mb-6 text-zinc-400">
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.
</p>
<div className="text-left text-sm text-zinc-500">
<p className="mb-2 font-medium text-zinc-400">
Features to test:
</p>
<ul className="list-inside list-disc space-y-1">
<li>✨ Search and filter by spaces</li>
<li>✨ Arrow key navigation in spaces dropdown</li>
<li>Pan and zoom the graph</li>
<li>Click on nodes to see details</li>
<li>Drag nodes around</li>
<li>Filter by space</li>
<li>Pagination loads more documents</li>
<li>Arrow key navigation</li>
<li>Stress test with 50-500 documents</li>
<li>FPS counter (shown during stress tests)</li>
</ul>
</div>
</div>
@ -274,20 +315,12 @@ export default function Home() {
) : (
<div className="h-full w-full">
<MemoryGraph
documents={documents}
documents={graphDocuments}
isLoading={isLoading}
isLoadingMore={isLoadingMore}
error={error}
hasMore={hasMore}
loadMoreDocuments={loadMoreDocuments}
totalLoaded={documents.length}
variant="consumer"
// Controlled space selection
selectedSpace={selectedSpace}
onSpaceChange={handleSpaceChange}
// Node limit - prevents performance issues with large graphs
maxNodes={500}
// Slideshow control
maxNodes={1000}
showFps={stressTestCount > 0}
isSlideshowActive={isSlideshowActive}
onSlideshowNodeChange={handleSlideshowNodeChange}
onSlideshowStop={handleSlideshowStop}

View file

@ -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"

View file

@ -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<string, string | number | boolean> | null
embedding?: number[] | null
embeddingModel?: string | null
tokenCount?: number | null
createdAt: string | Date
updatedAt: string | Date
// Fields from join relationship
sourceAddedAt?: Date | null
sourceRelevanceScore?: number | null
sourceMetadata?: Record<string, unknown> | null
spaceContainerTag?: string | null
// 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<string, string | number | boolean> | null
processingMetadata?: Record<string, unknown> | null
raw?: string | null
tokenCount?: number | null
wordCount?: number | null
chunkCount?: number | null
averageChunkSize?: number | null
summaryEmbedding?: number[] | null
summaryEmbeddingModel?: string | null
createdAt: string | Date
updatedAt: string | Date
memoryEntries: MemoryEntry[]
}
export interface DocumentsResponse {
documents: DocumentWithMemories[]
pagination: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}

View file

@ -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,
}

View file

@ -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<GraphCanvasProps>(function GraphCanvas({
nodes,
edges,
width,
height,
highlightDocumentIds,
selectedNodeId = null,
onNodeHover,
onNodeClick,
onNodeDragStart,
onNodeDragEnd,
onViewportChange,
canvasRef: externalCanvasRef,
variant = "console",
simulation,
viewportRef: externalViewportRef,
}) {
const internalCanvasRef = useRef<HTMLCanvasElement>(null)
const canvasRef = externalCanvasRef || internalCanvasRef
// Engine instances — mutable, never trigger re-renders
const viewportRef = useRef<ViewportState | null>(null)
const spatialRef = useRef(new SpatialIndex())
const inputRef = useRef<InputHandler | null>(null)
const rafRef = useRef(0)
const renderNeeded = useRef(true)
const nodeMapRef = useRef(new Map<string, GraphNode>())
// 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<ViewportState | null>
).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 (
<canvas
ref={canvasRef}
className="absolute inset-0"
style={{ touchAction: "none", userSelect: "none" }}
/>
)
})

View file

@ -7,7 +7,7 @@ import type {
GraphViewportResponse,
GraphBoundsResponse,
GraphStatsResponse,
} from "../types"
} from "@supermemory/memory-graph"
interface ViewportParams {
minX: number

View file

@ -1,289 +0,0 @@
"use client"
import { useMemo, useRef, useEffect } from "react"
import { MEMORY_BORDER, EDGE_COLORS } from "../constants"
import type {
GraphNode,
GraphEdge,
GraphApiDocument,
GraphApiMemory,
GraphApiEdge,
DocumentNodeData,
MemoryNodeData,
} from "../types"
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
const ONE_DAY_MS = 24 * 60 * 60 * 1000
const MEMORY_CLUSTER_SPREAD = 150
function getMemoryBorderColor(mem: GraphApiMemory): string {
if (mem.isForgotten) return MEMORY_BORDER.forgotten
if (mem.forgetAfter) {
const msLeft = new Date(mem.forgetAfter).getTime() - Date.now()
if (msLeft < SEVEN_DAYS_MS) return MEMORY_BORDER.expiring
}
const age = Date.now() - new Date(mem.createdAt).getTime()
if (age < ONE_DAY_MS) return MEMORY_BORDER.recent
return MEMORY_BORDER.default
}
function getEdgeVisualProps(similarity: number) {
return {
opacity: 0.3 + similarity * 0.5,
thickness: 1 + similarity * 1.5,
}
}
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)
}
const rangeX = maxX - minX || 1
const rangeY = maxY - minY || 1
const PAD = 100
return documents.map((doc) => ({
...doc,
x: PAD + ((doc.x - minX) / rangeX) * (1000 - 2 * PAD),
y: PAD + ((doc.y - minY) / rangeY) * (1000 - 2 * PAD),
}))
}
export function useGraphData(
documents: GraphApiDocument[],
apiEdges: GraphApiEdge[],
draggingNodeId: string | null,
canvasWidth: number,
canvasHeight: number,
) {
const nodeCache = useRef<Map<string, GraphNode>>(new Map())
useEffect(() => {
if (!documents || documents.length === 0) return
const currentIds = new Set<string>()
for (const doc of documents) {
currentIds.add(doc.id)
for (const mem of doc.memories) currentIds.add(mem.id)
}
for (const [id] of nodeCache.current.entries()) {
if (!currentIds.has(id)) nodeCache.current.delete(id)
}
}, [documents])
const { scale, offsetX, offsetY } = useMemo(() => {
if (canvasWidth === 0 || canvasHeight === 0) {
return { scale: 1, offsetX: 0, offsetY: 0 }
}
const paddingFactor = 0.8
const s = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000
const ox = (canvasWidth - 1000 * s) / 2
const oy = (canvasHeight - 1000 * s) / 2
return { scale: s, offsetX: ox, offsetY: oy }
}, [canvasWidth, canvasHeight])
const normalizedDocs = useMemo(
() => normalizeDocCoordinates(documents),
[documents],
)
const nodes = useMemo(() => {
if (!normalizedDocs || normalizedDocs.length === 0) return []
const result: GraphNode[] = []
for (const doc of normalizedDocs) {
const initialX = doc.x * scale + offsetX
const initialY = doc.y * scale + offsetY
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,
}
if (docNode) {
docNode.data = docData
docNode.isDragging = draggingNodeId === doc.id
} else {
docNode = {
id: doc.id,
type: "document",
x: initialX,
y: initialY,
data: docData,
size: 50,
borderColor: "#2A2F36",
isHovered: false,
isDragging: false,
}
nodeCache.current.set(doc.id, docNode)
}
result.push(docNode)
const memCount = doc.memories.length
for (let i = 0; i < memCount; i++) {
const mem = doc.memories[i]!
let memNode = nodeCache.current.get(mem.id)
const memData: MemoryNodeData = {
...mem,
documentId: doc.id,
content: mem.memory,
}
if (memNode) {
memNode.data = memData
memNode.borderColor = getMemoryBorderColor(mem)
memNode.isDragging = draggingNodeId === mem.id
} else {
const angle = (i / memCount) * 2 * Math.PI
memNode = {
id: mem.id,
type: "memory",
x: docNode.x + Math.cos(angle) * MEMORY_CLUSTER_SPREAD,
y: docNode.y + Math.sin(angle) * MEMORY_CLUSTER_SPREAD,
data: memData,
size: 36,
borderColor: getMemoryBorderColor(mem),
isHovered: false,
isDragging: false,
}
nodeCache.current.set(mem.id, memNode)
}
result.push(memNode)
}
}
return result
}, [normalizedDocs, scale, offsetX, offsetY, draggingNodeId])
const edges = useMemo(() => {
if (!normalizedDocs || normalizedDocs.length === 0) return []
const result: GraphEdge[] = []
const allNodeIds = new Set(nodes.map((n) => n.id))
for (const doc of normalizedDocs) {
for (const mem of doc.memories) {
result.push({
id: `dm-${doc.id}-${mem.id}`,
source: doc.id,
target: mem.id,
similarity: 1,
visualProps: { opacity: 0.3, thickness: 1.5 },
edgeType: "doc-memory",
})
}
}
for (const doc of normalizedDocs) {
for (const mem of doc.memories) {
if (mem.parentMemoryId && allNodeIds.has(mem.parentMemoryId)) {
result.push({
id: `ver-${mem.parentMemoryId}-${mem.id}`,
source: mem.parentMemoryId,
target: mem.id,
similarity: 1,
visualProps: { opacity: 0.6, thickness: 2 },
edgeType: "version",
})
}
}
}
for (const apiEdge of apiEdges) {
if (!allNodeIds.has(apiEdge.source) || !allNodeIds.has(apiEdge.target)) {
continue
}
result.push({
id: `sim-${apiEdge.source}-${apiEdge.target}`,
source: apiEdge.source,
target: apiEdge.target,
similarity: apiEdge.similarity,
visualProps: getEdgeVisualProps(apiEdge.similarity),
edgeType: "similarity",
})
}
return result
}, [normalizedDocs, apiEdges, nodes])
return { nodes, edges, scale, offsetX, offsetY }
}
export function screenToBackendCoords(
screenX: number,
screenY: number,
panX: number,
panY: number,
zoom: number,
canvasWidth: number,
canvasHeight: number,
): { x: number; y: number } {
const canvasX = (screenX - panX) / zoom
const canvasY = (screenY - panY) / zoom
const paddingFactor = 0.8
const s = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000
const ox = (canvasWidth - 1000 * s) / 2
const oy = (canvasHeight - 1000 * s) / 2
return {
x: (canvasX - ox) / s,
y: (canvasY - oy) / s,
}
}
export function calculateBackendViewport(
panX: number,
panY: number,
zoom: number,
canvasWidth: number,
canvasHeight: number,
): { minX: number; maxX: number; minY: number; maxY: number } {
const topLeft = screenToBackendCoords(
0,
0,
panX,
panY,
zoom,
canvasWidth,
canvasHeight,
)
const bottomRight = screenToBackendCoords(
canvasWidth,
canvasHeight,
panX,
panY,
zoom,
canvasWidth,
canvasHeight,
)
return {
minX: Math.max(0, Math.min(topLeft.x, bottomRight.x)),
maxX: Math.max(topLeft.x, bottomRight.x),
minY: Math.max(0, Math.min(topLeft.y, bottomRight.y)),
maxY: Math.max(topLeft.y, bottomRight.y),
}
}

View file

@ -1,33 +1,19 @@
// Memory Graph components
export { MemoryGraph } from "./memory-graph"
export type { MemoryGraphProps } from "./memory-graph"
// Re-export the wrapper as MemoryGraph (same name, drop-in replacement)
export { MemoryGraph } from "./memory-graph-wrapper"
export type { MemoryGraphWrapperProps as MemoryGraphProps } from "./memory-graph-wrapper"
// Keep GraphCard (app-specific)
export { GraphCard } from "./graph-card"
export type { GraphCardProps } from "./graph-card"
// Hooks
export { useGraphApi } from "./hooks/use-graph-api"
export {
useGraphData,
calculateBackendViewport,
screenToBackendCoords,
} from "./hooks/use-graph-data"
// Canvas engine
export { ViewportState } from "./canvas/viewport"
export { ForceSimulation } from "./canvas/simulation"
// Types
// Re-export useful types from the package
export type {
GraphNode,
GraphEdge,
GraphApiDocument,
GraphApiMemory,
GraphApiEdge,
GraphViewportResponse,
GraphBoundsResponse,
GraphStatsResponse,
DocumentNodeData,
MemoryNodeData,
DocumentWithMemories,
MemoryEntry,
} from "./types"
} from "@supermemory/memory-graph"
// Keep the API hook export
export { useGraphApi } from "./hooks/use-graph-api"

View file

@ -1,599 +0,0 @@
"use client"
import { useIsMobile } from "@hooks/use-mobile"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@ui/components/collapsible"
import { ChevronDown, ChevronRight } from "lucide-react"
import { memo, useEffect, useState } from "react"
import type { GraphEdge, GraphNode, LegendProps } from "./types"
import { cn } from "@lib/utils"
// Cookie utility functions for legend state
const setCookie = (name: string, value: string, days = 365) => {
if (typeof document === "undefined") return
const expires = new Date()
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`
}
const getCookie = (name: string): string | null => {
if (typeof document === "undefined") return null
const nameEQ = `${name}=`
const ca = document.cookie.split(";")
for (let i = 0; i < ca.length; i++) {
let c = ca[i]
if (!c) continue
while (c.charAt(0) === " ") c = c.substring(1, c.length)
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length)
}
return null
}
interface ExtendedLegendProps extends LegendProps {
id?: string
nodes?: GraphNode[]
edges?: GraphEdge[]
isLoading?: boolean
}
// Toggle switch component matching Figma design
const SmallToggle = memo(function SmallToggle({
checked,
onChange,
}: {
checked: boolean
onChange: (checked: boolean) => void
}) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={cn(
"box-border flex flex-row justify-center items-center",
"w-6 h-3.5 rounded-full transition-all duration-200",
"border border-white/5",
"shadow-[inset_1px_1px_2px_rgba(0,0,0,0.5)]",
)}
style={{
background: "#0D121A",
}}
>
<div
className={cn(
"w-2.5 h-2.5 rounded-full transition-all duration-200",
checked ? "ml-auto mr-0.5" : "mr-auto ml-0.5",
)}
style={{
background: checked ? "#162E57" : "rgba(115, 115, 115, 0.25)",
}}
/>
</button>
)
})
// 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 (
<svg
width={size}
height={size}
viewBox="0 0 12 12"
style={{ opacity }}
className="shrink-0"
aria-hidden="true"
>
<polygon
points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75"
fill={fill}
stroke={stroke}
strokeWidth="0.6"
/>
</svg>
)
})
// Document icon (rounded square)
const DocumentIcon = memo(function DocumentIcon() {
return (
<div
className="w-3 h-3 shrink-0 rounded-[2.4px] flex items-center justify-center"
style={{
background: "#1B1F24",
boxShadow:
"0px 0.85px 4.26px rgba(0, 0, 0, 0.25), inset 0.21px 0.21px 0.21px rgba(255, 255, 255, 0.1)",
}}
>
<div
className="w-[10.8px] h-[10.8px] rounded-[1.8px]"
style={{
background: "#262C33",
boxShadow: "inset 0.43px 0.43px 1.28px rgba(11, 15, 21, 0.4)",
}}
/>
</div>
)
})
// Connection icon (graph)
const ConnectionIcon = memo(function ConnectionIcon() {
return (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
className="shrink-0"
aria-hidden="true"
>
<circle cx="3" cy="3" r="1.5" fill="#90A2B9" />
<circle cx="9" cy="3" r="1.5" fill="#90A2B9" />
<circle cx="6" cy="9" r="1.5" fill="#90A2B9" />
<line x1="3" y1="3" x2="9" y2="3" stroke="#90A2B9" strokeWidth="0.8" />
<line x1="3" y1="3" x2="6" y2="9" stroke="#90A2B9" strokeWidth="0.8" />
<line x1="9" y1="3" x2="6" y2="9" stroke="#90A2B9" strokeWidth="0.8" />
</svg>
)
})
// Line icon for connections
const LineIcon = memo(function LineIcon({
color,
dashed = false,
}: {
color: string
dashed?: boolean
}) {
return (
<div className="w-3 h-3 flex items-center justify-center shrink-0">
<div
className="w-3 h-0"
style={{
borderTop: `1.6px ${dashed ? "dashed" : "solid"} ${color}`,
}}
/>
</div>
)
})
// Similarity circle icon
const SimilarityCircle = memo(function SimilarityCircle({
variant,
}: {
variant: "strong" | "weak"
}) {
return (
<div
className="w-3 h-3 rounded-full shrink-0"
style={{
background: variant === "strong" ? "#616D7F" : "#313A44",
border: "0.6px solid rgba(255, 255, 255, 0.2)",
}}
/>
)
})
// 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 (
<div className="flex flex-col">
<button
type="button"
onClick={expandable ? onToggle : undefined}
className={cn(
"flex flex-row justify-between items-center w-full py-0",
expandable && "cursor-pointer",
)}
>
<div className="flex flex-row items-center gap-2">
{icon}
<span className="text-xs text-[#FAFAFA] font-normal">{label}</span>
{expandable && (
<ChevronDown
className={cn(
"w-3 h-3 text-[#737373] transition-transform",
expanded && "rotate-180",
)}
/>
)}
</div>
<span className="text-xs text-[#737373]">{count}</span>
</button>
{expandable && expanded && children && (
<div className="pl-2.5 pt-1.5 flex flex-col gap-1.5">{children}</div>
)}
</div>
)
})
// 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 (
<div className="flex flex-row justify-between items-center w-full">
<div className="flex flex-row items-center gap-2">
{icon}
<span className="text-xs text-[#FAFAFA] font-normal">{label}</span>
</div>
<SmallToggle checked={checked} onChange={onChange} />
</div>
)
})
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 (
<div
className={cn("absolute z-20 overflow-hidden", "bottom-4 left-4")}
style={{
width: "214px",
}}
id={id}
>
<Collapsible onOpenChange={handleToggleExpanded} open={isExpanded}>
{/* Glass background */}
<div
className="absolute inset-0 rounded-[10px]"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
border: "1px solid rgba(23, 24, 26, 0.7)",
}}
/>
<div className="relative z-10 p-3">
{/* Header - always visible */}
<CollapsibleTrigger className="flex flex-row items-center gap-1.5 w-full">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-[#FAFAFA]" />
) : (
<ChevronRight className="w-4 h-4 text-[#FAFAFA]" />
)}
<span
className="text-sm text-white font-normal"
style={{
fontFamily: "DM Sans",
letterSpacing: "-0.01em",
}}
>
Legend
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div
className="mt-4 flex flex-row gap-3 overflow-y-auto max-h-[312px]"
style={{ scrollbarWidth: "thin" }}
>
{/* Main content column */}
<div className="flex flex-col gap-4 flex-1">
{/* STATISTICS Section */}
<div className="flex flex-col gap-2">
<span
className="text-xs text-[#737373] font-normal"
style={{ fontFamily: "Space Grotesk" }}
>
STATISTICS
</span>
<div className="flex flex-col gap-1.5">
{/* Memories */}
<StatRow
icon={
<HexagonIcon
fill="#0D2034"
stroke="#3B73B8"
size={12}
/>
}
label="Memories"
count={memoryCount}
expandable
expanded={memoriesExpanded}
onToggle={() => setMemoriesExpanded(!memoriesExpanded)}
>
<div className="flex flex-col gap-1.5 pl-0">
<div className="flex flex-row justify-between items-center">
<div className="flex items-center gap-2">
<HexagonIcon
fill="#0D2034"
stroke="#3B73B8"
size={12}
/>
<span className="text-xs text-[#FAFAFA]">
Memory (latest)
</span>
</div>
<span className="text-xs text-[#737373]">76</span>
</div>
<div className="flex flex-row justify-between items-center">
<div className="flex items-center gap-2">
<HexagonIcon
fill="#0D2034"
stroke="#5F7085"
opacity={0.6}
size={12}
/>
<span className="text-xs text-[#FAFAFA]">
Memory (oldest)
</span>
</div>
<span className="text-xs text-[#737373]">182</span>
</div>
<div className="flex flex-row justify-between items-center">
<div className="flex items-center gap-2">
<div className="w-3 h-3 flex items-center justify-center">
<HexagonIcon
fill="#0C1827"
stroke="rgba(54, 155, 253, 0.2)"
size={12}
/>
</div>
<span className="text-xs text-[#FAFAFA]">
Score
</span>
</div>
<span className="text-xs text-[#737373]">23</span>
</div>
<div className="flex flex-row justify-between items-center">
<div className="flex items-center gap-2">
<svg
width="12"
height="12"
viewBox="0 0 12 12"
className="shrink-0"
aria-hidden="true"
>
<polygon
points="6,0 11.2,6 6,12 0.8,6"
fill="#00FFA9"
fillOpacity="0.6"
stroke="#00FFA9"
strokeWidth="0.6"
/>
</svg>
<span className="text-xs text-[#FAFAFA]">
New memory
</span>
</div>
<span className="text-xs text-[#737373]">17</span>
</div>
<div className="flex flex-row justify-between items-center">
<div className="flex items-center gap-2">
<svg
width="12"
height="12"
viewBox="0 0 12 12"
className="shrink-0"
aria-hidden="true"
>
<polygon
points="6,0 11.2,6 6,12 0.8,6"
fill="#4D2E00"
fillOpacity="0.6"
stroke="#FE9900"
strokeWidth="0.6"
/>
</svg>
<span className="text-xs text-[#FAFAFA]">
Expiring soon
</span>
</div>
<span className="text-xs text-[#737373]">11</span>
</div>
<div className="flex flex-row justify-between items-center">
<div className="flex items-center gap-2">
<div className="w-3 h-3 relative shrink-0">
<HexagonIcon
fill="#60272C"
stroke="#FF6467"
opacity={0.6}
size={12}
/>
</div>
<span className="text-xs text-[#FAFAFA]">
Forgotten
</span>
</div>
<span className="text-xs text-[#737373]">6</span>
</div>
</div>
</StatRow>
{/* Documents */}
<StatRow
icon={<DocumentIcon />}
label="Documents"
count={documentCount}
expandable
expanded={documentsExpanded}
onToggle={() => setDocumentsExpanded(!documentsExpanded)}
/>
{/* Connections */}
<StatRow
icon={<ConnectionIcon />}
label="Connections"
count={connectionCount}
expandable
expanded={connectionsExpanded}
onToggle={() =>
setConnectionsExpanded(!connectionsExpanded)
}
>
<div className="flex flex-col gap-1.5">
<div className="flex flex-row justify-between items-center">
<div className="flex items-center gap-2">
<LineIcon color="#5070A1" />
<span className="text-xs text-[#FAFAFA]">
Doc &gt; Memory
</span>
</div>
</div>
<ToggleRow
icon={<LineIcon color="#5070A1" dashed />}
label="Doc similarity"
checked={showStrong}
onChange={setShowStrong}
/>
</div>
</StatRow>
</div>
</div>
{/* RELATIONS Section */}
<div className="flex flex-col gap-2">
<span
className="text-xs text-[#737373] font-normal"
style={{ fontFamily: "Space Grotesk" }}
>
RELATIONS
</span>
<div className="flex flex-col gap-1.5">
<ToggleRow
icon={<LineIcon color="#7800AB" />}
label="Updates"
checked={showUpdates}
onChange={setShowUpdates}
/>
<ToggleRow
icon={<LineIcon color="#00732E" />}
label="Extends"
checked={showExtends}
onChange={setShowExtends}
/>
<ToggleRow
icon={<LineIcon color="#0054D1" />}
label="Inferences"
checked={showInferences}
onChange={setShowInferences}
/>
</div>
</div>
{/* SIMILARITY Section */}
<div className="flex flex-col gap-2">
<span
className="text-xs text-[#737373] font-normal"
style={{ fontFamily: "Space Grotesk" }}
>
SIMILARITY
</span>
<div className="flex flex-col gap-1.5">
<ToggleRow
icon={<SimilarityCircle variant="strong" />}
label="Strong"
checked={showStrong}
onChange={setShowStrong}
/>
<ToggleRow
icon={<SimilarityCircle variant="weak" />}
label="Weak"
checked={showWeak}
onChange={setShowWeak}
/>
</div>
</div>
</div>
{/* Scrollbar indicator */}
<div
className="w-0.5 h-12 rounded-sm self-start"
style={{ background: "#737373" }}
/>
</div>
</CollapsibleContent>
</div>
</Collapsible>
</div>
)
})
Legend.displayName = "Legend"

View file

@ -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<LoadingIndicatorProps>(
({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => {
if (!isLoading && !isLoadingMore) return null
return (
<div className="absolute z-30 rounded-xl overflow-hidden top-[5.5rem] left-4">
{/* Glass effect background */}
<GlassMenuEffect rounded="rounded-xl" />
<div className="relative z-10 text-slate-300 px-4 py-3">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 animate-spin text-blue-300" />
<span className="text-sm">
{isLoading
? "Loading memory graph..."
: `Loading more documents... (${totalLoaded})`}
</span>
</div>
</div>
</div>
)
},
)
LoadingIndicator.displayName = "LoadingIndicator"

View file

@ -0,0 +1,78 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph"
import type { GraphApiDocument, GraphApiEdge } 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<HTMLCanvasElement | null>
}
export function MemoryGraph({
children,
isLoading: externalIsLoading = false,
error: externalError = null,
variant = "console",
containerTags,
documentIds,
maxNodes = 200,
canvasRef,
...rest
}: MemoryGraphWrapperProps) {
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
const containerRef = useRef<HTMLDivElement>(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 {
data: apiData,
isLoading: apiIsLoading,
error: apiError,
} = useGraphApi({
containerTags,
documentIds,
limit: maxNodes,
enabled: containerSize.width > 0 && containerSize.height > 0,
})
return (
<div ref={containerRef} className="w-full h-full">
<MemoryGraphBase
documents={apiData.documents as GraphApiDocument[]}
apiEdges={apiData.edges as GraphApiEdge[]}
isLoading={externalIsLoading || apiIsLoading}
error={externalError || apiError}
variant={variant}
maxNodes={maxNodes}
canvasRef={canvasRef}
totalCount={apiData.totalCount}
{...rest}
>
{children}
</MemoryGraphBase>
</div>
)
}

View file

@ -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<HTMLCanvasElement | null>
}
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<DOMRect | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<ViewportState | null>(null)
const simulationRef = useRef<ForceSimulation | null>(null)
const chainIndex = useRef(new VersionChainIndex())
// React state only for things that affect DOM
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
const [selectedNode, setSelectedNode] = useState<string | null>(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 (
<div
className="h-full flex items-center justify-center"
style={{ backgroundColor: colors.background.primary }}
>
<div className="rounded-xl overflow-hidden">
<GlassMenuEffect rounded="rounded-xl" />
<div className="relative z-10 text-slate-300 px-6 py-4">
Error loading graph: {error.message}
</div>
</div>
</div>
)
}
return (
<div className="relative h-full rounded-xl overflow-hidden">
<LoadingIndicator
isLoading={isLoading}
isLoadingMore={false}
totalLoaded={apiData.totalCount}
variant={variant}
/>
{!isLoading && !nodes.some((n) => n.type === "document") && children}
<div
className="w-full h-full relative overflow-hidden touch-none select-none"
ref={containerRef}
>
{containerSize.width > 0 && containerSize.height > 0 && (
<GraphCanvas
nodes={nodes}
edges={edges}
width={containerSize.width}
height={containerSize.height}
highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
selectedNodeId={selectedNode}
onNodeHover={handleNodeHover}
onNodeClick={handleNodeClick}
onNodeDragStart={handleNodeDragStart}
onNodeDragEnd={handleNodeDragEnd}
onViewportChange={handleViewportChange}
canvasRef={canvasRef}
variant={variant}
simulation={simulationRef.current ?? undefined}
viewportRef={viewportRef}
/>
)}
{activeNodeData && activePopoverPosition && (
<NodeHoverPopover
node={activeNodeData}
screenX={activePopoverPosition.screenX}
screenY={activePopoverPosition.screenY}
nodeRadius={activePopoverPosition.nodeRadius}
containerBounds={containerBounds ?? undefined}
versionChain={activeVersionChain}
onNavigateNext={navigateNext}
onNavigatePrev={navigatePrev}
onNavigateUp={navigateUp}
onNavigateDown={navigateDown}
onSelectNode={handleNodeClick}
/>
)}
<div>
{containerSize.width > 0 && (
<NavigationControls
onCenter={handleCenter}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onAutoFit={handleAutoFit}
nodes={nodes}
className="absolute bottom-18 left-4 z-15"
zoomLevel={zoomDisplay}
/>
)}
<Legend
edges={edges}
id={legendId}
isLoading={isLoading}
nodes={nodes}
variant={variant}
/>
</div>
</div>
</div>
)
}

View file

@ -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 (
<div
className="flex flex-row items-center gap-1 px-1.5 py-0.5 rounded"
style={{
background: "rgba(33, 33, 33, 0.5)",
border: "1px solid rgba(115, 115, 115, 0.2)",
}}
>
<span className="text-[10px] text-[#737373] font-medium leading-none">
{keys}
</span>
</div>
)
})
// Navigation buttons component
const NavigationButtons = memo(function NavigationButtons({
onAutoFit,
onCenter,
onZoomIn,
onZoomOut,
zoomLevel,
}: {
onAutoFit: () => void
onCenter: () => void
onZoomIn: () => void
onZoomOut: () => void
zoomLevel: number
}) {
return (
<div className="flex flex-col gap-1">
{/* Fit button */}
<button
type="button"
className="flex w-fit gap-3 items-center justify-between px-3 py-2 rounded-full cursor-pointer hover:bg-white/10 transition-colors"
onClick={onAutoFit}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
border: "1px solid rgba(23, 24, 26, 0.7)",
boxShadow: "1.5px 1.5px 20px rgba(0, 0, 0, 0.65)",
}}
>
<span className="text-xs text-white font-medium">Fit</span>
<KeyboardShortcut keys="Z" />
</button>
{/* Center button */}
<button
type="button"
className="flex w-fit gap-3 items-center justify-between px-3 py-2 rounded-full cursor-pointer hover:bg-white/10 transition-colors"
onClick={onCenter}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
border: "1px solid rgba(23, 24, 26, 0.7)",
boxShadow: "1.5px 1.5px 20px rgba(0, 0, 0, 0.65)",
}}
>
<span className="text-xs text-white font-medium">Center</span>
<KeyboardShortcut keys="C" />
</button>
{/* Zoom controls */}
<div
className="flex w-fit gap-3 items-center justify-between px-3 py-2 rounded-full"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
border: "1px solid rgba(23, 24, 26, 0.7)",
boxShadow: "1.5px 1.5px 20px rgba(0, 0, 0, 0.65)",
}}
>
<span className="text-xs text-white font-medium">{zoomLevel}%</span>
<div className="flex flex-row items-center gap-0.5">
<button
type="button"
onClick={onZoomOut}
className="w-5 h-5 flex items-center justify-center rounded bg-black/20 border border-white/10 text-white/70 hover:bg-white/10 hover:text-white transition-colors"
>
<span className="text-xs">−</span>
</button>
<button
type="button"
onClick={onZoomIn}
className="w-5 h-5 flex items-center justify-center rounded bg-black/20 border border-white/10 text-white/70 hover:bg-white/10 hover:text-white transition-colors"
>
<span className="text-xs">+</span>
</button>
</div>
</div>
</div>
)
})
const SettingsButton = memo(function SettingsButton() {
return (
<button
type="button"
className="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-white/10 transition-colors"
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
border: "1px solid rgba(23, 24, 26, 0.7)",
boxShadow: "1.5px 1.5px 20px rgba(0, 0, 0, 0.65)",
}}
>
<Settings className="w-5 h-5 text-[#737373]" />
</button>
)
})
export const NavigationControls = memo<NavigationControlsProps>(
({
onCenter,
onZoomIn,
onZoomOut,
onAutoFit,
nodes,
className = "",
zoomLevel,
}) => {
if (nodes.length === 0) {
return null
}
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className="flex flex-row items-end gap-2">
<NavigationButtons
onAutoFit={onAutoFit}
onCenter={onCenter}
onZoomIn={onZoomIn}
onZoomOut={onZoomOut}
zoomLevel={zoomLevel}
/>
{/* Commented out for now as we are not using this */}
{/*<SettingsButton />*/}
</div>
</div>
)
},
)
NavigationControls.displayName = "NavigationControls"

View file

@ -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 (
<span
className="inline-flex items-center justify-center w-4 h-4 rounded text-[10px] font-medium"
style={{
backgroundColor: "#181A1E",
border: "1px solid #2A2C2F",
color: "#737373",
}}
>
{children}
</span>
)
}
function NavButton({
icon,
label,
onClick,
}: {
icon: string
label: string
onClick?: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
style={{ background: "none", border: "none", padding: "2px 0" }}
>
<KeyBadge>{icon}</KeyBadge>
<span
className="text-[11px] whitespace-nowrap"
style={{ color: "#525D6E" }}
>
{label}
</span>
</button>
)
}
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 (
<button
type="button"
onClick={copy}
className="flex items-center gap-1.5 group cursor-pointer"
style={{ background: "none", border: "none", padding: 0 }}
>
<span className="text-[10px]" style={{ color: "#525D6E" }}>
{label}
</span>
<span
className="text-[10px] font-mono group-hover:text-white transition-colors"
style={{ color: "#737373" }}
>
{copied ? "Copied!" : short}
</span>
{!copied && (
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="#525D6E"
strokeWidth="2"
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
)}
</button>
)
}
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 (
<div className="flex flex-col gap-0 max-h-[120px] overflow-y-auto">
{chain.map((entry) => {
const isCurrent = entry.id === currentId
return (
<button
key={entry.id}
type="button"
onClick={() => onSelect?.(entry.id)}
className="flex items-start gap-2 px-3 py-1.5 text-left cursor-pointer transition-colors"
style={{
background: isCurrent ? "#0A1825" : "transparent",
border: "none",
borderLeft: isCurrent ? "2px solid #36FDFD" : "2px solid #1A2333",
}}
>
<span
className="text-[10px] font-semibold shrink-0 mt-px"
style={{
color: entry.isForgotten
? "#DC2626"
: isCurrent
? "#36FDFD"
: "#525D6E",
}}
>
v{entry.version}
</span>
<span
className="text-[11px] leading-tight"
style={{
color: isCurrent ? "#9CA3AF" : "#4A5568",
}}
>
{truncate(entry.memory, 60)}
</span>
</button>
)
})}
</div>
)
}
export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
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 (
<div className="pointer-events-none absolute inset-0 z-[100]">
<svg
className="absolute inset-0 w-full h-full overflow-visible"
style={{ pointerEvents: "none" }}
>
<path
d={connectorPath}
stroke="#3B73B8"
strokeWidth="1.5"
fill="none"
strokeDasharray="4 2"
/>
</svg>
<div
className="absolute flex gap-3 pointer-events-auto"
style={{ left: popoverX, top: popoverY }}
>
{/* Card */}
<div
className="flex flex-col rounded-xl overflow-hidden"
style={{ width: CARD_W, backgroundColor: "#0C1829" }}
>
{/* Content — show timeline if chain exists, otherwise plain text */}
{hasChain ? (
<VersionTimeline
chain={versionChain}
currentId={node.id}
onSelect={onSelectNode}
/>
) : (
<div
className="p-3 overflow-hidden"
style={{ backgroundColor: "#060D17" }}
>
<p
className="m-0 leading-[135%]"
style={{
fontFamily: "'DM Sans', sans-serif",
fontSize: 12,
color: "#525D6E",
}}
>
{truncate(content, 100) || "No content"}
</p>
</div>
)}
{/* Forget info (memory-only) */}
{memoryMeta && hasForgetInfo && (
<div
className="px-3 py-1.5 flex flex-col gap-0.5"
style={{
backgroundColor: "#0A1320",
borderTop: "1px solid #1A2333",
}}
>
{memoryMeta.forgetAfter && (
<span className="text-[10px]" style={{ color: "#F59E0B" }}>
Expires:{" "}
{new Date(memoryMeta.forgetAfter).toLocaleDateString()}
</span>
)}
{memoryMeta.forgetReason && (
<span className="text-[10px]" style={{ color: "#8B8B8B" }}>
Reason: {memoryMeta.forgetReason}
</span>
)}
{memoryMeta.isForgotten && !memoryMeta.forgetReason && (
<span className="text-[10px]" style={{ color: "#EF4444" }}>
Forgotten
</span>
)}
</div>
)}
{/* Bottom bar */}
<div
className="flex items-center justify-between px-3 py-2"
style={{
backgroundColor: "#0C1829",
borderTop: "1px solid #1A2333",
}}
>
{memoryMeta ? (
<>
<span
className="text-xs font-medium"
style={{
color: memoryMeta.isForgotten
? "#DC2626"
: memoryMeta.isLatest
? "#05A376"
: "#525D6E",
}}
>
v{memoryMeta.version}{" "}
{memoryMeta.isForgotten
? "Forgotten"
: memoryMeta.isLatest
? "Latest"
: "Superseded"}
</span>
</>
) : (
<>
<span className="text-xs" style={{ color: "#525D6E" }}>
{docData?.type || "document"}
</span>
<span className="text-xs" style={{ color: "#525D6E" }}>
{docData?.memories?.length ?? 0} memories
</span>
</>
)}
</div>
{/* ID row */}
<div
className="px-3 py-1.5 flex items-center"
style={{
backgroundColor: "#080E18",
borderTop: "1px solid #1A2333",
}}
>
{isMemory ? (
<CopyableId label="Memory" value={node.id} />
) : (
<CopyableId label="Document" value={node.id} />
)}
</div>
</div>
{/* Navigation */}
<div
className="flex flex-col justify-center gap-1.5 px-3 py-2 rounded-lg"
style={{ backgroundColor: "#0C1829" }}
>
{isMemory && (
<NavButton
icon="↑"
label={hasChain ? "Older version" : "Go to document"}
onClick={onNavigateUp}
/>
)}
{(isMemory ? hasChain : true) && (
<NavButton
icon="↓"
label={isMemory ? "Newer version" : "Go to memory"}
onClick={onNavigateDown}
/>
)}
<NavButton
icon="→"
label={isMemory ? "Next memory" : "Next document"}
onClick={onNavigateNext}
/>
<NavButton
icon="←"
label={isMemory ? "Prev memory" : "Prev document"}
onClick={onNavigatePrev}
/>
</div>
</div>
</div>
)
},
)

View file

@ -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<NodePopoverProps>(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 */}
<div
onClick={handleBackdropClick}
className={cn(
"fixed z-[999] pointer-events-auto bg-transparent",
!containerBounds && "inset-0",
)}
style={backdropStyle}
/>
{/* Popover content */}
<div
onClick={(e) => 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
<div className="flex flex-col gap-3">
{/* Header */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-slate-400"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<h3 className="text-base font-bold text-white m-0">Document</h3>
</div>
<button
type="button"
onClick={onClose}
className="p-1 bg-transparent border-none text-slate-400 cursor-pointer text-base leading-none transition-colors hover:text-white"
>
×
</button>
</div>
{/* Sections */}
<div className="flex flex-col gap-3">
{/* Title */}
<div>
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
Title
</div>
<p className="text-sm text-slate-300 m-0 leading-relaxed">
{(node.data as any).title || "Untitled Document"}
</p>
</div>
{/* Summary - truncated to 2 lines */}
{(node.data as any).summary && (
<div>
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
Summary
</div>
<p className="text-sm text-slate-300 m-0 leading-relaxed line-clamp-2">
{(node.data as any).summary}
</p>
</div>
)}
{/* Type */}
<div>
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
Type
</div>
<p className="text-sm text-slate-300 m-0 leading-relaxed">
{(node.data as any).type || "Document"}
</p>
</div>
{/* Memory Count */}
<div>
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
Memory Count
</div>
<p className="text-sm text-slate-300 m-0 leading-relaxed">
{(node.data as any).memoryEntries?.length || 0} memories
</p>
</div>
{/* URL */}
{((node.data as any).url || (node.data as any).customId) && (
<div>
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
URL
</div>
<a
href={(() => {
const doc = node.data as any
if (doc.type === "google_doc" && doc.customId) {
return `https://docs.google.com/document/d/${doc.customId}`
}
if (doc.type === "google_sheet" && doc.customId) {
return `https://docs.google.com/spreadsheets/d/${doc.customId}`
}
if (doc.type === "google_slide" && doc.customId) {
return `https://docs.google.com/presentation/d/${doc.customId}`
}
return doc.url ?? undefined
})()}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-indigo-400 no-underline flex items-center gap-1 transition-colors hover:text-indigo-300"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
View Document
</a>
</div>
)}
{/* Footer with metadata */}
<div className="pt-3 border-t border-slate-600/50 flex items-center gap-4 text-xs text-slate-400">
<div className="flex items-center gap-1">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>
{new Date(
(node.data as any).createdAt,
).toLocaleDateString()}
</span>
</div>
<div className="flex items-center gap-1 overflow-hidden text-ellipsis whitespace-nowrap flex-1">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="4" y1="9" x2="20" y2="9" />
<line x1="4" y1="15" x2="20" y2="15" />
<line x1="10" y1="3" x2="8" y2="21" />
<line x1="16" y1="3" x2="14" y2="21" />
</svg>
<span className="overflow-hidden text-ellipsis">
{node.id}
</span>
</div>
</div>
</div>
</div>
) : (
// Memory popover
<div className="flex flex-col gap-3">
{/* Header */}
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-blue-400"
>
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z" />
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z" />
</svg>
<h3 className="text-base font-bold text-white m-0">Memory</h3>
</div>
<button
type="button"
onClick={onClose}
className="p-1 bg-transparent border-none text-slate-400 cursor-pointer text-base leading-none transition-colors hover:text-white"
>
×
</button>
</div>
{/* Sections */}
<div className="flex flex-col gap-3">
{/* Memory content */}
<div>
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
Memory
</div>
<p className="text-sm text-slate-300 m-0 leading-relaxed">
{(node.data as any).memory ||
(node.data as any).content ||
"No content"}
</p>
{(node.data as any).isForgotten && (
<div className="mt-2 px-2 py-1 bg-red-600/15 rounded text-xs text-red-400 inline-block">
Forgotten
</div>
)}
{/* Expires (inline with memory if exists) */}
{(node.data as any).forgetAfter && (
<p className="text-xs text-slate-400 mt-2 leading-relaxed">
Expires:{" "}
{new Date(
(node.data as any).forgetAfter,
).toLocaleDateString()}
{(node.data as any).forgetReason &&
` - ${(node.data as any).forgetReason}`}
</p>
)}
</div>
{/* Space */}
<div>
<div className="text-[11px] text-slate-400/80 uppercase tracking-wider mb-1">
Space
</div>
<p className="text-sm text-slate-300 m-0 leading-relaxed">
{(node.data as any).spaceId || "Default"}
</p>
</div>
{/* Footer with metadata */}
<div className="pt-3 border-t border-slate-600/50 flex items-center gap-4 text-xs text-slate-400">
<div className="flex items-center gap-1">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>
{new Date(
(node.data as any).createdAt,
).toLocaleDateString()}
</span>
</div>
<div className="flex items-center gap-1 overflow-hidden text-ellipsis whitespace-nowrap flex-1">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="4" y1="9" x2="20" y2="9" />
<line x1="4" y1="15" x2="20" y2="15" />
<line x1="10" y1="3" x2="8" y2="21" />
<line x1="16" y1="3" x2="14" y2="21" />
</svg>
<span className="overflow-hidden text-ellipsis">
{node.id}
</span>
</div>
</div>
</div>
</div>
)}
</div>
</>
)
})

View file

@ -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<HTMLCanvasElement | null>
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"
}

View file

@ -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",

View file

@ -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,23 +295,14 @@
},
"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",
@ -2059,22 +2051,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 +2811,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 +3279,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 +4155,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 +5145,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 +5173,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 +6015,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=="],

View file

@ -1,6 +1,6 @@
{
"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",
@ -17,16 +17,18 @@
"default": "./dist/memory-graph.cjs"
}
},
"./styles.css": "./dist/memory-graph.css",
"./mock-data": {
"import": {
"types": "./dist/mock-data.d.ts",
"default": "./dist/mock-data.js"
}
},
"./package.json": "./package.json"
},
"files": [
"dist",
"README.md"
],
"sideEffects": [
"**/*.css"
],
"scripts": {
"dev": "vite build --watch",
"build": "vite build && tsc --emitDeclarationOnly",
@ -59,21 +61,12 @@
"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"

View file

@ -1,71 +1,29 @@
// 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<string, string | number | boolean> | null
embedding?: number[] | null
embeddingModel?: string | null
tokenCount?: number | null
createdAt: string | Date
updatedAt: string | Date
// Fields from join relationship
sourceAddedAt?: Date | null
sourceRelevanceScore?: number | null
sourceMetadata?: Record<string, unknown> | null
spaceContainerTag?: string | null
// Version chain fields
updatesMemoryId?: string | null
nextVersionId?: string | null
relation?: "updates" | "extends" | "derives" | null
// Memory status fields
content: string
createdAt: string
updatedAt: string
spaceId?: string
embedding?: number[]
isStatic?: boolean
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
forgetAfter?: string | null
forgetReason?: string | null
version?: number
parentMemoryId?: string | null
rootMemoryId?: string | null
isLatest?: boolean
}
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<string, string | number | boolean> | null
processingMetadata?: Record<string, unknown> | null
raw?: string | null
tokenCount?: number | null
wordCount?: number | null
chunkCount?: number | null
averageChunkSize?: number | null
summaryEmbedding?: number[] | null
summaryEmbeddingModel?: string | null
createdAt: string | Date
updatedAt: string | Date
memoryEntries: MemoryEntry[]
memories: MemoryEntry[]
}
export interface DocumentsResponse {

View file

@ -1,208 +0,0 @@
export const OneDrive = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 256 165"
xmlns="http://www.w3.org/2000/svg"
>
<title>OneDrive</title>
<path
d="m154.66 110.682l52.842-50.534c-10.976-42.8-54.57-68.597-97.37-57.62a80 80 0 0 0-46.952 33.51c.817-.02 91.48 74.644 91.48 74.644"
fill="#0364B8"
/>
<path
d="m97.618 45.552l-.002.009a63.7 63.7 0 0 0-33.619-9.543c-.274 0-.544.017-.818.02C27.852 36.476-.432 65.47.005 100.798a63.97 63.97 0 0 0 11.493 35.798l79.165-9.915l60.694-48.94z"
fill="#0078D4"
/>
<path
d="M207.502 60.148a53 53 0 0 0-3.51-.131a51.8 51.8 0 0 0-20.61 4.254l-.002-.005l-32.022 13.475l35.302 43.607l63.11 15.341c13.62-25.283 4.164-56.82-21.12-70.44a52 52 0 0 0-21.148-6.1"
fill="#1490DF"
/>
<path
d="M11.498 136.596a63.91 63.91 0 0 0 52.5 27.417h139.994a51.99 51.99 0 0 0 45.778-27.323l-98.413-58.95z"
fill="#28A8EA"
/>
</svg>
)
export const GoogleDrive = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 256 229"
xmlns="http://www.w3.org/2000/svg"
>
<title>Google Drive</title>
<path
d="m19.354 196.034l11.29 19.5c2.346 4.106 5.718 7.332 9.677 9.678q17.009-21.591 23.68-33.137q6.77-11.717 16.641-36.655q-26.604-3.502-40.32-3.502q-13.165 0-40.322 3.502c0 4.545 1.173 9.09 3.519 13.196z"
fill="#0066DA"
/>
<path
d="M215.681 225.212c3.96-2.346 7.332-5.572 9.677-9.677l4.692-8.064l22.434-38.855a26.57 26.57 0 0 0 3.518-13.196q-27.315-3.502-40.247-3.502q-13.899 0-40.248 3.502q9.754 25.075 16.422 36.655q6.724 11.683 23.752 33.137"
fill="#EA4335"
/>
<path
d="M128.001 73.311q19.68-23.768 27.125-36.655q5.996-10.377 13.196-33.137C164.363 1.173 159.818 0 155.126 0h-54.25C96.184 0 91.64 1.32 87.68 3.519q9.16 26.103 15.544 37.154q7.056 12.213 24.777 32.638"
fill="#00832D"
/>
<path
d="M175.36 155.42H80.642l-40.32 69.792c3.958 2.346 8.503 3.519 13.195 3.519h148.968c4.692 0 9.238-1.32 13.196-3.52z"
fill="#2684FC"
/>
<path
d="M128.001 73.311L87.681 3.52c-3.96 2.346-7.332 5.571-9.678 9.677L3.519 142.224A26.57 26.57 0 0 0 0 155.42h80.642z"
fill="#00AC47"
/>
<path
d="m215.242 77.71l-37.243-64.514c-2.345-4.106-5.718-7.331-9.677-9.677l-40.32 69.792l47.358 82.109h80.496c0-4.546-1.173-9.09-3.519-13.196z"
fill="#FFBA00"
/>
</svg>
)
export const Notion = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 256 268"
xmlns="http://www.w3.org/2000/svg"
>
<title>Notion</title>
<path
d="M16.092 11.538L164.09.608c18.179-1.56 22.85-.508 34.28 7.801l47.243 33.282C253.406 47.414 256 48.975 256 55.207v182.527c0 11.439-4.155 18.205-18.696 19.24L65.44 267.378c-10.913.517-16.11-1.043-21.825-8.327L8.826 213.814C2.586 205.487 0 199.254 0 191.97V29.726c0-9.352 4.155-17.153 16.092-18.188"
fill="#FFF"
/>
<path d="M164.09.608L16.092 11.538C4.155 12.573 0 20.374 0 29.726v162.245c0 7.284 2.585 13.516 8.826 21.843l34.789 45.237c5.715 7.284 10.912 8.844 21.825 8.327l171.864-10.404c14.532-1.035 18.696-7.801 18.696-19.24V55.207c0-5.911-2.336-7.614-9.21-12.66l-1.185-.856L198.37 8.409C186.94.1 182.27-.952 164.09.608M69.327 52.22c-14.033.945-17.216 1.159-25.186-5.323L23.876 30.778c-2.06-2.086-1.026-4.69 4.163-5.207l142.274-10.395c11.947-1.043 18.17 3.12 22.842 6.758l24.401 17.68c1.043.525 3.638 3.637.517 3.637L71.146 52.095zm-16.36 183.954V81.222c0-6.767 2.077-9.887 8.3-10.413L230.02 60.93c5.724-.517 8.31 3.12 8.31 9.879v153.917c0 6.767-1.044 12.49-10.387 13.008l-161.487 9.361c-9.343.517-13.489-2.594-13.489-10.921M212.377 89.53c1.034 4.681 0 9.362-4.681 9.897l-7.783 1.542v114.404c-6.758 3.637-12.981 5.715-18.18 5.715c-8.308 0-10.386-2.604-16.609-10.396l-50.898-80.079v77.476l16.1 3.646s0 9.362-12.989 9.362l-35.814 2.077c-1.043-2.086 0-7.284 3.63-8.318l9.351-2.595V109.823l-12.98-1.052c-1.044-4.68 1.55-11.439 8.826-11.965l38.426-2.585l52.958 81.113v-71.76l-13.498-1.552c-1.043-5.733 3.111-9.896 8.3-10.404z" />
</svg>
)
export const GoogleDocs = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Google Docs</title>
<path
d="M14.727 6.727H14V0H4.91c-.905 0-1.637.732-1.637 1.636v20.728c0 .904.732 1.636 1.636 1.636h14.182c.904 0 1.636-.732 1.636-1.636V6.727zm-.545 10.455H7.09v-1.364h7.09v1.364zm2.727-3.273H7.091v-1.364h9.818zm0-3.273H7.091V9.273h9.818zM14.727 6h6l-6-6z"
fill="currentColor"
/>
</svg>
)
export const GoogleSheets = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Google Sheets</title>
<path
d="M11.318 12.545H7.91v-1.909h3.41v1.91zM14.728 0v6h6zm1.363 10.636h-3.41v1.91h3.41zm0 3.273h-3.41v1.91h3.41zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5zm-3.273 2.773H6.545v7.909h10.91v-7.91zm-6.136 4.636H7.91v1.91h3.41v-1.91z"
fill="currentColor"
/>
</svg>
)
export const GoogleSlides = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Google Slides</title>
<path
d="M16.09 15.273H7.91v-4.637h8.18zm1.728-8.523h2.91v15.614c0 .904-.733 1.636-1.637 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.068v6.75zm-.363 2.523H6.545v7.363h10.91zm-2.728-5.979V6h6.001l-6-6v3.294z"
fill="currentColor"
/>
</svg>
)
export const NotionDoc = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Notion Doc</title>
<path
d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.981-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.841-.046.935-.56.935-1.167V6.354c0-.606-.233-.933-.748-.887l-15.177.887c-.56.047-.747.327-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514c-.748 0-.935-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233l4.764 7.279v-6.44l-1.215-.139c-.093-.514.28-.887.747-.933zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.934.653.934 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.447-1.632"
fill="currentColor"
/>
</svg>
)
export const MicrosoftWord = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Microsoft Word</title>
<path
d="M23.004 1.5q.41 0 .703.293t.293.703v19.008q0 .41-.293.703t-.703.293H6.996q-.41 0-.703-.293T6 21.504V18H.996q-.41 0-.703-.293T0 17.004V6.996q0-.41.293-.703T.996 6H6V2.496q0-.41.293-.703t.703-.293zM6.035 11.203l1.442 4.735h1.64l1.57-7.876H9.036l-.937 4.653l-1.325-4.5H5.38l-1.406 4.523l-.938-4.675H1.312l1.57 7.874h1.641zM22.5 21v-3h-15v3zm0-4.5v-3.75H12v3.75zm0-5.25V7.5H12v3.75zm0-5.25V3h-15v3Z"
fill="currentColor"
/>
</svg>
)
export const MicrosoftExcel = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Microsoft Excel</title>
<path
d="M23 1.5q.41 0 .7.3q.3.29.3.7v19q0 .41-.3.7q-.29.3-.7.3H7q-.41 0-.7-.3q-.3-.29-.3-.7V18H1q-.41 0-.7-.3q-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h5V2.5q0-.41.3-.7q.29-.3.7-.3zM6 13.28l1.42 2.66h2.14l-2.38-3.87l2.34-3.8H7.46l-1.3 2.4l-.05.08l-.04.09l-.64-1.28l-.66-1.29H2.59l2.27 3.82l-2.48 3.85h2.16zM14.25 21v-3H7.5v3zm0-4.5v-3.75H12v3.75zm0-5.25V7.5H12v3.75zm0-5.25V3H7.5v3zm8.25 15v-3h-6.75v3zm0-4.5v-3.75h-6.75v3.75zm0-5.25V7.5h-6.75v3.75zm0-5.25V3h-6.75v3Z"
fill="currentColor"
/>
</svg>
)
export const MicrosoftPowerpoint = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Microsoft PowerPoint</title>
<path
d="M13.5 1.5q1.453 0 2.795.375t2.508 1.06t2.12 1.641q.956.955 1.641 2.121q.686 1.166 1.061 2.508T24 12t-.375 2.795t-1.06 2.508q-.686 1.166-1.641 2.12q-.955.956-2.121 1.641q-1.166.686-2.508 1.061T13.5 22.5q-1.29 0-2.52-.305q-1.23-.304-2.337-.884T6.58 19.893Q5.625 19.055 4.887 18H.997q-.411 0-.704-.293T0 17.004V6.996q0-.41.293-.703T.996 6h3.89q.739-1.055 1.694-1.893q.955-.837 2.063-1.418q1.107-.58 2.337-.884T13.5 1.5m.75 1.535v8.215h8.215q-.14-1.64-.826-3.076t-1.782-2.531q-1.095-1.096-2.537-1.782t-3.07-.826m-5.262 7.57q0-.68-.228-1.166q-.229-.486-.627-.79q-.399-.305-.938-.446q-.539-.14-1.172-.14H2.848v7.863h1.84v-2.742H5.93q.574 0 1.119-.17t.978-.493q.434-.322.698-.802t.263-1.114M13.5 21q1.172 0 2.262-.287t2.056-.82t1.776-1.278q.808-.744 1.418-1.664t.984-1.986q.375-1.067.469-2.227h-9.703V3.035q-1.735.14-3.27.908T6.797 6h4.207q.41 0 .703.293t.293.703v10.008q0 .41-.293.703t-.703.293H6.797q.644.715 1.412 1.271q.768.557 1.623.944t1.781.586T13.5 21M5.812 9.598q.575 0 .915.228q.34.229.34.838q0 .27-.124.44q-.123.17-.31.275q-.188.105-.422.146t-.445.041H4.687V9.598Z"
fill="currentColor"
/>
</svg>
)
export const MicrosoftOneNote = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Microsoft OneNote</title>
<path
d="M23 1.5q.41 0 .7.3q.3.29.3.7v19q0 .41-.3.7q-.29.3-.7.3H7q-.41 0-.7-.3q-.3-.29-.3-.7V18H1q-.41 0-.7-.3q-.3-.29-.3-.7V7q0-.41.3-.7Q.58 6 1 6h5V2.5q0-.41.3-.7q.29-.3.7-.3ZM4.56 11l2.83 4.93h1.79V8.07H7.44v5.03L4.71 8.07H2.82v7.86h1.74ZM22.5 21v-3h-3v3Zm0-4.5v-3h-3v3Zm0-4.5V9h-3v3Zm0-4.5V3h-15v3H11q.41 0 .7.3q.3.29.3.7v10q0 .41-.3.7q-.29.3-.7.3H7.5v3H18V7.5Z"
fill="currentColor"
/>
</svg>
)
export const PDF = ({ className }: { className?: string }) => (
<svg
className={className}
viewBox="0 0 15 16"
xmlns="http://www.w3.org/2000/svg"
>
<title>PDF</title>
<path
d="M3 13h.86v-.9h.39c.62 0 1.14-.45 1.14-1.06s-.5-1.05-1.14-1.05H3v3Zm.86-1.59v-.72h.3c.2 0 .37.13.37.35s-.16.36-.37.36h-.3ZM6.19 13h1.19c1 0 1.62-.59 1.62-1.52C9 10.61 8.38 10 7.38 10H6.19zm.86-.71V10.7h.29c.33 0 .78.16.78.78c0 .65-.45.81-.78.81zM10 13h.86v-1.07h1.06v-.69h-1.06v-.54h1.21v-.69h-2.06v3Z"
fill="currentColor"
/>
<path
d="M12.5 16h-10c-.83 0-1.5-.67-1.5-1.5v-13C1 .67 1.67 0 2.5 0h7.09c.4 0 .78.16 1.06.44l2.91 2.91c.28.28.44.66.44 1.06V14.5c0 .83-.67 1.5-1.5 1.5M2.5 1c-.28 0-.5.22-.5.5v13c0 .28.22.5.5.5h10c.28 0 .5-.22.5-.5V4.41a.47.47 0 0 0-.15-.35L9.94 1.15A.5.5 0 0 0 9.59 1z"
fill="currentColor"
/>
<path
d="M13.38 5h-2.91C9.66 5 9 4.34 9 3.53V.62c0-.28.22-.5.5-.5s.5.22.5.5v2.91c0 .26.21.47.47.47h2.91c.28 0 .5.22.5.5s-.22.5-.5.5"
fill="currentColor"
/>
</svg>
)

View file

@ -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,7 +58,6 @@ 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
}

View file

@ -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

View file

@ -1,5 +1,10 @@
import type {
DocumentNodeData,
GraphEdge,
GraphNode,
GraphThemeColors,
} from "../types"
import type { ViewportState } from "./viewport"
import type { GraphNode, GraphEdge, DocumentNodeData } from "../types"
export interface RenderState {
selectedNodeId: string | null
@ -8,6 +13,9 @@ export interface RenderState {
dimProgress: number
}
// Module-level reusable batch map – cleared each frame instead of reallocating
const edgeBatches = new Map<string, PreparedEdge[]>()
export function renderFrame(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
@ -17,21 +25,23 @@ export function renderFrame(
height: number,
state: RenderState,
nodeMap: Map<string, GraphNode>,
colors: GraphThemeColors,
): 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)
drawDocDocLines(ctx, nodes, viewport, width, height, colors)
drawEdges(ctx, edges, viewport, width, height, state, nodeMap, colors)
drawNodes(ctx, nodes, viewport, width, height, state, colors)
}
// Connect each visible doc to its 2 nearest neighbors
function drawDocDocLines(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
viewport: ViewportState,
width: number,
height: number,
colors: GraphThemeColors,
): void {
// Collect visible docs with screen positions
const docs: { x: number; y: number }[] = []
for (const n of nodes) {
if (n.type !== "document") continue
@ -42,43 +52,68 @@ function drawDocDocLines(
}
if (docs.length < 2) return
ctx.strokeStyle = "#8DA3F4"
// Build spatial grid for O(n*k) nearest-neighbor instead of O(n^2)
const CELL = 200
const grid = new Map<string, number[]>()
for (let i = 0; i < docs.length; i++) {
const d = docs[i]!
const key = `${Math.floor(d.x / CELL)},${Math.floor(d.y / CELL)}`
let cell = grid.get(key)
if (!cell) {
cell = []
grid.set(key, cell)
}
cell.push(i)
}
// For each doc, find 2 nearest from neighboring cells only
ctx.strokeStyle = colors.edgeDocDoc
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]!
const cx = Math.floor(d.x / CELL)
const cy = Math.floor(d.y / CELL)
let best1 = -1
let best2 = -1
let dist1 = Number.POSITIVE_INFINITY
let dist2 = Number.POSITIVE_INFINITY
let dist1 = Infinity
let dist2 = 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
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
const cell = grid.get(`${cx + dx},${cy + dy}`)
if (!cell) continue
for (const j of cell) {
if (j === i) continue
const other = docs[j]!
const ox = other.x - d.x
const oy = other.y - d.y
const dist = ox * ox + oy * oy
if (dist < dist1) {
best2 = best1
dist2 = dist1
best1 = j
dist1 = dist
} else if (dist < dist2) {
best2 = j
dist2 = dist
}
}
}
}
if (best1 >= 0 && i < best1) {
const b1 = docs[best1]!
ctx.moveTo(d.x, d.y)
ctx.lineTo(docs[best1]!.x, docs[best1]!.y)
ctx.lineTo(b1.x, b1.y)
}
if (best2 >= 0 && i < best2) {
const b2 = docs[best2]!
ctx.moveTo(d.x, d.y)
ctx.lineTo(docs[best2]!.x, docs[best2]!.y)
ctx.lineTo(b2.x, b2.y)
}
}
@ -87,26 +122,19 @@ function drawDocDocLines(
ctx.globalAlpha = 1
}
// --- Edges ---
const EDGE_STYLE: Record<string, { color: string; width: number }> = {
"doc-memory": { color: "#4A5568", width: 1.5 },
version: { color: "#8B5CF6", width: 2 },
function edgeStyle(
edge: GraphEdge,
colors: GraphThemeColors,
): { color: string; width: number } {
if (edge.edgeType === "doc-memory")
return { color: colors.edgeDocMemory, width: 1.5 }
if (edge.edgeType === "version")
return { color: colors.edgeVersion, width: 2 }
if (edge.similarity >= 0.9) return { color: colors.edgeSimStrong, width: 2 }
if (edge.similarity >= 0.8) return { color: colors.edgeSimMedium, width: 1.5 }
return { color: colors.edgeSimWeak, width: 1 }
}
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}`
}
@ -130,21 +158,26 @@ function drawEdges(
height: number,
state: RenderState,
nodeMap: Map<string, GraphNode>,
colors: GraphThemeColors,
): void {
const margin = 100
const hasDim = state.selectedNodeId !== null && state.dimProgress > 0
// Prepare all visible edges
const prepared: PreparedEdge[] = []
for (const edge of edges) {
// Zoom-based edge culling for similarity edges
if (edge.edgeType === "similarity") {
if (viewport.zoom < 0.3) continue
if (viewport.zoom < 0.5 && edge.similarity < 0.9) 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
// 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
@ -187,30 +220,30 @@ function drawEdges(
endX: t.x - ux * tr,
endY: t.y - uy * tr,
connected,
style: edgeStyle(edge),
style: edgeStyle(edge, colors),
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<string, PreparedEdge[]>()
// Reuse module-level batch map
edgeBatches.clear()
for (const e of prepared) {
const dimKey = hasDim ? (e.connected ? "|c" : "|d") : ""
const key = batchKey(e.style) + dimKey
let batch = batches.get(key)
let batch = edgeBatches.get(key)
if (!batch) {
batch = []
batches.set(key, batch)
edgeBatches.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]!
for (const [key, batch] of edgeBatches) {
const first = batch[0]
if (!first) continue
const isDimmed = key.endsWith("|d")
ctx.globalAlpha = isDimmed ? 1 - state.dimProgress * 0.8 : 1
@ -224,7 +257,6 @@ function drawEdges(
}
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
@ -260,8 +292,6 @@ function drawArrowHead(
ctx.fill()
}
// --- Nodes ---
function drawNodes(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
@ -269,6 +299,7 @@ function drawNodes(
width: number,
height: number,
state: RenderState,
colors: GraphThemeColors,
): void {
const margin = 60
const memDots: { x: number; y: number; r: number; color: string }[] = []
@ -278,7 +309,6 @@ function drawNodes(
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 ||
@ -292,7 +322,6 @@ function drawNodes(
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) })
@ -301,7 +330,7 @@ function drawNodes(
x: screen.x,
y: screen.y,
r: Math.max(2, screenSize * 0.45),
color: node.borderColor || "#3B73B8",
color: node.borderColor || colors.memStrokeDefault,
})
}
continue
@ -323,6 +352,7 @@ function drawNodes(
isSelected,
isHovered,
isHighlighted,
colors,
)
} else {
drawMemoryNode(
@ -334,11 +364,12 @@ function drawNodes(
isSelected,
isHovered,
isHighlighted,
colors,
)
}
if (isSelected || isHighlighted) {
drawGlow(ctx, screen.x, screen.y, screenSize, node.type)
drawGlow(ctx, screen.x, screen.y, screenSize, node.type, colors)
}
}
@ -347,10 +378,9 @@ function drawNodes(
? 1 - state.dimProgress * 0.7
: 1
// Batch: document dots as filled squares
if (docDots.length > 0) {
ctx.fillStyle = "#1B1F24"
ctx.strokeStyle = "#2A2F36"
ctx.fillStyle = colors.docFill
ctx.strokeStyle = colors.docStroke
ctx.lineWidth = 1
ctx.globalAlpha = dimAlpha
for (const d of docDots) {
@ -360,12 +390,10 @@ function drawNodes(
}
}
// 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.fillStyle = colors.memFill
ctx.beginPath()
for (const d of memDots) {
ctx.moveTo(d.x + d.r, d.y)
@ -373,7 +401,6 @@ function drawNodes(
}
ctx.fill()
// Pass 2: colored strokes grouped by border color
ctx.lineWidth = 1.5
const byColor = new Map<string, typeof memDots>()
for (const d of memDots) {
@ -407,32 +434,34 @@ function drawDocumentNode(
isSelected: boolean,
isHovered: boolean,
isHighlighted: boolean,
colors: GraphThemeColors,
): void {
const half = size * 0.5
const cornerR = 8 * (size / 50)
// Outer rect
ctx.fillStyle = "#1B1F24"
ctx.fillStyle = colors.docFill
ctx.strokeStyle =
isSelected || isHighlighted ? "#3B73B8" : isHovered ? "#3B73B8" : "#2A2F36"
isSelected || isHighlighted
? colors.accent
: isHovered
? colors.accent
: colors.docStroke
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"
ctx.fillStyle = colors.docInnerFill
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")
drawDocIcon(ctx, sx, sy, iconSize, docType || "text", colors)
}
function drawMemoryNode(
@ -444,17 +473,16 @@ function drawMemoryNode(
isSelected: boolean,
isHovered: boolean,
_isHighlighted: boolean,
colors: GraphThemeColors,
): void {
const radius = size * 0.5
// Fill
ctx.fillStyle = isHovered ? "#112840" : "#0D2034"
ctx.fillStyle = isHovered ? colors.memFillHover : colors.memFill
drawHexagon(ctx, sx, sy, radius)
ctx.fill()
// Stroke with time-based border color
const borderColor = node.borderColor || "#3B73B8"
ctx.strokeStyle = isSelected ? "#3B73B8" : borderColor
const borderColor = node.borderColor || colors.memStrokeDefault
ctx.strokeStyle = isSelected ? colors.accent : borderColor
ctx.lineWidth = isHovered ? 2 : 1.5
ctx.stroke()
}
@ -465,8 +493,9 @@ function drawGlow(
sy: number,
size: number,
nodeType: "document" | "memory",
colors: GraphThemeColors,
): void {
ctx.strokeStyle = "#3B73B8"
ctx.strokeStyle = colors.glowColor
ctx.lineWidth = 2
ctx.setLineDash([3, 3])
ctx.globalAlpha = 0.8
@ -485,8 +514,6 @@ function drawGlow(
ctx.globalAlpha = 1
}
// --- Shapes ---
function drawHexagon(
ctx: CanvasRenderingContext2D,
cx: number,
@ -524,20 +551,17 @@ function roundRect(
ctx.closePath()
}
// --- Document icons ---
const ICON_COLOR = "#3B73B8"
function drawDocIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
type: string,
colors: GraphThemeColors,
): void {
ctx.save()
ctx.fillStyle = ICON_COLOR
ctx.strokeStyle = ICON_COLOR
ctx.fillStyle = colors.iconColor
ctx.strokeStyle = colors.iconColor
ctx.lineWidth = Math.max(1, size / 12)
ctx.lineCap = "round"
ctx.lineJoin = "round"

View file

@ -1,5 +1,5 @@
import * as d3 from "d3-force"
import type { GraphNode, GraphEdge } from "../types"
import type { GraphEdge, GraphNode } from "../types"
export class ForceSimulation {
private sim: d3.Simulation<GraphNode, GraphEdge> | null = null
@ -10,7 +10,7 @@ export class ForceSimulation {
try {
this.sim = d3
.forceSimulation<GraphNode>(nodes)
.alphaDecay(0.03)
.alphaDecay(0.04)
.alphaMin(0.001)
.velocityDecay(0.6)
@ -40,10 +40,9 @@ export class ForceSimulation {
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()
for (let i = 0; i < 100; i++) this.sim.tick()
this.sim.alphaTarget(0).restart()
} catch (e) {
console.error("ForceSimulation.init failed:", e)

View file

@ -33,7 +33,6 @@ export class VersionChainIndex {
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<string>()
let current: GraphApiMemory | undefined = mem
@ -53,7 +52,6 @@ export class VersionChainIndex {
chain.reverse()
// Cache for every member in the chain
for (const entry of chain) {
this.cache.set(entry.id, chain)
}

View file

@ -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

View file

@ -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,
})

File diff suppressed because it is too large Load diff

View file

@ -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,
})

View file

@ -1,276 +1,427 @@
"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 (
<svg
aria-hidden="true"
height={size}
viewBox="0 0 12 12"
width={size}
style={{ flexShrink: 0 }}
>
<polygon
fill={fill}
points="6,1.5 10.4,3.75 10.4,8.25 6,10.5 1.6,8.25 1.6,3.75"
stroke={stroke}
strokeWidth="0.6"
/>
</svg>
)
}
function LineIcon({
color,
dashed = false,
}: {
color: string
dashed?: boolean
}) {
return (
<div
style={{
width: 12,
height: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<div
style={{
width: 12,
height: 0,
borderTop: `1.6px ${dashed ? "dashed" : "solid"} ${color}`,
}}
/>
</div>
)
}
function ChevronDownIcon({ color }: { color: string }) {
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ flexShrink: 0 }}
>
<path d="m6 9 6 6 6-6" />
</svg>
)
}
function ChevronRightIcon({ color }: { color: string }) {
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ flexShrink: 0 }}
>
<path d="m9 18 6-6-6-6" />
</svg>
)
}
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 (
<div style={{ display: "flex", flexDirection: "column" }}>
<button onClick={expandable ? onToggle : undefined} style={buttonStyle} type="button">
<div style={leftStyle}>
{icon}
<span style={labelStyle}>{label}</span>
{expandable &&
(expanded ? (
<ChevronDownIcon color={colors.textMuted} />
) : (
<ChevronRightIcon color={colors.textMuted} />
))}
</div>
<span style={countStyle}>{count}</span>
</button>
{expandable && expanded && children && (
<div style={childrenContainerStyle}>{children}</div>
)}
</div>
)
}
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 (
<div className={containerClass} id={id}>
<Collapsible onOpenChange={handleToggleExpanded} open={isExpanded}>
{/* Glass effect background */}
<GlassMenuEffect rounded="xl" />
<div style={outerStyle}>
<div style={cardStyle}>
<div style={{ padding: 12 }}>
<button
onClick={() => setIsExpanded(!isExpanded)}
style={headerBtnStyle}
type="button"
>
{isExpanded ? (
<ChevronDownIcon color={colors.textPrimary} />
) : (
<ChevronRightIcon color={colors.textPrimary} />
)}
<span style={headerTextStyle}>Legend</span>
</button>
<div className={styles.legendContent}>
{/* Mobile and Desktop collapsed state */}
{!isExpanded && (
<CollapsibleTrigger className={styles.collapsedTrigger}>
<div className={styles.collapsedContent}>
<div className={styles.collapsedText}>?</div>
<ChevronUp className={styles.collapsedIcon} />
</div>
</CollapsibleTrigger>
)}
{/* Expanded state */}
{isExpanded && (
<>
{/* Header with toggle */}
<div className={styles.legendHeader}>
<div className={styles.legendTitle}>Legend</div>
<CollapsibleTrigger className={styles.headerTrigger}>
<ChevronDown className={styles.headerIcon} />
</CollapsibleTrigger>
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 16 }}>
{/* Statistics section */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span style={sectionLabelStyle}>Statistics</span>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<StatRow
count={memoryCount}
icon={
<HexagonIcon
fill={colors.memFill}
stroke={colors.memStrokeDefault}
/>
}
label="Memories"
colors={colors}
/>
<StatRow
count={documentCount}
icon={
<div
style={{
width: 12,
height: 12,
flexShrink: 0,
borderRadius: 2,
backgroundColor: colors.controlBg,
border: `1px solid ${colors.controlBorder}`,
}}
/>
}
label="Documents"
colors={colors}
/>
<StatRow
count={connectionCount}
expandable
expanded={connectionsExpanded}
icon={
<svg
aria-hidden="true"
height="12"
viewBox="0 0 12 12"
width="12"
style={{ flexShrink: 0 }}
>
<circle cx="3" cy="3" fill={colors.textMuted} r="1.5" />
<circle cx="9" cy="3" fill={colors.textMuted} r="1.5" />
<circle cx="6" cy="9" fill={colors.textMuted} r="1.5" />
<line
stroke={colors.textMuted}
strokeWidth="0.8"
x1="3"
x2="9"
y1="3"
y2="3"
/>
<line
stroke={colors.textMuted}
strokeWidth="0.8"
x1="3"
x2="6"
y1="3"
y2="9"
/>
<line
stroke={colors.textMuted}
strokeWidth="0.8"
x1="9"
x2="6"
y1="3"
y2="9"
/>
</svg>
}
label="Connections"
onToggle={() => setConnectionsExpanded(!connectionsExpanded)}
colors={colors}
>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeDocMemory} />
<span style={edgeLabelStyle}>
Doc &gt; Memory
</span>
</div>
</div>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeVersion} />
<span style={edgeLabelStyle}>Version chain</span>
</div>
</div>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeSimStrong} />
<span style={edgeLabelStyle}>Similarity</span>
</div>
</div>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeDocDoc} dashed />
<span style={edgeLabelStyle}>Doc similarity</span>
</div>
</div>
</div>
</StatRow>
</div>
</div>
<CollapsibleContent>
<div className={styles.sectionsContainer}>
{/* Stats Section */}
{!isLoading && (
<div className={styles.sectionWrapper}>
<div className={styles.sectionTitle}>Statistics</div>
<div className={styles.itemsList}>
<div className={styles.legendItem}>
<Brain
className={styles.legendIcon}
style={{ color: "rgb(96, 165, 250)" }}
/>
<span className={styles.legendText}>
{memoryCount} memories
</span>
</div>
<div className={styles.legendItem}>
<FileText
className={styles.legendIcon}
style={{ color: "rgb(203, 213, 225)" }}
/>
<span className={styles.legendText}>
{documentCount} documents
</span>
</div>
<div className={styles.legendItem}>
<div className={styles.gradientCircle} />
<span className={styles.legendText}>
{edges.length} connections
</span>
</div>
</div>
</div>
)}
{/* Node Types */}
<div className={styles.sectionWrapper}>
<div className={styles.sectionTitle}>Nodes</div>
<div className={styles.itemsList}>
<div className={styles.legendItem}>
<div className={styles.documentNode} />
<span className={styles.legendText}>Document</span>
</div>
<div className={styles.legendItem}>
<div className={styles.memoryNode} />
<span className={styles.legendText}>
Memory (latest)
</span>
</div>
<div className={styles.legendItem}>
<div className={styles.memoryNodeOlder} />
<span className={styles.legendText}>
Memory (older)
</span>
</div>
</div>
{/* Memory Status section */}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span style={sectionLabelStyle}>Memory Status</span>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<div style={statusRowStyle}>
<HexagonIcon
fill={colors.memFill}
stroke={colors.memBorderRecent}
/>
<span style={edgeLabelStyle}>
Recent (&lt; 24h)
</span>
</div>
{/* Status Indicators */}
<div className={styles.sectionWrapper}>
<div className={styles.sectionTitle}>Status</div>
<div className={styles.itemsList}>
<div className={styles.legendItem}>
<div className={styles.forgottenNode}>
<div className={styles.forgottenIcon}>✕</div>
</div>
<span className={styles.legendText}>Forgotten</span>
</div>
<div className={styles.legendItem}>
<div className={styles.expiringNode} />
<span className={styles.legendText}>Expiring soon</span>
</div>
<div className={styles.legendItem}>
<div className={styles.newNode}>
<div className={styles.newBadge} />
</div>
<span className={styles.legendText}>New memory</span>
</div>
</div>
<div style={statusRowStyle}>
<HexagonIcon
fill={colors.memFill}
stroke={colors.memBorderExpiring}
/>
<span style={edgeLabelStyle}>Expiring soon</span>
</div>
{/* Connection Types */}
<div className={styles.sectionWrapper}>
<div className={styles.sectionTitle}>Connections</div>
<div className={styles.itemsList}>
<div className={styles.legendItem}>
<div className={styles.connectionLine} />
<span className={styles.legendText}>Doc → Memory</span>
</div>
<div className={styles.legendItem}>
<div className={styles.similarityLine} />
<span className={styles.legendText}>
Doc similarity
</span>
</div>
</div>
</div>
{/* Relation Types */}
<div className={styles.sectionWrapper}>
<div className={styles.sectionTitle}>Relations</div>
<div className={styles.itemsList}>
{[
["updates", colors.relations.updates],
["extends", colors.relations.extends],
["derives", colors.relations.derives],
].map(([label, color]) => (
<div className={styles.legendItem} key={label}>
<div
className={styles.relationLine}
style={{ borderColor: color }}
/>
<span
className={styles.legendText}
style={{
color: color,
textTransform: "capitalize",
}}
>
{label}
</span>
</div>
))}
</div>
</div>
{/* Similarity Strength */}
<div className={styles.sectionWrapper}>
<div className={styles.sectionTitle}>Similarity</div>
<div className={styles.itemsList}>
<div className={styles.legendItem}>
<div className={styles.weakSimilarity} />
<span className={styles.legendText}>Weak</span>
</div>
<div className={styles.legendItem}>
<div className={styles.strongSimilarity} />
<span className={styles.legendText}>Strong</span>
</div>
</div>
<div style={statusRowStyle}>
<HexagonIcon
fill={colors.memFill}
stroke={colors.memBorderForgotten}
/>
<span style={edgeLabelStyle}>Forgotten</span>
</div>
</div>
</CollapsibleContent>
</>
</div>
</div>
)}
</div>
</Collapsible>
</div>
</div>
)
})

View file

@ -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,
})

View file

@ -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<LoadingIndicatorProps>(
({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => {
if (!isLoading && !isLoadingMore) return null
const spinKeyframes = `
@keyframes mg-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`
return (
<div className={loadingContainer}>
{/* Glass effect background */}
<GlassMenuEffect rounded="xl" />
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
}
<div className={loadingContent}>
<div className={loadingFlex}>
{/*@ts-ignore */}
<Sparkles className={loadingIcon} />
<span className={loadingText}>
{isLoading
? "Loading memory graph..."
: `Loading more documents... (${totalLoaded})`}
</span>
</div>
</div>
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 (
<div style={containerStyle}>
<div style={flexStyle}>
<svg
aria-hidden="true"
fill="none"
role="img"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
style={spinnerStyle}
>
<title>Loading</title>
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
</svg>
<span style={textStyle}>
{isLoading
? "Loading memory graph..."
: `Loading more documents... (${totalLoaded})`}
</span>
</div>
)
},
)
</div>
)
})
LoadingIndicator.displayName = "LoadingIndicator"

View file

@ -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
})

File diff suppressed because it is too large Load diff

View file

@ -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,
},
])

View file

@ -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 <span style={style}>{keys}</span>
}
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 (
<button
onClick={onClick}
style={style}
type="button"
onMouseEnter={(e) => {
e.currentTarget.style.opacity = "0.85"
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = "1"
}}
>
{children}
</button>
)
}
export const NavigationControls = memo<NavigationControlsProps>(
({ 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 (
<div className={containerClassName}>
<button
type="button"
onClick={onAutoFit}
className={navButton}
title="Auto-fit graph to viewport"
>
Fit
</button>
<button
type="button"
onClick={onCenter}
className={navButton}
title="Center view on graph"
>
Center
</button>
<div className={zoomContainer}>
<button
type="button"
onClick={onZoomIn}
className={zoomInButton}
title="Zoom in"
>
+
</button>
<button
type="button"
onClick={onZoomOut}
className={zoomOutButton}
title="Zoom out"
>
−
</button>
<div className={className} style={containerStyle}>
<NavBtn onClick={onAutoFit} colors={colors}>
<span style={labelStyle}>Fit</span>
<KeyBadge keys="Z" colors={colors} />
</NavBtn>
<NavBtn onClick={onCenter} colors={colors}>
<span style={labelStyle}>Center</span>
<KeyBadge keys="C" colors={colors} />
</NavBtn>
<div style={zoomRowStyle}>
<span style={labelStyle}>{zoomLevel}%</span>
<div style={zoomBtnGroupStyle}>
<button
onClick={onZoomOut}
style={zoomBtnStyle}
type="button"
onMouseEnter={(e) => {
e.currentTarget.style.opacity = "0.8"
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = "1"
}}
>
<span style={{ fontSize: 12 }}>−</span>
</button>
<button
onClick={onZoomIn}
style={zoomBtnStyle}
type="button"
onMouseEnter={(e) => {
e.currentTarget.style.opacity = "0.8"
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = "1"
}}
>
<span style={{ fontSize: 12 }}>+</span>
</button>
</div>
</div>
</div>
)

View file

@ -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",
})

View file

@ -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 <GoogleDocs {...iconProps} />
case "google_sheet":
return <GoogleSheets {...iconProps} />
case "google_slide":
return <GoogleSlides {...iconProps} />
case "google_drive":
return <GoogleDrive {...iconProps} />
case "notion":
case "notion_doc":
return <NotionDoc {...iconProps} />
case "word":
case "microsoft_word":
return <MicrosoftWord {...iconProps} />
case "excel":
case "microsoft_excel":
return <MicrosoftExcel {...iconProps} />
case "powerpoint":
case "microsoft_powerpoint":
return <MicrosoftPowerpoint {...iconProps} />
case "onenote":
case "microsoft_onenote":
return <MicrosoftOneNote {...iconProps} />
case "onedrive":
return <OneDrive {...iconProps} />
case "pdf":
return <PDF {...iconProps} />
default:
{
}
return <FileText {...iconProps} />
}
}
export const NodeDetailPanel = memo(function NodeDetailPanel({
node,
onClose,
variant = "console",
}: NodeDetailPanelProps) {
if (!node) return null
const isDocument = node.type === "document"
const data = node.data
return (
<motion.div
animate={{ opacity: 1 }}
className={styles.container}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
transition={{
duration: 0.2,
ease: "easeInOut",
}}
>
{/* Glass effect background */}
<GlassMenuEffect rounded="xl" />
<motion.div
animate={{ opacity: 1 }}
className={styles.content}
initial={{ opacity: 0 }}
transition={{ delay: 0.05, duration: 0.15 }}
>
<div className={styles.header}>
<div className={styles.headerLeft}>
{isDocument ? (
getDocumentIcon((data as DocumentWithMemories).type ?? "")
) : (
<Brain className={styles.headerIconMemory} />
)}
<HeadingH3Bold>{isDocument ? "Document" : "Memory"}</HeadingH3Bold>
</div>
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<Button
className={styles.closeButton}
onClick={onClose}
size="sm"
variant="ghost"
>
{/* @ts-ignore */}
<X className={styles.closeIcon} />
</Button>
</motion.div>
</div>
<div className={styles.sections}>
{isDocument ? (
<>
<div className={styles.section}>
<span className={styles.sectionLabel}>Title</span>
<p className={styles.sectionValue}>
{(data as DocumentWithMemories).title || "Untitled Document"}
</p>
</div>
{(data as DocumentWithMemories).summary && (
<div className={styles.section}>
<span className={styles.sectionLabel}>Summary</span>
<p className={styles.sectionValueTruncated}>
{(data as DocumentWithMemories).summary}
</p>
</div>
)}
<div className={styles.section}>
<span className={styles.sectionLabel}>Type</span>
<p className={styles.sectionValue}>
{formatDocumentType(
(data as DocumentWithMemories).type ?? "",
)}
</p>
</div>
<div className={styles.section}>
<span className={styles.sectionLabel}>Memory Count</span>
<p className={styles.sectionValue}>
{(data as DocumentWithMemories).memoryEntries.length} memories
</p>
</div>
{((data as DocumentWithMemories).url ||
(data as DocumentWithMemories).customId) && (
<div className={styles.section}>
<span className={styles.sectionLabel}>URL</span>
<a
className={styles.link}
href={(() => {
const doc = data as DocumentWithMemories
if (doc.type === "google_doc" && doc.customId) {
return `https://docs.google.com/document/d/${doc.customId}`
}
if (doc.type === "google_sheet" && doc.customId) {
return `https://docs.google.com/spreadsheets/d/${doc.customId}`
}
if (doc.type === "google_slide" && doc.customId) {
return `https://docs.google.com/presentation/d/${doc.customId}`
}
return doc.url ?? undefined
})()}
rel="noopener noreferrer"
target="_blank"
>
{/* @ts-ignore */}
<ExternalLink className={styles.linkIcon} />
View Document
</a>
</div>
)}
</>
) : (
<>
<div className={styles.section}>
<span className={styles.sectionLabel}>Memory</span>
<p className={styles.sectionValue}>
{(data as MemoryEntry).memory}
</p>
{(data as MemoryEntry).isForgotten && (
<Badge className={styles.badge} variant="destructive">
Forgotten
</Badge>
)}
{(data as MemoryEntry).forgetAfter && (
<p className={styles.expiryText}>
Expires:{" "}
{(data as MemoryEntry).forgetAfter
? new Date(
(data as MemoryEntry).forgetAfter!,
).toLocaleDateString()
: ""}{" "}
{"forgetReason" in data && (data as any).forgetReason
? `- ${(data as any).forgetReason}`
: null}
</p>
)}
</div>
<div className={styles.section}>
<span className={styles.sectionLabel}>Space</span>
<p className={styles.sectionValue}>
{(data as MemoryEntry).spaceId || "Default"}
</p>
</div>
</>
)}
<div className={styles.footer}>
<div className={styles.metadata}>
<span className={styles.metadataItem}>
{/* @ts-ignore */}
<Calendar className={styles.metadataIcon} />
{new Date(data.createdAt).toLocaleDateString()}
</span>
<span className={styles.metadataItem}>
{/* @ts-ignore */}
<Hash className={styles.metadataIcon} />
{node.id}
</span>
</div>
</div>
</div>
</motion.div>
</motion.div>
)
})
NodeDetailPanel.displayName = "NodeDetailPanel"

View file

@ -0,0 +1,637 @@
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<ReturnType<typeof setTimeout>>(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 <span style={style}>{children}</span>
}
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 (
<button
onClick={onClick}
style={buttonStyle}
type="button"
onMouseEnter={(e) => {
e.currentTarget.style.opacity = "0.8"
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = "1"
}}
>
<KeyBadge colors={colors}>{icon}</KeyBadge>
<span style={labelStyle}>{label}</span>
</button>
)
}
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 (
<button onClick={() => copy(value)} style={buttonStyle} type="button">
<span style={labelStyle}>{label}</span>
<span style={valueStyle}>{copied ? "Copied!" : short}</span>
</button>
)
}
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 (
<div style={containerStyle}>
{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 (
<button
key={entry.id}
onClick={() => onSelect?.(entry.id)}
style={entryStyle}
type="button"
>
<span style={versionStyle}>v{entry.version}</span>
<span style={textStyle}>{truncate(entry.memory, 60)}</span>
</button>
)
})}
</div>
)
}
export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
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 (
<div style={overlayStyle}>
<svg aria-hidden="true" style={svgStyle}>
<path
d={connectorPath}
fill="none"
stroke={colors.accent}
strokeDasharray="4 2"
strokeWidth="1.5"
/>
</svg>
<div style={popoverContainerStyle}>
<div style={cardStyle}>
{hasChain ? (
<VersionTimeline
chain={versionChain}
colors={colors}
currentId={node.id}
onSelect={onSelectNode}
/>
) : (
<div style={contentPadStyle}>
<p style={contentTextStyle}>
{truncate(content, 100) || "No content"}
</p>
</div>
)}
{memoryMeta && hasForgetInfo && (
<div
style={{
paddingLeft: 12,
paddingRight: 12,
paddingTop: 6,
paddingBottom: 6,
display: "flex",
flexDirection: "column",
gap: 2,
...dividerStyle,
}}
>
{memoryMeta.forgetAfter && (
<span
style={{
fontSize: 10,
color: colors.memBorderExpiring,
}}
>
Expires:{" "}
{new Date(memoryMeta.forgetAfter).toLocaleDateString()}
</span>
)}
{memoryMeta.forgetReason && (
<span
style={{
fontSize: 10,
color: colors.popoverTextMuted,
}}
>
Reason: {memoryMeta.forgetReason}
</span>
)}
{memoryMeta.isForgotten && !memoryMeta.forgetReason && (
<span
style={{
fontSize: 10,
color: colors.memBorderForgotten,
}}
>
Forgotten
</span>
)}
</div>
)}
<div style={footerStyle}>
{memoryMeta ? (
<span
style={{
fontSize: 12,
fontWeight: 500,
color: memoryMeta.isForgotten
? colors.memBorderForgotten
: memoryMeta.isLatest
? colors.memBorderRecent
: colors.popoverTextSecondary,
}}
>
v{memoryMeta.version}{" "}
{memoryMeta.isForgotten
? "Forgotten"
: memoryMeta.isLatest
? "Latest"
: "Superseded"}
</span>
) : (
<>
<span
style={{
fontSize: 12,
color: colors.popoverTextSecondary,
}}
>
{docData?.type || "document"}
</span>
<span
style={{
fontSize: 12,
color: colors.popoverTextSecondary,
}}
>
{docData?.memories?.length ?? 0} memories
</span>
</>
)}
</div>
<div style={idRowStyle}>
{isMemory ? (
<CopyableId colors={colors} label="Memory" value={node.id} />
) : (
<CopyableId colors={colors} label="Document" value={node.id} />
)}
</div>
</div>
<div style={shortcutsPanelStyle}>
{isMemory && (
<NavButton
colors={colors}
icon="↑"
label={hasChain ? "Older version" : "Go to document"}
onClick={onNavigateUp}
/>
)}
{(isMemory ? hasChain : true) && (
<NavButton
colors={colors}
icon="↓"
label={isMemory ? "Newer version" : "Go to memory"}
onClick={onNavigateDown}
/>
)}
<NavButton
colors={colors}
icon="→"
label={isMemory ? "Next memory" : "Next document"}
onClick={onNavigateNext}
/>
<NavButton
colors={colors}
icon="←"
label={isMemory ? "Prev memory" : "Prev document"}
onClick={onNavigatePrev}
/>
</div>
</div>
</div>
)
},
)

View file

@ -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",
})

View file

@ -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<NodePopoverProps>(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 */}
<div
onClick={handleBackdropClick}
className={backdropClassName}
style={backdropStyle}
/>
{/* Popover content */}
<div
onClick={(e) => e.stopPropagation()} // Prevent closing when clicking inside
className={styles.popoverContainer}
style={{
left: `${x}px`,
top: `${y}px`,
}}
>
{node.type === "document" ? (
// Document popover
<div className={styles.contentContainer}>
{/* Header */}
<div className={styles.header}>
<div className={styles.headerTitle}>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={styles.headerIcon}
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<h3 className={styles.title}>Document</h3>
</div>
<button
type="button"
onClick={onClose}
className={styles.closeButton}
>
×
</button>
</div>
{/* Sections */}
<div className={styles.sectionsContainer}>
{/* Title */}
<div>
<div className={styles.fieldLabel}>Title</div>
<p className={styles.fieldValue}>
{(node.data as any).title || "Untitled Document"}
</p>
</div>
{/* Summary - truncated to 2 lines */}
{(node.data as any).summary && (
<div>
<div className={styles.fieldLabel}>Summary</div>
<p className={styles.summaryValue}>
{(node.data as any).summary}
</p>
</div>
)}
{/* Type */}
<div>
<div className={styles.fieldLabel}>Type</div>
<p className={styles.fieldValue}>
{(node.data as any).type || "Document"}
</p>
</div>
{/* Memory Count */}
<div>
<div className={styles.fieldLabel}>Memory Count</div>
<p className={styles.fieldValue}>
{(node.data as any).memoryEntries?.length || 0} memories
</p>
</div>
{/* URL */}
{((node.data as any).url || (node.data as any).customId) && (
<div>
<div className={styles.fieldLabel}>URL</div>
<a
href={(() => {
const doc = node.data as any
if (doc.type === "google_doc" && doc.customId) {
return `https://docs.google.com/document/d/${doc.customId}`
}
if (doc.type === "google_sheet" && doc.customId) {
return `https://docs.google.com/spreadsheets/d/${doc.customId}`
}
if (doc.type === "google_slide" && doc.customId) {
return `https://docs.google.com/presentation/d/${doc.customId}`
}
return doc.url ?? undefined
})()}
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
View Document
</a>
</div>
)}
{/* Footer with metadata */}
<div className={styles.footer}>
<div className={styles.footerItem}>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>
{new Date(
(node.data as any).createdAt,
).toLocaleDateString()}
</span>
</div>
<div className={styles.footerItemId}>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="4" y1="9" x2="20" y2="9" />
<line x1="4" y1="15" x2="20" y2="15" />
<line x1="10" y1="3" x2="8" y2="21" />
<line x1="16" y1="3" x2="14" y2="21" />
</svg>
<span className={styles.idText}>{node.id}</span>
</div>
</div>
</div>
</div>
) : (
// Memory popover
<div className={styles.contentContainer}>
{/* Header */}
<div className={styles.header}>
<div className={styles.headerTitle}>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={styles.headerIconMemory}
>
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z" />
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z" />
</svg>
<h3 className={styles.title}>Memory</h3>
</div>
<button
type="button"
onClick={onClose}
className={styles.closeButton}
>
×
</button>
</div>
{/* Sections */}
<div className={styles.sectionsContainer}>
{/* Memory content */}
<div>
<div className={styles.fieldLabel}>Memory</div>
<p className={styles.fieldValue}>
{(node.data as any).memory ||
(node.data as any).content ||
"No content"}
</p>
{(node.data as any).isForgotten && (
<div className={styles.forgottenBadge}>Forgotten</div>
)}
{/* Expires (inline with memory if exists) */}
{(node.data as any).forgetAfter && (
<p className={styles.expiresText}>
Expires:{" "}
{new Date(
(node.data as any).forgetAfter,
).toLocaleDateString()}
{(node.data as any).forgetReason &&
` - ${(node.data as any).forgetReason}`}
</p>
)}
</div>
{/* Space */}
<div>
<div className={styles.fieldLabel}>Space</div>
<p className={styles.fieldValue}>
{(node.data as any).spaceId || "Default"}
</p>
</div>
{/* Footer with metadata */}
<div className={styles.footer}>
<div className={styles.footerItem}>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>
{new Date(
(node.data as any).createdAt,
).toLocaleDateString()}
</span>
</div>
<div className={styles.footerItemId}>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="4" y1="9" x2="20" y2="9" />
<line x1="4" y1="15" x2="20" y2="15" />
<line x1="10" y1="3" x2="8" y2="21" />
<line x1="16" y1="3" x2="14" y2="21" />
</svg>
<span className={styles.idText}>{node.id}</span>
</div>
</div>
</div>
</div>
)}
</div>
</>
)
})

View file

@ -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",
})

View file

@ -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<SpacesDropdownProps>(
({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => {
const [isOpen, setIsOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const dropdownRef = useRef<HTMLDivElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map())
// Close dropdown when clicking outside
useEffect(() => {
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 (
<div
className={styles.container}
ref={dropdownRef}
onKeyDown={handleKeyDown}
>
<button
className={styles.trigger}
onClick={() => setIsOpen(!isOpen)}
type="button"
>
{/*@ts-ignore */}
<Eye className={styles.triggerIcon} />
<div className={styles.triggerContent}>
<span className={styles.triggerLabel}>
{selectedSpace === "all"
? "Latest"
: selectedSpace || "Select space"}
</span>
<div className={styles.triggerSubtext}>
{selectedSpace === "all"
? ""
: `${spaceMemoryCounts[selectedSpace] || 0} memories`}
</div>
</div>
{/*@ts-ignore */}
<ChevronDown
className={`${styles.triggerChevron} ${isOpen ? styles.triggerChevronOpen : ""}`}
/>
</button>
{isOpen && (
<div className={styles.dropdown}>
<div className={styles.dropdownInner}>
{/* Search Input - Always show for filtering */}
<div className={styles.searchContainer}>
<div className={styles.searchForm}>
{/*@ts-ignore */}
<Search className={styles.searchIcon} />
<input
className={styles.searchInput}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search spaces..."
ref={searchInputRef}
type="text"
value={searchQuery}
/>
{searchQuery && (
<button
className={styles.searchClearButton}
onClick={() => setSearchQuery("")}
type="button"
aria-label="Clear search"
>
{/*@ts-ignore */}
<X className={styles.searchIcon} />
</button>
)}
</div>
</div>
{/* Spaces List */}
<div className={styles.dropdownList}>
{/* Always show "Latest" option */}
<button
ref={(el) => {
if (el) itemRefs.current.set(0, el)
}}
className={
selectedSpace === "all"
? styles.dropdownItemActive
: highlightedIndex === 0
? styles.dropdownItemHighlighted
: styles.dropdownItem
}
onClick={() => {
onSpaceChange("all")
setIsOpen(false)
}}
onMouseEnter={() => setHighlightedIndex(0)}
type="button"
>
<span className={styles.dropdownItemLabel}>Latest</span>
<Badge className={styles.dropdownItemBadge}>
{totalMemories}
</Badge>
</button>
{/* Show all spaces, filtered by search query */}
{filteredSpaces.length > 0
? filteredSpaces.map((space, index) => {
const itemIndex = index + 1
return (
<button
ref={(el) => {
if (el) itemRefs.current.set(itemIndex, el)
}}
className={
selectedSpace === space
? styles.dropdownItemActive
: highlightedIndex === itemIndex
? styles.dropdownItemHighlighted
: styles.dropdownItem
}
key={space}
onClick={() => {
onSpaceChange(space)
setIsOpen(false)
}}
onMouseEnter={() => setHighlightedIndex(itemIndex)}
type="button"
>
<span className={styles.dropdownItemLabelTruncate}>
{space}
</span>
<Badge className={styles.dropdownItemBadge}>
{spaceMemoryCounts[space] || 0}
</Badge>
</button>
)
})
: searchQuery && (
<div className={styles.emptyState}>
No spaces found matching "{searchQuery}"
</div>
)}
</div>
</div>
</div>
)}
</div>
)
},
)
SpacesDropdown.displayName = "SpacesDropdown"

View file

@ -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.8,
version: 1.0,
docDocBase: 0.3,
},
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: 150,
chargeStrength: -1000,
collisionRadius: { document: 80, memory: 40 },
alphaDecay: 0.04,
alphaMin: 0.001,
velocityDecay: 0.6,
alphaTarget: 0.3,
preSettleTicks: 100,
}
// 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",
edgeDocMemory: "#4A5568",
edgeVersion: "#8B5CF6",
edgeSimStrong: "#00D4B8",
edgeSimMedium: "#6B8FBF",
edgeSimWeak: "#4A6A8A",
edgeDocDoc: "#8DA3F4",
memBorderForgotten: "#EF4444",
memBorderExpiring: "#F59E0B",
memBorderRecent: "#10B981",
glowColor: "#3B73B8",
iconColor: "#3B73B8",
popoverBg: "#1a1f29",
popoverBorder: "#2A2F36",
popoverTextPrimary: "#ffffff",
popoverTextSecondary: "#e2e8f0",
popoverTextMuted: "#94a3b8",
controlBg: "#1a1f29",
controlBorder: "#2A2F36",
}

View file

@ -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<GraphNode, GraphEdge> | 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<d3.Simulation<GraphNode, GraphEdge> | null>(null)
// Initialize simulation ONCE
useEffect(() => {
if (!enabled || nodes.length === 0) {
return
}
// Only create simulation once
if (!simulationRef.current) {
const simulation = d3
.forceSimulation<GraphNode>(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<GraphNode, GraphEdge>(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<GraphNode>().strength(FORCE_CONFIG.chargeStrength),
)
// 3. Collision force - prevent node overlap
simulation.force(
"collide",
d3
.forceCollide<GraphNode>()
.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<d3.ForceLink<GraphNode, GraphEdge>>("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,
}
}

View file

@ -1,471 +1,292 @@
"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,
GraphApiEdge,
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_CLUSTER_SPREAD = 150
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
}
function getEdgeVisualProps(similarity: number) {
return {
opacity: 0.3 + similarity * 0.5,
thickness: 1 + similarity * 1.5,
}
}
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)
}
const rangeX = maxX - minX || 1
const rangeY = maxY - minY || 1
const PAD = 100
return documents.map((doc) => ({
...doc,
x: PAD + ((doc.x - minX) / rangeX) * (1000 - 2 * PAD),
y: PAD + ((doc.y - minY) / rangeY) * (1000 - 2 * PAD),
}))
}
export function useGraphData(
data: DocumentsResponse | null,
selectedSpace: string,
nodePositions: Map<
string,
{
x: number
y: number
parentDocId?: string
offsetX?: number
offsetY?: number
}
>,
documents: GraphApiDocument[],
apiEdges: GraphApiEdge[],
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<Map<string, GraphNode>>(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<string>()
data.documents.forEach((doc) => {
currentNodeIds.add(doc.id)
doc.memoryEntries.forEach((mem) => {
currentNodeIds.add(`${mem.id}`)
})
})
const currentIds = new Set<string>()
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 { scale, offsetX, offsetY } = useMemo(() => {
if (canvasWidth === 0 || canvasHeight === 0) {
return { scale: 1, offsetX: 0, offsetY: 0 }
}
const paddingFactor = 0.8
const s = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000
const ox = (canvasWidth - 1000 * s) / 2
const oy = (canvasHeight - 1000 * s) / 2
return { scale: s, offsetX: ox, offsetY: oy }
}, [canvasWidth, canvasHeight])
// 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 normalizedDocs = useMemo(
() => normalizeDocCoordinates(documents),
[documents],
)
// 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,
)
const nodes = useMemo(() => {
if (!normalizedDocs || normalizedDocs.length === 0) return []
// 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
})
const result: GraphNode[] = []
return {
...doc,
memoryEntries: memories,
for (const doc of normalizedDocs) {
const initialX = doc.x * scale + offsetX
const initialY = doc.y * scale + offsetY
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.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<string, typeof filteredDocuments>()
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,
const angle = (i / memCount) * 2 * Math.PI
memNode = {
id: mem.id,
type: "memory",
x: docNode.x + Math.cos(angle) * MEMORY_CLUSTER_SPREAD,
y: docNode.y + Math.sin(angle) * MEMORY_CLUSTER_SPREAD,
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<string, GraphNode>()
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)
}
result.push(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)
}
return result
}, [normalizedDocs, scale, offsetX, offsetY, draggingNodeId, colors])
// Create doc-memory edge with similarity
allEdges.push({
id: `edge-${docNode.id}-${memory.id}`,
source: docNode.id,
target: memoryId,
const edges = useMemo(() => {
if (!normalizedDocs || normalizedDocs.length === 0) return []
const result: GraphEdge[] = []
const allNodeIds = new Set(nodes.map((n) => n.id))
for (const doc of normalizedDocs) {
for (const mem of doc.memories) {
result.push({
id: `dm-${doc.id}-${mem.id}`,
source: doc.id,
target: mem.id,
similarity: 1,
visualProps: getConnectionVisualProps(1),
color: colors.connection.memory,
visualProps: { opacity: 0.3, thickness: 1.5 },
edgeType: "doc-memory",
})
})
})
// Build mapping of memoryId -> nodeId for version chains
const memNodeIdMap = new Map<string, string>()
allNodes.forEach((n) => {
if (n.type === "memory") {
memNodeIdMap.set((n.data as MemoryEntry).id, n.id)
}
})
}
// Add version-chain edges (old -> new)
data.documents.forEach((doc) => {
doc.memoryEntries.forEach((mem: MemoryEntry) => {
// Support both new object structure and legacy array/single parent fields
let parentRelations: Record<string, MemoryRelation> =
(mem.memoryRelations ?? {}) as Record<string, MemoryRelation>
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<string, MemoryRelation>,
)
} else if (mem.parentMemoryId) {
parentRelations = {
[mem.parentMemoryId]: "updates" as MemoryRelation,
}
for (const doc of normalizedDocs) {
for (const mem of doc.memories) {
if (mem.parentMemoryId && allNodeIds.has(mem.parentMemoryId)) {
result.push({
id: `ver-${mem.parentMemoryId}-${mem.id}`,
source: mem.parentMemoryId,
target: mem.id,
similarity: 1,
visualProps: { opacity: 0.6, thickness: 2 },
edgeType: "version",
})
}
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,
})
}
})
}
}
for (const apiEdge of apiEdges) {
if (!allNodeIds.has(apiEdge.source) || !allNodeIds.has(apiEdge.target)) {
continue
}
result.push({
id: `sim-${apiEdge.source}-${apiEdge.target}`,
source: apiEdge.source,
target: apiEdge.target,
similarity: apiEdge.similarity,
visualProps: getEdgeVisualProps(apiEdge.similarity),
edgeType: "similarity",
})
})
}
// Append similarity edges (calculated in separate memo)
allEdges.push(...similarityEdges)
return result
}, [normalizedDocs, apiEdges, nodes])
return { nodes: allNodes, edges: allEdges }
}, [data, filteredDocuments, nodePositions, draggingNodeId, similarityEdges])
return { nodes, edges, scale, offsetX, offsetY }
}
export function screenToBackendCoords(
screenX: number,
screenY: number,
panX: number,
panY: number,
zoom: number,
canvasWidth: number,
canvasHeight: number,
): { x: number; y: number } {
const canvasX = (screenX - panX) / zoom
const canvasY = (screenY - panY) / zoom
const paddingFactor = 0.8
const s = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000
const ox = (canvasWidth - 1000 * s) / 2
const oy = (canvasHeight - 1000 * s) / 2
return {
x: (canvasX - ox) / s,
y: (canvasY - oy) / s,
}
}
export function calculateBackendViewport(
panX: number,
panY: number,
zoom: number,
canvasWidth: number,
canvasHeight: number,
): { minX: number; maxX: number; minY: number; maxY: number } {
const topLeft = screenToBackendCoords(
0,
0,
panX,
panY,
zoom,
canvasWidth,
canvasHeight,
)
const bottomRight = screenToBackendCoords(
canvasWidth,
canvasHeight,
panX,
panY,
zoom,
canvasWidth,
canvasHeight,
)
return {
minX: Math.max(0, Math.min(topLeft.x, bottomRight.x)),
maxX: Math.max(topLeft.x, bottomRight.x),
minY: Math.max(0, Math.min(topLeft.y, bottomRight.y)),
maxY: Math.max(topLeft.y, bottomRight.y),
}
}

View file

@ -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<string | null>(null)
const [selectedNode, setSelectedNode] = useState<string | null>(null)
const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null)
const [dragStart, setDragStart] = useState({
x: 0,
y: 0,
nodeX: 0,
nodeY: 0,
})
const [nodePositions, setNodePositions] = useState<
Map<
string,
{
x: number
y: number
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<number | null>(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,
}
}

View file

@ -0,0 +1,78 @@
import { useEffect, 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),
edgeDocMemory: readCssVar("--graph-edge-doc-mem", DEFAULT_COLORS.edgeDocMemory),
edgeVersion: readCssVar("--graph-edge-version", DEFAULT_COLORS.edgeVersion),
edgeSimStrong: readCssVar("--graph-edge-sim-strong", DEFAULT_COLORS.edgeSimStrong),
edgeSimMedium: readCssVar("--graph-edge-sim-medium", DEFAULT_COLORS.edgeSimMedium),
edgeSimWeak: readCssVar("--graph-edge-sim-weak", DEFAULT_COLORS.edgeSimWeak),
edgeDocDoc: readCssVar("--graph-edge-doc-doc", DEFAULT_COLORS.edgeDocDoc),
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>): GraphThemeColors {
const [colors, setColors] = useState<GraphThemeColors>(() => 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)
}
}, [])
// Apply overrides if provided
if (overrides) {
return { ...colors, ...overrides }
}
return colors
}

View file

@ -1,19 +0,0 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(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
}

View file

@ -1,25 +1,41 @@
// 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,
GraphViewportResponse,
GraphBoundsResponse,
GraphStatsResponse,
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"

View file

@ -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)
}

View file

@ -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}%)`
}

View file

@ -0,0 +1,341 @@
import type { GraphApiDocument, GraphApiEdge, GraphApiMemory } from "./types"
export interface MockGraphOptions {
documentCount?: number
memoriesPerDoc?: number | [number, number]
similarityEdgeRatio?: 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",
] 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<string, string[]> = {
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[]
edges: GraphApiEdge[]
} {
const {
documentCount = 100,
memoriesPerDoc = [2, 6] as [number, number],
similarityEdgeRatio = 0.1,
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
// 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
}
} else {
// Standalone memory
isForgotten = random() < 0.1
isLatest = true
version = 1
}
// 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,
})
}
// Position documents spread across a 1000x1000 space
const x = random() * 1000
const y = random() * 1000
documents.push({
id: docId,
title: generateTitle(random),
summary: generateSummary(random),
documentType: DOCUMENT_TYPES[Math.floor(random() * DOCUMENT_TYPES.length)],
createdAt: docCreatedAt,
updatedAt: docUpdatedAt,
x,
y,
memories,
})
}
// Generate similarity edges between random document pairs
const edges: GraphApiEdge[] = []
const totalPossiblePairs = (documentCount * (documentCount - 1)) / 2
const targetEdgeCount = Math.max(0, Math.floor(totalPossiblePairs * similarityEdgeRatio))
// Use a set to avoid duplicate pairs
const edgeSet = new Set<string>()
// For small document counts, iterate all pairs; for large, sample randomly
if (documentCount <= 50 || targetEdgeCount > totalPossiblePairs * 0.5) {
// Iterate all pairs and include based on probability
for (let i = 0; i < documentCount; i++) {
for (let j = i + 1; j < documentCount; j++) {
if (random() < similarityEdgeRatio) {
const sourceId = documents[i].id
const targetId = documents[j].id
const key = `${sourceId}:${targetId}`
if (!edgeSet.has(key)) {
edgeSet.add(key)
// Similarity weighted towards medium-high values
const similarity = 0.3 + random() * 0.7
edges.push({
source: sourceId,
target: targetId,
similarity: Math.round(similarity * 1000) / 1000,
})
}
}
}
}
} else {
// Random sampling for large document counts
let attempts = 0
const maxAttempts = targetEdgeCount * 5
while (edges.length < targetEdgeCount && attempts < maxAttempts) {
attempts++
const i = Math.floor(random() * documentCount)
let j = Math.floor(random() * documentCount)
if (i === j) continue
const sourceIdx = Math.min(i, j)
const targetIdx = Math.max(i, j)
const sourceId = documents[sourceIdx].id
const targetId = documents[targetIdx].id
const key = `${sourceId}:${targetId}`
if (edgeSet.has(key)) continue
edgeSet.add(key)
const similarity = 0.3 + random() * 0.7
edges.push({
source: sourceId,
target: targetId,
similarity: Math.round(similarity * 1000) / 1000,
})
}
}
return { documents, edges }
}

View file

@ -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",
},
})

View file

@ -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}`,
},
},
})

View file

@ -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",
})

View file

@ -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"

View file

@ -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<typeof sprinkles>[0]

View file

@ -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",
},
})

View file

@ -1,148 +1,221 @@
import type {
DocumentsResponse,
DocumentWithMemories,
MemoryEntry,
} from "./api-types"
// Graph API types matching backend response
// Re-export for convenience
export type { DocumentsResponse, DocumentWithMemories, MemoryEntry }
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
y: number
memories: GraphApiMemory[]
}
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 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: 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: "doc-memory" | "similarity" | "version"
}
export interface SpacesDropdownProps {
selectedSpace: string
availableSpaces: string[]
spaceMemoryCounts: Record<string, number>
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
edgeDocMemory: string
edgeVersion: string
edgeSimStrong: string
edgeSimMedium: string
edgeSimWeak: string
edgeDocDoc: 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) => void
canvasRef?: React.RefObject<HTMLCanvasElement | null>
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[]
/** API edges between documents */
apiEdges?: GraphApiEdge[]
/** Whether data is loading */
isLoading?: boolean
/** Error that occurred during data fetching */
/** Error from data fetching */
error?: Error | null
/** Optional children to render when no documents exist */
/** Children to render when no documents */
children?: React.ReactNode
/** Whether more data is being loaded (for pagination) */
isLoadingMore?: boolean
/** Total number of documents loaded */
totalLoaded?: number
/** Whether there are more documents to load */
hasMore?: boolean
/** Callback to load more documents (for pagination) */
loadMoreDocuments?: () => Promise<void>
/** Show/hide the spaces filter dropdown */
showSpacesSelector?: boolean
/** Visual variant - "console" for full view, "consumer" for embedded */
/** 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<HTMLCanvasElement | null>
/** 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 +233,5 @@ 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"

View file

@ -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<typeof badge>

View file

@ -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 <Comp className={combinedClassName} data-slot="badge" {...props} />
}
export { Badge, badge as badgeVariants }

View file

@ -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<typeof button>

View file

@ -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 <Comp className={combinedClassName} data-slot="button" {...props} />
}
export { Button, button as buttonVariants }

View file

@ -1,33 +0,0 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View file

@ -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",
},
})

View file

@ -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 (
<div className={`${glassMenuContainer} ${className}`}>
{/* Frosted glass effect with translucent border */}
<div className={glassMenuEffect({ rounded })} />
</div>
)
}

View file

@ -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
},
},
})

View file

@ -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 <Comp className={combinedClassName} {...props} />
}

View file

@ -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()
}

View file

@ -1,26 +1,22 @@
{
"extends": "@total-typescript/tsconfig/bundler/dom/library",
"compilerOptions": {
"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,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noEmit": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"]
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationDir": "./dist",
"emitDeclarationOnly": true,
"outDir": "./dist",
"rootDir": "./src",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,93 +1,32 @@
import { defineConfig, type Plugin } from "vite"
import { defineConfig } from "vite"
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"),
},
},
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,
},
})