Rewrite @supermemory/memory-graph with perf optimizations + consolidate consumers (#809)

Co-authored-by: Vorflux AI <noreply@vorflux.com>
This commit is contained in:
vorflux[bot] 2026-03-28 19:06:27 -07:00 committed by GitHub
parent 38282a37d6
commit 851b8cfe86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
92 changed files with 6791 additions and 12118 deletions

View file

@ -45,52 +45,41 @@ export interface Project {
documentCount?: number
}
// Graph API types
export interface GraphApiMemory {
// Documents API types
export interface DocumentMemoryEntry {
id: string
memory: string
isStatic: boolean
isLatest: boolean
isForgotten: boolean
forgetAfter: string | null
version: number
parentMemoryId: string | null
spaceId: string
isStatic?: boolean
isLatest?: boolean
isForgotten?: boolean
forgetAfter?: string | null
forgetReason?: string | null
version?: number
parentMemoryId?: string | null
rootMemoryId?: string | null
createdAt: string
updatedAt: string
}
export interface GraphApiDocument {
export interface DocumentWithMemories {
id: string
title: string | null
summary: string | null
documentType: string
summary?: string | null
type: string
createdAt: string
updatedAt: string
x: number
y: number
memories: GraphApiMemory[]
memoryEntries: DocumentMemoryEntry[]
}
export interface GraphApiEdge {
source: string
target: string
similarity: number
}
export interface GraphViewportResponse {
documents: GraphApiDocument[]
edges: GraphApiEdge[]
viewport: { minX: number; maxX: number; minY: number; maxY: number }
totalCount: number
}
export interface GraphBoundsResponse {
bounds: {
minX: number
maxX: number
minY: number
maxY: number
} | null
export interface DocumentsApiResponse {
documents: DocumentWithMemories[]
pagination: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}
export function getMemoryText(m: Memory): string {
@ -171,9 +160,13 @@ export class SupermemoryClient {
message: `Successfully forgot memory (exact match) with ID: ${result.id}`,
containerTag: this.containerTag,
}
} catch (error: any) {
} catch (error: unknown) {
// If not 404, it's a real error - re-throw it
if (error?.status !== 404) {
const status =
error && typeof error === "object" && "status" in error
? (error as Record<string, unknown>).status
: undefined
if (status !== 404) {
throw error
}
// Otherwise continue to semantic search fallback
@ -332,53 +325,33 @@ export class SupermemoryClient {
}
}
// Fetch graph bounds for coordinate range
async getGraphBounds(containerTags?: string[]): Promise<GraphBoundsResponse> {
try {
const params = new URLSearchParams()
if (containerTags?.length) {
params.set("containerTags", JSON.stringify(containerTags))
}
const url = `${this.apiUrl}/v3/graph/bounds${params.toString() ? `?${params}` : ""}`
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${this.bearerToken}`,
"Content-Type": "application/json",
},
})
if (!response.ok) {
throw Object.assign(new Error("Failed to fetch graph bounds"), {
status: response.status,
})
}
return (await response.json()) as GraphBoundsResponse
} catch (error) {
this.handleError(error)
}
}
// Fetch graph data for a viewport region
async getGraphViewport(
viewport: { minX: number; maxX: number; minY: number; maxY: number },
// Fetch documents with their memory entries
async getDocuments(
containerTags?: string[],
page = 1,
limit = 200,
): Promise<GraphViewportResponse> {
): Promise<DocumentsApiResponse> {
try {
const response = await fetch(`${this.apiUrl}/v3/graph/viewport`, {
const response = await fetch(`${this.apiUrl}/v3/documents/documents`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.bearerToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ viewport, containerTags, limit }),
body: JSON.stringify({
page,
limit,
sort: "createdAt",
order: "desc",
containerTags,
}),
})
if (!response.ok) {
throw Object.assign(new Error("Failed to fetch graph viewport"), {
throw Object.assign(new Error("Failed to fetch documents"), {
status: response.status,
})
}
return (await response.json()) as GraphViewportResponse
return (await response.json()) as DocumentsApiResponse
} catch (error) {
this.handleError(error)
}

View file

@ -311,21 +311,14 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
? [effectiveContainerTag]
: undefined
const [bounds, viewport] = await Promise.all([
client.getGraphBounds(containerTags),
client.getGraphViewport(
{ minX: 0, maxX: 1000, minY: 0, maxY: 1000 },
containerTags,
200,
),
])
const result = await client.getDocuments(containerTags, 1, 200)
const memoryCount = viewport.documents.reduce(
(sum, d) => sum + d.memories.length,
const memoryCount = result.documents.reduce(
(sum, d) => sum + d.memoryEntries.length,
0,
)
const textParts = [
`Memory Graph: ${viewport.documents.length} documents, ${memoryCount} memories, ${viewport.edges.length} connections`,
`Memory Graph: ${result.documents.length} documents, ${memoryCount} memories`,
]
if (effectiveContainerTag) {
textParts.push(`Project: ${effectiveContainerTag}`)
@ -335,10 +328,8 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
content: [{ type: "text" as const, text: textParts.join(". ") }],
structuredContent: {
containerTag: effectiveContainerTag,
bounds: bounds.bounds,
documents: viewport.documents,
edges: viewport.edges,
totalCount: viewport.totalCount,
documents: result.documents,
totalCount: result.pagination.totalItems,
},
}
} catch (error) {
@ -359,20 +350,15 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
},
)
// App-only tool for the UI to fetch additional graph data
// App-only tool for the UI to fetch additional documents (pagination)
registerAppTool(
this.server,
"fetch-graph-data",
{
description: "Fetch graph data for a viewport region",
description: "Fetch documents with memories for graph display",
inputSchema: z.object({
containerTag: z.string().optional(),
viewport: z.object({
minX: z.number(),
maxX: z.number(),
minY: z.number(),
maxY: z.number(),
}),
page: z.number().optional().default(1),
limit: z.number().optional().default(200),
}),
_meta: {
@ -385,12 +371,7 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
// @ts-expect-error - zod type inference issue with MCP SDK
async (args: {
containerTag?: string
viewport: {
minX: number
maxX: number
minY: number
maxY: number
}
page?: number
limit?: number
}) => {
try {
@ -400,9 +381,9 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
const containerTags = effectiveContainerTag
? [effectiveContainerTag]
: undefined
const data = await client.getGraphViewport(
args.viewport,
const data = await client.getDocuments(
containerTags,
args.page,
args.limit,
)

View file

@ -27,13 +27,18 @@ interface GraphApiMemory {
id: string
memory: string
isStatic: boolean
spaceId: string
isLatest: boolean
isForgotten: boolean
forgetAfter: string | null
forgetReason: string | null
version: number
parentMemoryId: string | null
rootMemoryId: string | null
createdAt: string
updatedAt: string
relation?: "updates" | "extends" | "derives" | null
memoryRelations?: Record<string, "updates" | "extends" | "derives"> | null
}
interface GraphApiDocument {
@ -43,22 +48,12 @@ interface GraphApiDocument {
documentType: string
createdAt: string
updatedAt: string
x: number
y: number
memories: GraphApiMemory[]
}
interface GraphApiEdge {
source: string
target: string
similarity: number
}
interface ToolResultData {
containerTag?: string
bounds: { minX: number; maxX: number; minY: number; maxY: number } | null
documents: GraphApiDocument[]
edges: GraphApiEdge[]
totalCount: number
}
@ -91,8 +86,7 @@ type GraphNode = MemoryNode | DocumentNode
interface GraphLink extends LinkObject {
source: string | GraphNode
target: string | GraphNode
edgeType: "doc-memory" | "version" | "similarity"
similarity?: number
edgeType: "derives" | "updates" | "extends"
}
// =============================================================================
@ -107,14 +101,14 @@ const MEMORY_BORDER = {
const EDGE_COLORS = {
dark: {
"doc-memory": "#4A5568",
version: "#8B5CF6",
similarity: "#00D4B8",
derives: "#38BDF8",
updates: "#A78BFA",
extends: "#2DD4BF",
},
light: {
"doc-memory": "#A0AEC0",
version: "#8B5CF6",
similarity: "#0D9488",
derives: "#7DD3FC",
updates: "#A78BFA",
extends: "#5EEAD4",
},
}
@ -157,33 +151,20 @@ function getMemoryBorderColor(mem: GraphApiMemory): string {
return MEMORY_BORDER.default
}
function normalizeDocCoordinates(
documents: GraphApiDocument[],
): GraphApiDocument[] {
if (documents.length <= 1) return documents
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const doc of documents) {
minX = Math.min(minX, doc.x)
maxX = Math.max(maxX, doc.x)
minY = Math.min(minY, doc.y)
maxY = Math.max(maxY, doc.y)
/** Simple hash to get deterministic initial positions from doc ID */
function hashCode(s: string): number {
let h = 0
for (let i = 0; i < s.length; i++) {
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
}
return h
}
const rangeX = maxX - minX || 1
const rangeY = maxY - minY || 1
// Small spread so documents start near each other.
// The force simulation will naturally separate them.
const SPREAD = 50
return documents.map((doc) => ({
...doc,
x: ((doc.x - minX) / rangeX - 0.5) * SPREAD,
y: ((doc.y - minY) / rangeY - 0.5) * SPREAD,
}))
function initialPosition(id: string, spread: number): { x: number; y: number } {
const h = hashCode(id)
const angle = ((h & 0xffff) / 0xffff) * Math.PI * 2
const radius = (((h >>> 16) & 0xffff) / 0xffff) * spread
return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius }
}
function transformData(data: ToolResultData): {
@ -192,11 +173,18 @@ function transformData(data: ToolResultData): {
} {
const nodes: GraphNode[] = []
const links: GraphLink[] = []
const SPREAD = 50
// Pre-populate all node IDs so edge targets are always resolvable
// regardless of iteration order.
const nodeIds = new Set<string>()
for (const doc of data.documents) {
nodeIds.add(doc.id)
for (const mem of doc.memories) nodeIds.add(mem.id)
}
const normalizedDocs = normalizeDocCoordinates(data.documents)
for (const doc of normalizedDocs) {
for (const doc of data.documents) {
const pos = initialPosition(doc.id, SPREAD)
nodes.push({
id: doc.id,
nodeType: "document",
@ -205,10 +193,9 @@ function transformData(data: ToolResultData): {
docType: doc.documentType,
createdAt: doc.createdAt,
memoryCount: doc.memories.length,
x: doc.x,
y: doc.y,
x: pos.x,
y: pos.y,
} as DocumentNode)
nodeIds.add(doc.id)
const memCount = doc.memories.length
for (let i = 0; i < memCount; i++) {
@ -227,34 +214,38 @@ function transformData(data: ToolResultData): {
parentMemoryId: mem.parentMemoryId,
createdAt: mem.createdAt,
borderColor: getMemoryBorderColor(mem),
x: doc.x + Math.cos(angle) * CLUSTER_SPREAD,
y: doc.y + Math.sin(angle) * CLUSTER_SPREAD,
x: pos.x + Math.cos(angle) * CLUSTER_SPREAD,
y: pos.y + Math.sin(angle) * CLUSTER_SPREAD,
} as MemoryNode)
nodeIds.add(mem.id)
// Doc-memory link
links.push({ source: doc.id, target: mem.id, edgeType: "doc-memory" })
// Derives link (doc -> memory)
links.push({ source: doc.id, target: mem.id, edgeType: "derives" })
// Version chain link
if (mem.parentMemoryId && nodeIds.has(mem.parentMemoryId)) {
links.push({
source: mem.parentMemoryId,
target: mem.id,
edgeType: "version",
})
// Memory-to-memory relation edges from backend data.
// Uses memoryRelations as primary source, falls back to parentMemoryId.
// Keep in sync with packages/memory-graph/src/hooks/use-graph-data.ts
let relations: Record<string, string> = {}
if (
// Defensive: data comes from structuredContent cast, may be unexpected type
mem.memoryRelations &&
typeof mem.memoryRelations === "object" &&
Object.keys(mem.memoryRelations).length > 0
) {
relations = mem.memoryRelations
} else if (mem.parentMemoryId) {
relations = { [mem.parentMemoryId]: "updates" }
}
}
}
// Similarity edges from API
for (const edge of data.edges) {
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
links.push({
source: edge.source,
target: edge.target,
edgeType: "similarity",
similarity: edge.similarity,
})
for (const [targetId, relationType] of Object.entries(relations)) {
if (!nodeIds.has(targetId)) continue
const edgeType =
relationType === "updates" ||
relationType === "extends" ||
relationType === "derives"
? relationType
: "updates"
links.push({ source: targetId, target: mem.id, edgeType })
}
}
}
@ -317,7 +308,7 @@ function drawDocumentNode(
// =============================================================================
function getLinkColor(link: GraphLink): string {
const palette = isDark ? EDGE_COLORS.dark : EDGE_COLORS.light
return palette[link.edgeType] || palette["doc-memory"]
return palette[link.edgeType] || palette["derives"]
}
const graph = new ForceGraph<GraphNode, GraphLink>(container)
@ -370,18 +361,17 @@ const graph = new ForceGraph<GraphNode, GraphLink>(container)
},
)
.linkWidth((link: GraphLink) => {
if (link.edgeType === "version") return 2
if (link.edgeType === "similarity")
return 0.5 + (link.similarity || 0) * 1.5
if (link.edgeType === "updates") return 2
if (link.edgeType === "extends") return 0.5
return 1
})
.linkColor(getLinkColor)
.linkLineDash((link: GraphLink) => {
if (link.edgeType === "similarity") return [4, 2]
if (link.edgeType === "extends") return [4, 2]
return null as unknown as number[]
})
.linkDirectionalArrowLength((link: GraphLink) =>
link.edgeType === "version" ? 4 : 0,
link.edgeType === "updates" ? 4 : 0,
)
.linkDirectionalArrowRelPos(1)
.onNodeClick(handleNodeClick)
@ -395,11 +385,11 @@ const graph = new ForceGraph<GraphNode, GraphLink>(container)
.d3Force(
"link",
forceLink()
.distance((l: GraphLink) => (l.edgeType === "doc-memory" ? 40 : 80))
.distance((l: GraphLink) => (l.edgeType === "derives" ? 40 : 80))
.strength((l: GraphLink) => {
if (l.edgeType === "doc-memory") return 0.8
if (l.edgeType === "version") return 1.0
return (l.similarity || 0.3) * 0.3
if (l.edgeType === "derives") return 0.8
if (l.edgeType === "updates") return 1.0
return 0.15 // extends
}),
)
.d3Force("collide", forceCollide(18))

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

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,50 @@ interface DocumentsResponse {
}
}
/** Convert the external API format to the internal graph format */
function toGraphDocuments(docs: DocumentWithMemories[]): GraphApiDocument[] {
return docs.map((doc) => ({
id: doc.id,
title: doc.title,
summary: doc.summary ?? null,
documentType: doc.documentType,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
memories: doc.memories.map(
(mem): GraphApiMemory => ({
id: mem.id,
memory: mem.content,
isStatic: mem.isStatic ?? false,
spaceId: mem.spaceId ?? "",
isLatest: mem.isLatest ?? true,
isForgotten: mem.isForgotten ?? false,
forgetAfter: mem.forgetAfter ?? null,
forgetReason: mem.forgetReason ?? null,
version: mem.version ?? 1,
parentMemoryId: mem.parentMemoryId ?? null,
rootMemoryId: mem.rootMemoryId ?? null,
createdAt: mem.createdAt,
updatedAt: mem.updatedAt,
}),
),
}))
}
export default function Home() {
const [apiKey, setApiKey] = useState("")
const [documents, setDocuments] = useState<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 +72,6 @@ export default function Home() {
if (page === 1) {
setIsLoading(true)
} else {
setIsLoadingMore(true)
}
setError(null)
@ -76,43 +103,37 @@ export default function Home() {
setDocuments(data.documents)
}
setCurrentPage(data.pagination.currentPage)
setHasMore(data.pagination.currentPage < data.pagination.totalPages)
setShowGraph(true)
setMockData(null)
setStressTestCount(0)
} catch (err) {
setError(err instanceof Error ? err : new Error("Unknown error"))
} finally {
setIsLoading(false)
setIsLoadingMore(false)
}
},
[apiKey],
)
const loadMoreDocuments = useCallback(async () => {
if (hasMore && !isLoadingMore) {
await fetchDocuments(currentPage + 1, true)
}
}, [hasMore, isLoadingMore, currentPage, fetchDocuments])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (apiKey) {
setDocuments([])
setCurrentPage(0)
setSelectedSpace("all")
fetchDocuments(1)
}
}
// Handle space change
const handleSpaceChange = useCallback((spaceId: string) => {
setSelectedSpace(spaceId)
}, [])
// Reset to defaults
const handleReset = () => {
setSelectedSpace("all")
const handleStressTest = (count: number) => {
const data = generateMockGraphData({
documentCount: count,
memoriesPerDoc: [2, 5],
seed: 12345,
})
setMockData({ documents: data.documents })
setDocuments([])
setStressTestCount(count)
setShowGraph(true)
setError(null)
}
// Toggle slideshow
@ -122,16 +143,22 @@ export default function Home() {
// Handle slideshow node change
const handleSlideshowNodeChange = useCallback((nodeId: string | null) => {
// Track which node is being shown in slideshow
setCurrentSlideshowNode(nodeId)
console.log("Slideshow showing node:", nodeId)
}, [])
// Handle slideshow stop (when user clicks outside)
// Handle slideshow stop
const handleSlideshowStop = useCallback(() => {
setIsSlideshowActive(false)
}, [])
// Convert real documents to graph format
const graphDocuments = useMemo(() => {
if (mockData) return mockData.documents
return toGraphDocuments(documents)
}, [documents, mockData])
const displayCount = mockData ? stressTestCount : documents.length
return (
<div className="flex flex-col h-screen bg-zinc-950">
{/* Header */}
@ -165,68 +192,65 @@ 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"
aria-hidden="true"
>
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">
@ -239,6 +263,7 @@ export default function Home() {
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
@ -252,21 +277,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 +298,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,681 +0,0 @@
import type { ViewportState } from "./viewport"
import type { GraphNode, GraphEdge, DocumentNodeData } from "../types"
export interface RenderState {
selectedNodeId: string | null
hoveredNodeId: string | null
highlightIds: Set<string>
dimProgress: number
}
export function renderFrame(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
edges: GraphEdge[],
viewport: ViewportState,
width: number,
height: number,
state: RenderState,
nodeMap: Map<string, GraphNode>,
): void {
ctx.clearRect(0, 0, width, height)
drawDocDocLines(ctx, nodes, viewport, width, height)
drawEdges(ctx, edges, viewport, width, height, state, nodeMap)
drawNodes(ctx, nodes, viewport, width, height, state)
}
// Connect each visible doc to its 2 nearest neighbors
function drawDocDocLines(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
viewport: ViewportState,
width: number,
height: number,
): void {
const docs: { x: number; y: number }[] = []
for (const n of nodes) {
if (n.type !== "document") continue
const s = viewport.worldToScreen(n.x, n.y)
if (s.x > -100 && s.x < width + 100 && s.y > -100 && s.y < height + 100) {
docs.push(s)
}
}
if (docs.length < 2) return
ctx.strokeStyle = "#8DA3F4"
ctx.lineWidth = 1
ctx.globalAlpha = 0.3
ctx.setLineDash([4, 6])
ctx.beginPath()
// Deduplicate: only draw line when i < neighbor index
for (let i = 0; i < docs.length; i++) {
const d = docs[i]!
let best1 = -1
let best2 = -1
let dist1 = Number.POSITIVE_INFINITY
let dist2 = Number.POSITIVE_INFINITY
for (let j = 0; j < docs.length; j++) {
if (j === i) continue
const dx = docs[j]!.x - d.x
const dy = docs[j]!.y - d.y
const dist = dx * dx + dy * dy
if (dist < dist1) {
best2 = best1
dist2 = dist1
best1 = j
dist1 = dist
} else if (dist < dist2) {
best2 = j
dist2 = dist
}
}
if (best1 >= 0 && i < best1) {
ctx.moveTo(d.x, d.y)
ctx.lineTo(docs[best1]!.x, docs[best1]!.y)
}
if (best2 >= 0 && i < best2) {
ctx.moveTo(d.x, d.y)
ctx.lineTo(docs[best2]!.x, docs[best2]!.y)
}
}
ctx.stroke()
ctx.setLineDash([])
ctx.globalAlpha = 1
}
// --- Edges ---
const EDGE_STYLE: Record<string, { color: string; width: number }> = {
"doc-memory": { color: "#4A5568", width: 1.5 },
version: { color: "#8B5CF6", width: 2 },
}
const SIM_STRONG = { color: "#00D4B8", width: 2 } as const
const SIM_MEDIUM = { color: "#6B8FBF", width: 1.5 } as const
const SIM_WEAK = { color: "#4A6A8A", width: 1 } as const
function edgeStyle(edge: GraphEdge): { color: string; width: number } {
const preset = EDGE_STYLE[edge.edgeType]
if (preset) return preset
if (edge.similarity >= 0.9) return SIM_STRONG
if (edge.similarity >= 0.8) return SIM_MEDIUM
return SIM_WEAK
}
// Unique key for batching: "color|width"
function batchKey(style: { color: string; width: number }): string {
return `${style.color}|${style.width}`
}
interface PreparedEdge {
startX: number
startY: number
endX: number
endY: number
connected: boolean
style: { color: string; width: number }
isVersion: boolean
arrowSize: number
}
function drawEdges(
ctx: CanvasRenderingContext2D,
edges: GraphEdge[],
viewport: ViewportState,
width: number,
height: number,
state: RenderState,
nodeMap: Map<string, GraphNode>,
): void {
const margin = 100
const hasDim = state.selectedNodeId !== null && state.dimProgress > 0
// Prepare all visible edges
const prepared: PreparedEdge[] = []
for (const edge of edges) {
const src =
typeof edge.source === "string" ? nodeMap.get(edge.source) : edge.source
const tgt =
typeof edge.target === "string" ? nodeMap.get(edge.target) : edge.target
if (!src || !tgt) continue
// Skip doc-memory edges when memory dots are too small to see connections
if (edge.edgeType === "doc-memory") {
const mem = src.type === "memory" ? src : tgt
if (mem.size * viewport.zoom < 3) continue
}
const s = viewport.worldToScreen(src.x, src.y)
const t = viewport.worldToScreen(tgt.x, tgt.y)
if (
(s.x < -margin && t.x < -margin) ||
(s.x > width + margin && t.x > width + margin) ||
(s.y < -margin && t.y < -margin) ||
(s.y > height + margin && t.y > height + margin)
)
continue
const dx = t.x - s.x
const dy = t.y - s.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < 1) continue
const ux = dx / dist
const uy = dy / dist
const sr = src.size * viewport.zoom * 0.5
const tr = tgt.size * viewport.zoom * 0.5
let connected = true
if (hasDim) {
const srcId =
typeof edge.source === "string" ? edge.source : edge.source.id
const tgtId =
typeof edge.target === "string" ? edge.target : edge.target.id
connected =
srcId === state.selectedNodeId || tgtId === state.selectedNodeId
}
prepared.push({
startX: s.x + ux * sr,
startY: s.y + uy * sr,
endX: t.x - ux * tr,
endY: t.y - uy * tr,
connected,
style: edgeStyle(edge),
isVersion: edge.edgeType === "version",
arrowSize:
edge.edgeType === "version" ? Math.max(6, 8 * viewport.zoom) : 0,
})
}
// Batch by style + dim state: group into "key|connected" and "key|dimmed"
const batches = new Map<string, PreparedEdge[]>()
for (const e of prepared) {
const dimKey = hasDim ? (e.connected ? "|c" : "|d") : ""
const key = batchKey(e.style) + dimKey
let batch = batches.get(key)
if (!batch) {
batch = []
batches.set(key, batch)
}
batch.push(e)
}
// Draw each batch in a single beginPath/stroke
ctx.setLineDash([])
for (const [key, batch] of batches) {
const first = batch[0]!
const isDimmed = key.endsWith("|d")
ctx.globalAlpha = isDimmed ? 1 - state.dimProgress * 0.8 : 1
ctx.strokeStyle = first.style.color
ctx.lineWidth = first.style.width
ctx.beginPath()
for (const e of batch) {
ctx.moveTo(e.startX, e.startY)
ctx.lineTo(e.endX, e.endY)
}
ctx.stroke()
// Arrow heads for version edges (fill calls — unavoidable per-arrow)
const versionEdges = batch.filter((e) => e.isVersion)
if (versionEdges.length > 0) {
ctx.fillStyle = first.style.color
for (const e of versionEdges) {
drawArrowHead(ctx, e.startX, e.startY, e.endX, e.endY, e.arrowSize)
}
}
}
ctx.globalAlpha = 1
}
function drawArrowHead(
ctx: CanvasRenderingContext2D,
fromX: number,
fromY: number,
toX: number,
toY: number,
size: number,
): void {
const angle = Math.atan2(toY - fromY, toX - fromX)
ctx.beginPath()
ctx.moveTo(toX, toY)
ctx.lineTo(
toX - size * Math.cos(angle - Math.PI / 6),
toY - size * Math.sin(angle - Math.PI / 6),
)
ctx.lineTo(
toX - size * Math.cos(angle + Math.PI / 6),
toY - size * Math.sin(angle + Math.PI / 6),
)
ctx.closePath()
ctx.fill()
}
// --- Nodes ---
function drawNodes(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
viewport: ViewportState,
width: number,
height: number,
state: RenderState,
): void {
const margin = 60
const memDots: { x: number; y: number; r: number; color: string }[] = []
const docDots: { x: number; y: number; s: number }[] = []
for (const node of nodes) {
const screen = viewport.worldToScreen(node.x, node.y)
const screenSize = node.size * viewport.zoom
// Frustum cull (use at least 2px so tiny nodes aren't culled)
const cullSize = Math.max(screenSize, 2)
if (
screen.x + cullSize < -margin ||
screen.x - cullSize > width + margin ||
screen.y + cullSize < -margin ||
screen.y - cullSize > height + margin
)
continue
const isSelected = node.id === state.selectedNodeId
const isHovered = node.id === state.hoveredNodeId
const isHighlighted = state.highlightIds.has(node.id)
// LOD: tiny nodes → batched dots (but selected/highlighted always get full detail)
if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) {
if (node.type === "document") {
docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) })
} else {
memDots.push({
x: screen.x,
y: screen.y,
r: Math.max(2, screenSize * 0.45),
color: node.borderColor || "#3B73B8",
})
}
continue
}
let alpha = 1
if (state.selectedNodeId && state.dimProgress > 0 && !isSelected) {
alpha = 1 - state.dimProgress * 0.7
}
ctx.globalAlpha = alpha
if (node.type === "document") {
drawDocumentNode(
ctx,
screen.x,
screen.y,
screenSize,
node,
isSelected,
isHovered,
isHighlighted,
)
} else {
drawMemoryNode(
ctx,
screen.x,
screen.y,
screenSize,
node,
isSelected,
isHovered,
isHighlighted,
)
}
if (isSelected || isHighlighted) {
drawGlow(ctx, screen.x, screen.y, screenSize, node.type)
}
}
const dimAlpha =
state.selectedNodeId && state.dimProgress > 0
? 1 - state.dimProgress * 0.7
: 1
// Batch: document dots as filled squares
if (docDots.length > 0) {
ctx.fillStyle = "#1B1F24"
ctx.strokeStyle = "#2A2F36"
ctx.lineWidth = 1
ctx.globalAlpha = dimAlpha
for (const d of docDots) {
const h = d.s * 0.5
ctx.fillRect(d.x - h, d.y - h, d.s, d.s)
ctx.strokeRect(d.x - h, d.y - h, d.s, d.s)
}
}
// Batch: memory dots — dark fill, then colored border strokes
if (memDots.length > 0) {
ctx.globalAlpha = dimAlpha
// Pass 1: all dark fills in one batch
ctx.fillStyle = "#0D2034"
ctx.beginPath()
for (const d of memDots) {
ctx.moveTo(d.x + d.r, d.y)
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
}
ctx.fill()
// Pass 2: colored strokes grouped by border color
ctx.lineWidth = 1.5
const byColor = new Map<string, typeof memDots>()
for (const d of memDots) {
let batch = byColor.get(d.color)
if (!batch) {
batch = []
byColor.set(d.color, batch)
}
batch.push(d)
}
for (const [color, batch] of byColor) {
ctx.strokeStyle = color
ctx.beginPath()
for (const d of batch) {
ctx.moveTo(d.x + d.r, d.y)
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
}
ctx.stroke()
}
}
ctx.globalAlpha = 1
}
function drawDocumentNode(
ctx: CanvasRenderingContext2D,
sx: number,
sy: number,
size: number,
node: GraphNode,
isSelected: boolean,
isHovered: boolean,
isHighlighted: boolean,
): void {
const half = size * 0.5
const cornerR = 8 * (size / 50)
// Outer rect
ctx.fillStyle = "#1B1F24"
ctx.strokeStyle =
isSelected || isHighlighted ? "#3B73B8" : isHovered ? "#3B73B8" : "#2A2F36"
ctx.lineWidth = isSelected || isHighlighted ? 2 : 1
roundRect(ctx, sx - half, sy - half, size, size, cornerR)
ctx.fill()
ctx.stroke()
// Inner rect
const innerSize = size * 0.72
const innerHalf = innerSize * 0.5
const innerR = 6 * (size / 50)
ctx.fillStyle = "#13161A"
roundRect(ctx, sx - innerHalf, sy - innerHalf, innerSize, innerSize, innerR)
ctx.fill()
// Icon
const iconSize = size * 0.35
const docType =
node.type === "document" ? (node.data as DocumentNodeData).type : "text"
drawDocIcon(ctx, sx, sy, iconSize, docType || "text")
}
function drawMemoryNode(
ctx: CanvasRenderingContext2D,
sx: number,
sy: number,
size: number,
node: GraphNode,
isSelected: boolean,
isHovered: boolean,
_isHighlighted: boolean,
): void {
const radius = size * 0.5
// Fill
ctx.fillStyle = isHovered ? "#112840" : "#0D2034"
drawHexagon(ctx, sx, sy, radius)
ctx.fill()
// Stroke with time-based border color
const borderColor = node.borderColor || "#3B73B8"
ctx.strokeStyle = isSelected ? "#3B73B8" : borderColor
ctx.lineWidth = isHovered ? 2 : 1.5
ctx.stroke()
}
function drawGlow(
ctx: CanvasRenderingContext2D,
sx: number,
sy: number,
size: number,
nodeType: "document" | "memory",
): void {
ctx.strokeStyle = "#3B73B8"
ctx.lineWidth = 2
ctx.setLineDash([3, 3])
ctx.globalAlpha = 0.8
if (nodeType === "document") {
const glowSize = size * 1.15
const half = glowSize * 0.5
const r = 8 * (glowSize / 50)
roundRect(ctx, sx - half, sy - half, glowSize, glowSize, r)
} else {
drawHexagon(ctx, sx, sy, size * 0.5 * 1.15)
}
ctx.stroke()
ctx.setLineDash([])
ctx.globalAlpha = 1
}
// --- Shapes ---
function drawHexagon(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
radius: number,
): void {
ctx.beginPath()
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i - Math.PI / 6
const x = cx + radius * Math.cos(angle)
const y = cy + radius * Math.sin(angle)
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
}
ctx.closePath()
}
function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
): void {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.arcTo(x + w, y, x + w, y + r, r)
ctx.lineTo(x + w, y + h - r)
ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
ctx.lineTo(x + r, y + h)
ctx.arcTo(x, y + h, x, y + h - r, r)
ctx.lineTo(x, y + r)
ctx.arcTo(x, y, x + r, y, r)
ctx.closePath()
}
// --- Document icons ---
const ICON_COLOR = "#3B73B8"
function drawDocIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
type: string,
): void {
ctx.save()
ctx.fillStyle = ICON_COLOR
ctx.strokeStyle = ICON_COLOR
ctx.lineWidth = Math.max(1, size / 12)
ctx.lineCap = "round"
ctx.lineJoin = "round"
switch (type) {
case "webpage":
case "url":
drawGlobeIcon(ctx, x, y, size)
break
case "pdf":
drawTextLabel(ctx, x, y, size, "PDF", 0.35)
break
case "md":
case "markdown":
drawTextLabel(ctx, x, y, size, "MD", 0.3)
break
case "doc":
case "docx":
drawTextLabel(ctx, x, y, size, "DOC", 0.28)
break
case "csv":
drawGridIcon(ctx, x, y, size)
break
case "json":
drawBracesIcon(ctx, x, y, size)
break
case "notion":
case "notion_doc":
drawTextLabel(ctx, x, y, size, "N", 0.4)
break
case "google_doc":
case "google_sheet":
case "google_slide":
drawTextLabel(ctx, x, y, size, "G", 0.4)
break
default:
drawDocOutline(ctx, x, y, size)
break
}
ctx.restore()
}
function drawTextLabel(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
text: string,
fontRatio: number,
): void {
ctx.font = `bold ${size * fontRatio}px sans-serif`
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText(text, x, y)
}
function drawGlobeIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const r = size * 0.4
ctx.beginPath()
ctx.arc(x, y, r, 0, Math.PI * 2)
ctx.stroke()
ctx.beginPath()
ctx.ellipse(x, y, r * 0.4, r, 0, 0, Math.PI * 2)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(x - r, y)
ctx.lineTo(x + r, y)
ctx.stroke()
}
function drawGridIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const w = size * 0.7
const h = size * 0.7
ctx.strokeRect(x - w / 2, y - h / 2, w, h)
ctx.beginPath()
ctx.moveTo(x, y - h / 2)
ctx.lineTo(x, y + h / 2)
ctx.moveTo(x - w / 2, y)
ctx.lineTo(x + w / 2, y)
ctx.stroke()
}
function drawBracesIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const w = size * 0.6
const h = size * 0.8
ctx.beginPath()
ctx.moveTo(x - w / 4, y - h / 2)
ctx.quadraticCurveTo(x - w / 2, y - h / 3, x - w / 2, y)
ctx.quadraticCurveTo(x - w / 2, y + h / 3, x - w / 4, y + h / 2)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(x + w / 4, y - h / 2)
ctx.quadraticCurveTo(x + w / 2, y - h / 3, x + w / 2, y)
ctx.quadraticCurveTo(x + w / 2, y + h / 3, x + w / 4, y + h / 2)
ctx.stroke()
}
function drawDocOutline(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const w = size * 0.7
const h = size * 0.85
const fold = size * 0.2
ctx.beginPath()
ctx.moveTo(x - w / 2, y - h / 2)
ctx.lineTo(x + w / 2 - fold, y - h / 2)
ctx.lineTo(x + w / 2, y - h / 2 + fold)
ctx.lineTo(x + w / 2, y + h / 2)
ctx.lineTo(x - w / 2, y + h / 2)
ctx.closePath()
ctx.stroke()
const sp = size * 0.15
const lw = size * 0.4
ctx.beginPath()
ctx.moveTo(x - lw / 2, y - sp)
ctx.lineTo(x + lw / 2, y - sp)
ctx.moveTo(x - lw / 2, y)
ctx.lineTo(x + lw / 2, y)
ctx.moveTo(x - lw / 2, y + sp)
ctx.lineTo(x + lw / 2, y + sp)
ctx.stroke()
}

View file

@ -1,79 +0,0 @@
import * as d3 from "d3-force"
import type { GraphNode, GraphEdge } from "../types"
export class ForceSimulation {
private sim: d3.Simulation<GraphNode, GraphEdge> | null = null
init(nodes: GraphNode[], edges: GraphEdge[]): void {
this.destroy()
try {
this.sim = d3
.forceSimulation<GraphNode>(nodes)
.alphaDecay(0.03)
.alphaMin(0.001)
.velocityDecay(0.6)
this.sim.force(
"link",
d3
.forceLink<GraphNode, GraphEdge>(edges)
.id((d) => d.id)
.distance((link) => (link.edgeType === "doc-memory" ? 150 : 300))
.strength((link) => {
if (link.edgeType === "doc-memory") return 0.8
if (link.edgeType === "version") return 1.0
return link.similarity * 0.3
}),
)
this.sim.force("charge", d3.forceManyBody<GraphNode>().strength(-1000))
this.sim.force(
"collide",
d3
.forceCollide<GraphNode>()
.radius((d) => (d.type === "document" ? 80 : 40))
.strength(0.7),
)
this.sim.force("x", d3.forceX().strength(0.05))
this.sim.force("y", d3.forceY().strength(0.05))
// Pre-settle synchronously, then start the live simulation
this.sim.stop()
this.sim.alpha(1)
for (let i = 0; i < 50; i++) this.sim.tick()
this.sim.alphaTarget(0).restart()
} catch (e) {
console.error("ForceSimulation.init failed:", e)
this.destroy()
}
}
update(nodes: GraphNode[], edges: GraphEdge[]): void {
if (!this.sim) return
this.sim.nodes(nodes)
const linkForce = this.sim.force<d3.ForceLink<GraphNode, GraphEdge>>("link")
if (linkForce) linkForce.links(edges)
}
reheat(): void {
this.sim?.alphaTarget(0.3).restart()
}
coolDown(): void {
this.sim?.alphaTarget(0)
}
isActive(): boolean {
return (this.sim?.alpha() ?? 0) > 0.001
}
destroy(): void {
if (this.sim) {
this.sim.stop()
this.sim = null
}
}
}

View file

@ -1,63 +0,0 @@
import type { GraphApiDocument, GraphApiMemory } from "../types"
export interface ChainEntry {
id: string
version: number
memory: string
isForgotten: boolean
isLatest: boolean
}
export class VersionChainIndex {
private memoryMap = new Map<string, GraphApiMemory>()
private cache = new Map<string, ChainEntry[]>()
private lastDocs: GraphApiDocument[] | null = null
rebuild(documents: GraphApiDocument[]): void {
if (documents === this.lastDocs) return
this.lastDocs = documents
this.memoryMap.clear()
this.cache.clear()
for (const doc of documents) {
for (const m of doc.memories) {
this.memoryMap.set(m.id, m)
}
}
}
getChain(memoryId: string): ChainEntry[] | null {
const cached = this.cache.get(memoryId)
if (cached) return cached
const mem = this.memoryMap.get(memoryId)
if (!mem || mem.version <= 1) return null
// Walk parentMemoryId up to the root
const chain: ChainEntry[] = []
const visited = new Set<string>()
let current: GraphApiMemory | undefined = mem
while (current && !visited.has(current.id)) {
visited.add(current.id)
chain.push({
id: current.id,
version: current.version,
memory: current.memory,
isForgotten: current.isForgotten,
isLatest: current.isLatest,
})
current = current.parentMemoryId
? this.memoryMap.get(current.parentMemoryId)
: undefined
}
chain.reverse()
// Cache for every member in the chain
for (const entry of chain) {
this.cache.set(entry.id, chain)
}
return chain
}
}

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

@ -85,6 +85,8 @@ function StaticGraphPreview({
height={height}
className="absolute inset-0"
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-label="Memory graph preview"
>
{edges.map((e, i) => (
<line
@ -116,9 +118,8 @@ export const GraphCard = memo<GraphCardProps>(
({ containerTags, width = 216, height = 220, className }) => {
const { setViewMode } = useViewMode()
const { data, isLoading, error } = useGraphApi({
const { documents, isLoading, error } = useGraphApi({
containerTags,
limit: 20,
enabled: true,
})
@ -139,11 +140,8 @@ export const GraphCard = memo<GraphCardProps>(
)
}
const documentCount = data.stats?.documentsWithSpatial ?? 0
const memoryCount = data.documents.reduce(
(sum, d) => sum + d.memories.length,
0,
)
const documentCount = documents.length
const memoryCount = documents.reduce((sum, d) => sum + d.memories.length, 0)
return (
<button

View file

@ -1,273 +1,154 @@
"use client"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useCallback, useMemo, useState, useRef, useEffect } from "react"
import { useInfiniteQuery } from "@tanstack/react-query"
import { useMemo } from "react"
import { $fetch } from "@lib/api"
import type {
GraphViewportResponse,
GraphBoundsResponse,
GraphStatsResponse,
} from "../types"
GraphApiDocument,
GraphApiMemory,
MemoryRelation,
} from "@supermemory/memory-graph"
interface ViewportParams {
minX: number
maxX: number
minY: number
maxY: number
}
const PAGE_SIZE = 100
interface UseGraphApiOptions {
containerTags?: string[]
limit?: number
enabled?: boolean
documentIds?: string[]
}
interface ApiMemoryEntry {
id: string
memory: string
content?: string | null
spaceId: string
isStatic?: boolean
isLatest?: boolean
isForgotten?: boolean
forgetAfter?: string | null
forgetReason?: string | null
version?: number
parentMemoryId?: string | null
rootMemoryId?: string | null
createdAt: string
updatedAt: string
relation?: MemoryRelation | null
updatesMemoryId?: string | null
nextVersionId?: string | null
memoryRelations?: Record<string, MemoryRelation> | null
spaceContainerTag?: string | null
}
interface ApiDocument {
id: string
title: string | null
summary?: string | null
type: string
createdAt: string
updatedAt: string
memoryEntries: ApiMemoryEntry[]
}
interface ApiDocumentsResponse {
documents: ApiDocument[]
pagination: {
currentPage: number
limit: number
totalItems: number
totalPages: number
}
}
function toGraphMemory(mem: ApiMemoryEntry): GraphApiMemory {
return {
id: mem.id,
memory: mem.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,
relation: mem.relation ?? null,
updatesMemoryId: mem.updatesMemoryId ?? null,
nextVersionId: mem.nextVersionId ?? null,
memoryRelations: mem.memoryRelations ?? null,
spaceContainerTag: mem.spaceContainerTag ?? null,
}
}
function toGraphDocument(doc: ApiDocument): GraphApiDocument {
return {
id: doc.id,
title: doc.title,
summary: doc.summary ?? null,
documentType: doc.type,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
memories: doc.memoryEntries.map(toGraphMemory),
}
}
export function useGraphApi(options: UseGraphApiOptions = {}) {
const { containerTags, documentIds, limit = 200, enabled = true } = options
const { containerTags, enabled = true } = options
const queryClient = useQueryClient()
const [viewport, setViewport] = useState<ViewportParams>({
minX: 0,
maxX: 1000,
minY: 0,
maxY: 1000,
})
// Debounce viewport changes
const viewportTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const pendingViewportRef = useRef<ViewportParams | null>(null)
const updateViewport = useCallback((newViewport: ViewportParams) => {
pendingViewportRef.current = newViewport
if (viewportTimeoutRef.current) {
clearTimeout(viewportTimeoutRef.current)
}
viewportTimeoutRef.current = setTimeout(() => {
if (pendingViewportRef.current) {
setViewport(pendingViewportRef.current)
pendingViewportRef.current = null
}
}, 150)
}, [])
useEffect(() => {
return () => {
if (viewportTimeoutRef.current) {
clearTimeout(viewportTimeoutRef.current)
}
}
}, [])
const boundsQuery = useQuery({
queryKey: ["graph-bounds", containerTags?.join(",")],
queryFn: async (): Promise<GraphBoundsResponse> => {
const params = new URLSearchParams()
if (containerTags?.length) {
params.set("containerTags", JSON.stringify(containerTags))
}
const response = await $fetch("@get/graph/bounds", {
query: Object.fromEntries(params),
disableValidation: true,
})
if (response.error) {
throw new Error(
response.error?.message || "Failed to fetch graph bounds",
)
}
return response.data as GraphBoundsResponse
},
staleTime: 5 * 60 * 1000,
enabled,
})
const statsQuery = useQuery({
queryKey: ["graph-stats", containerTags?.join(",")],
queryFn: async (): Promise<GraphStatsResponse> => {
const params = new URLSearchParams()
if (containerTags?.length) {
params.set("containerTags", JSON.stringify(containerTags))
}
const response = await $fetch("@get/graph/stats", {
query: Object.fromEntries(params),
disableValidation: true,
})
if (response.error) {
throw new Error(
response.error?.message || "Failed to fetch graph stats",
)
}
return response.data as GraphStatsResponse
},
staleTime: 5 * 60 * 1000,
enabled,
})
const viewportQuery = useQuery({
queryKey: [
"graph-viewport",
viewport.minX,
viewport.maxX,
viewport.minY,
viewport.maxY,
containerTags?.join(","),
documentIds?.join(","),
limit,
],
queryFn: async (): Promise<GraphViewportResponse> => {
const response = await $fetch("@post/graph/viewport", {
const {
data,
error,
isPending,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
} = useInfiniteQuery<ApiDocumentsResponse, Error>({
queryKey: ["graph-documents", containerTags?.join(",")],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
const response = await $fetch("@post/documents/documents", {
body: {
viewport: {
minX: viewport.minX,
maxX: viewport.maxX,
minY: viewport.minY,
maxY: viewport.maxY,
},
page: pageParam as number,
limit: PAGE_SIZE,
sort: "createdAt",
order: "desc",
containerTags,
documentIds,
limit,
},
disableValidation: true,
})
if (response.error) {
throw new Error(
response.error?.message || "Failed to fetch graph viewport",
)
throw new Error(response.error?.message || "Failed to fetch documents")
}
return response.data as GraphViewportResponse
return response.data as unknown as ApiDocumentsResponse
},
getNextPageParam: (lastPage) => {
const { currentPage, totalPages } = lastPage.pagination
if (currentPage < totalPages) {
return currentPage + 1
}
return undefined
},
staleTime: 30 * 1000,
enabled,
})
// Prefetch adjacent viewports for smoother panning
const prefetchAdjacentViewports = useCallback(
(currentViewport: ViewportParams) => {
const viewportWidth = currentViewport.maxX - currentViewport.minX
const viewportHeight = currentViewport.maxY - currentViewport.minY
const documents = useMemo(() => {
if (!data?.pages) return []
return data.pages.flatMap((page) => page.documents.map(toGraphDocument))
}, [data])
const offsets = [
{ dx: viewportWidth * 0.5, dy: 0 },
{ dx: -viewportWidth * 0.5, dy: 0 },
{ dx: 0, dy: viewportHeight * 0.5 },
{ dx: 0, dy: -viewportHeight * 0.5 },
]
offsets.forEach(({ dx, dy }) => {
const prefetchViewport = {
minX: Math.max(0, currentViewport.minX + dx),
maxX: Math.max(0, currentViewport.maxX + dx),
minY: Math.max(0, currentViewport.minY + dy),
maxY: Math.max(0, currentViewport.maxY + dy),
}
queryClient.prefetchQuery({
queryKey: [
"graph-viewport",
prefetchViewport.minX,
prefetchViewport.maxX,
prefetchViewport.minY,
prefetchViewport.maxY,
containerTags?.join(","),
limit,
],
queryFn: async () => {
const response = await $fetch("@post/graph/viewport", {
body: {
viewport: prefetchViewport,
containerTags,
limit,
},
disableValidation: true,
})
if (response.error) {
throw new Error(
response.error?.message || "Failed to fetch graph viewport",
)
}
return response.data
},
staleTime: 30 * 1000,
})
})
},
[queryClient, containerTags, limit],
)
const data = useMemo(() => {
return {
documents: viewportQuery.data?.documents ?? [],
edges: viewportQuery.data?.edges ?? [],
totalCount: viewportQuery.data?.totalCount ?? 0,
bounds: boundsQuery.data?.bounds ?? null,
stats: statsQuery.data ?? null,
}
}, [viewportQuery.data, boundsQuery.data, statsQuery.data])
const isLoading = viewportQuery.isPending || boundsQuery.isPending
const isRefetching = viewportQuery.isRefetching
const error =
viewportQuery.error || boundsQuery.error || statsQuery.error || null
const totalCount = data?.pages[0]?.pagination.totalItems ?? 0
return {
data,
isLoading,
isRefetching,
error,
viewport,
updateViewport,
prefetchAdjacentViewports,
refetch: viewportQuery.refetch,
}
}
/**
* Scales backend coordinates (0-1000) to graph canvas coordinates
*/
export function scaleBackendToCanvas(
x: number,
y: number,
canvasWidth: number,
canvasHeight: number,
): { x: number; y: number } {
const scale = Math.min(canvasWidth, canvasHeight) / 1000
const offsetX = (canvasWidth - 1000 * scale) / 2
const offsetY = (canvasHeight - 1000 * scale) / 2
return {
x: x * scale + offsetX,
y: y * scale + offsetY,
}
}
/**
* Scales canvas coordinates to backend coordinates (0-1000)
*/
export function scaleCanvasToBackend(
x: number,
y: number,
canvasWidth: number,
canvasHeight: number,
): { x: number; y: number } {
const scale = Math.min(canvasWidth, canvasHeight) / 1000
const offsetX = (canvasWidth - 1000 * scale) / 2
const offsetY = (canvasHeight - 1000 * scale) / 2
return {
x: (x - offsetX) / scale,
y: (y - offsetY) / scale,
documents,
isLoading: isPending,
isLoadingMore: isFetchingNextPage,
error: error ?? null,
hasMore: hasNextPage ?? false,
loadMore: fetchNextPage,
totalCount,
}
}

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,18 @@
// 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,80 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph"
import { useGraphApi } from "./hooks/use-graph-api"
export interface MemoryGraphWrapperProps {
children?: React.ReactNode
isLoading?: boolean
error?: Error | null
variant?: "console" | "consumer"
legendId?: string
highlightDocumentIds?: string[]
highlightsVisible?: boolean
containerTags?: string[]
documentIds?: string[]
maxNodes?: number
isSlideshowActive?: boolean
onSlideshowNodeChange?: (nodeId: string | null) => void
onSlideshowStop?: () => void
canvasRef?: React.RefObject<HTMLCanvasElement | null>
}
export function MemoryGraph({
children,
isLoading: externalIsLoading = false,
error: externalError = null,
variant = "console",
containerTags,
maxNodes = 200,
canvasRef,
...rest
}: MemoryGraphWrapperProps) {
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
const containerRef = useRef<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 {
documents,
isLoading: apiIsLoading,
isLoadingMore,
error: apiError,
hasMore,
loadMore,
totalCount,
} = useGraphApi({
containerTags,
enabled: containerSize.width > 0 && containerSize.height > 0,
})
return (
<div ref={containerRef} className="w-full h-full">
<MemoryGraphBase
documents={documents}
isLoading={externalIsLoading || apiIsLoading}
isLoadingMore={isLoadingMore}
onLoadMore={hasMore ? () => loadMore() : undefined}
hasMore={hasMore}
error={externalError || apiError}
variant={variant}
maxNodes={maxNodes}
canvasRef={canvasRef}
totalCount={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

@ -6,6 +6,7 @@ const nextConfig: NextConfig = {
ignoreBuildErrors: true,
},
transpilePackages: [
"@supermemory/memory-graph",
"@tiptap/core",
"@tiptap/react",
"@tiptap/pm",

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,26 +295,18 @@
},
"packages/memory-graph": {
"name": "@supermemory/memory-graph",
"version": "0.1.8",
"version": "0.2.0",
"dependencies": {
"@emotion/is-prop-valid": "^1.4.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-slot": "^1.2.4",
"@vanilla-extract/css": "^1.17.4",
"@vanilla-extract/recipes": "^0.5.7",
"@vanilla-extract/sprinkles": "^1.6.5",
"d3-force": "^3.0.0",
"lucide-react": "^0.552.0",
"motion": "^12.23.24",
},
"devDependencies": {
"@types/d3-force": "^3.0.10",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vanilla-extract/vite-plugin": "^5.1.1",
"@vitejs/plugin-react": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^7.2.1",
"vitest": "^3.2.4",
},
"peerDependencies": {
"react": ">=18.0.0",
@ -2059,22 +2052,12 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vanilla-extract/babel-plugin-debug-ids": ["@vanilla-extract/babel-plugin-debug-ids@1.2.2", "", { "dependencies": { "@babel/core": "^7.23.9" } }, "sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw=="],
"@vanilla-extract/compiler": ["@vanilla-extract/compiler@0.3.4", "", { "dependencies": { "@vanilla-extract/css": "^1.18.0", "@vanilla-extract/integration": "^8.0.7", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", "vite-node": "^3.2.2" } }, "sha512-W9HXf9EAccpE1vEIATvSoBVj/bQnmHfYHfDJjUN8dcOHW6oMcnoGTqweDM9I66BHqlNH4d0IsaeZKSViOv7K4w=="],
"@vanilla-extract/css": ["@vanilla-extract/css@1.18.0", "", { "dependencies": { "@emotion/hash": "^0.9.0", "@vanilla-extract/private": "^1.0.9", "css-what": "^6.1.0", "cssesc": "^3.0.0", "csstype": "^3.2.3", "dedent": "^1.5.3", "deep-object-diff": "^1.1.9", "deepmerge": "^4.2.2", "lru-cache": "^10.4.3", "media-query-parser": "^2.0.2", "modern-ahocorasick": "^1.0.0", "picocolors": "^1.0.0" } }, "sha512-/p0dwOjr0o8gE5BRQ5O9P0u/2DjUd6Zfga2JGmE4KaY7ZITWMszTzk4x4CPlM5cKkRr2ZGzbE6XkuPNfp9shSQ=="],
"@vanilla-extract/integration": ["@vanilla-extract/integration@8.0.7", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/plugin-syntax-typescript": "^7.23.3", "@vanilla-extract/babel-plugin-debug-ids": "^1.2.2", "@vanilla-extract/css": "^1.18.0", "dedent": "^1.5.3", "esbuild": "npm:esbuild@>=0.17.6 <0.28.0", "eval": "0.1.8", "find-up": "^5.0.0", "javascript-stringify": "^2.0.1", "mlly": "^1.4.2" } }, "sha512-ILob4F9cEHXpbWAVt3Y2iaQJpqYq/c/5TJC8Fz58C2XmX3QW2Y589krvViiyJhQfydCGK3EbwPQhVFjQaBeKfg=="],
"@vanilla-extract/private": ["@vanilla-extract/private@1.0.9", "", {}, "sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA=="],
"@vanilla-extract/recipes": ["@vanilla-extract/recipes@0.5.7", "", { "peerDependencies": { "@vanilla-extract/css": "^1.0.0" } }, "sha512-Fvr+htdyb6LVUu+PhH61UFPhwkjgDEk8L4Zq9oIdte42sntpKrgFy90MyTRtGwjVALmrJ0pwRUVr8UoByYeW8A=="],
"@vanilla-extract/sprinkles": ["@vanilla-extract/sprinkles@1.6.5", "", { "peerDependencies": { "@vanilla-extract/css": "^1.0.0" } }, "sha512-HOYidLONR/SeGk8NBAeI64I4gYdsMX9vJmniL13ZcLVwawyK0s2GUENEAcGA+GYLIoeyQB61UqmhqPodJry7zA=="],
"@vanilla-extract/vite-plugin": ["@vanilla-extract/vite-plugin@5.1.4", "", { "dependencies": { "@vanilla-extract/compiler": "^0.3.4", "@vanilla-extract/integration": "^8.0.7" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-fTYNKUK3n4ApkUf2FEcO7mpqNKEHf9kDGg8DXlkqHtPxgwPhjuaajmDfQCSBsNgnA2SLI+CB5EO6kLQuKsw2Rw=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="],
@ -2829,8 +2812,6 @@
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eval": ["eval@0.1.8", "", { "dependencies": { "@types/node": "*", "require-like": ">= 0.1.1" } }, "sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw=="],
"event-target-polyfill": ["event-target-polyfill@0.0.4", "", {}, "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
@ -3299,8 +3280,6 @@
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"javascript-stringify": ["javascript-stringify@2.1.0", "", {}, "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg=="],
"jest-worker": ["jest-worker@27.5.1", "", { "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", "supports-color": "^8.0.0" } }, "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
@ -4177,8 +4156,6 @@
"require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="],
"require-like": ["require-like@0.1.2", "", {}, "sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A=="],
"resend": ["resend@4.8.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
@ -5169,8 +5146,6 @@
"@supermemory/ai-sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@supermemory/memory-graph/lucide-react": ["lucide-react@0.552.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-g9WCjmfwqbexSnZE+2cl21PCfXOcqnGeWeMTNAOGEfpPbm/ZF4YIq77Z8qWrxbu660EKuLB4nSLggoKnCb+isw=="],
"@supermemory/memory-graph/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"@supermemory/tools/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.70", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W3WjQlb0Ho+CVAQUvb8Rtk3hGS3Jlgy79ihY2H0yj2k4yU8XuxpQw0Oz+7JQsB47j+jlHhk7nUXtxhAeRg3S3Q=="],
@ -5199,8 +5174,6 @@
"@vanilla-extract/css/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@vanilla-extract/integration/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
@ -6043,58 +6016,6 @@
"@supermemory/tools/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="],
"@vanilla-extract/integration/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"@vanilla-extract/integration/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
"@vanilla-extract/integration/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
"@vanilla-extract/integration/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
"@vanilla-extract/integration/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
"@vanilla-extract/integration/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
"@vanilla-extract/integration/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
"@vanilla-extract/integration/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
"@vanilla-extract/integration/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
"@vanilla-extract/integration/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
"@vanilla-extract/integration/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
"@vanilla-extract/integration/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
"@vanilla-extract/integration/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
"@vanilla-extract/integration/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
"@vanilla-extract/integration/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
"@vanilla-extract/integration/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
"@vanilla-extract/integration/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
"@vanilla-extract/integration/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
"@vanilla-extract/integration/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
"@vanilla-extract/integration/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
"@vanilla-extract/integration/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
"@vanilla-extract/integration/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
"@vanilla-extract/integration/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
"@vanilla-extract/integration/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
"@vanilla-extract/integration/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
"@vanilla-extract/integration/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"agents/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"agents/@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],

View file

@ -1,37 +1,27 @@
{
"name": "@supermemory/memory-graph",
"version": "0.1.8",
"version": "0.2.0",
"description": "Interactive graph visualization component for Supermemory - visualize and explore your memory connections",
"type": "module",
"main": "./dist/memory-graph.cjs",
"module": "./dist/memory-graph.js",
"types": "./dist/index.d.ts",
"main": "./src/index.tsx",
"module": "./src/index.tsx",
"types": "./src/index.tsx",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/memory-graph.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/memory-graph.cjs"
}
},
"./styles.css": "./dist/memory-graph.css",
".": "./src/index.tsx",
"./mock-data": "./src/mock-data.ts",
"./package.json": "./package.json"
},
"files": [
"dist",
"README.md"
],
"sideEffects": [
"**/*.css"
],
"scripts": {
"dev": "vite build --watch",
"build": "vite build && tsc --emitDeclarationOnly",
"check-types": "tsc --noEmit",
"prepublishOnly": "bun run build"
"prepack": "bun run build && bun run scripts/swap-exports.ts pack",
"postpack": "bun run scripts/swap-exports.ts unpack",
"test": "vitest run"
},
"keywords": [
"supermemory",
@ -59,23 +49,15 @@
"react-dom": ">=18.0.0"
},
"dependencies": {
"@emotion/is-prop-valid": "^1.4.0",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-slot": "^1.2.4",
"@vanilla-extract/css": "^1.17.4",
"@vanilla-extract/recipes": "^0.5.7",
"@vanilla-extract/sprinkles": "^1.6.5",
"d3-force": "^3.0.0",
"lucide-react": "^0.552.0",
"motion": "^12.23.24"
"d3-force": "^3.0.0"
},
"devDependencies": {
"@types/d3-force": "^3.0.10",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vanilla-extract/vite-plugin": "^5.1.1",
"@vitejs/plugin-react": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^7.2.1"
"vite": "^7.2.1",
"vitest": "^3.2.4"
}
}

View file

@ -0,0 +1,50 @@
/**
* Swaps package.json exports between source (for workspace/monorepo use)
* and dist (for npm publishing).
*
* Usage:
* bun run scripts/swap-exports.ts pack # switch to dist exports
* bun run scripts/swap-exports.ts unpack # switch back to source exports
*/
import { readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path"
const pkgPath = join(import.meta.dirname, "..", "package.json")
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"))
const mode = process.argv[2]
if (mode === "pack") {
// Switch to dist exports for npm publishing
pkg.main = "./dist/memory-graph.cjs"
pkg.module = "./dist/memory-graph.js"
pkg.types = "./dist/index.d.ts"
pkg.exports = {
".": {
types: "./dist/index.d.ts",
import: "./dist/memory-graph.js",
require: "./dist/memory-graph.cjs",
},
"./mock-data": {
types: "./dist/mock-data.d.ts",
import: "./dist/mock-data.js",
},
"./package.json": "./package.json",
}
} else if (mode === "unpack") {
// Switch back to source exports for workspace use
pkg.main = "./src/index.tsx"
pkg.module = "./src/index.tsx"
pkg.types = "./src/index.tsx"
pkg.exports = {
".": "./src/index.tsx",
"./mock-data": "./src/mock-data.ts",
"./package.json": "./package.json",
}
} else {
console.error("Usage: bun run scripts/swap-exports.ts [pack|unpack]")
process.exit(1)
}
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
console.log(`Exports swapped to ${mode === "pack" ? "dist" : "source"} mode`)

View file

@ -0,0 +1,655 @@
/**
* Adversarial tests for the three core changes in graph-perf-consolidation.
*
* Focuses on the exact logic that was changed, NOT on surrounding scaffolding:
*
* (1) use-graph-data.ts edges useMemo now uses memoryRelations Record as
* primary source, falls back to parentMemoryId for legacy data.
* The old code expected an ARRAY [{relationType, targetMemoryId}].
* The new code expects a Record<targetId, relationType>.
*
* (2) MCP mcp-app.ts transformData now pre-populates nodeIds before edge
* computation, fixing a forward-reference bug where edges to memories
* that appeared later in the iteration order were silently dropped.
*
* (3) Invalid relationType values now default to "updates" instead of blowing up.
*
* The edges useMemo is a pure function of `documents` no React hook machinery
* is needed. We extract the identical logic here and test it directly.
*/
import { describe, it, expect } from "vitest"
import type { GraphApiDocument, GraphApiMemory } from "../types"
import { getEdgeVisualProps } from "../hooks/use-graph-data"
// ---------------------------------------------------------------------------
// Pure extraction of edges useMemo from use-graph-data.ts
// This is a verbatim copy of the logic so a regression in the source will
// cause this test to diverge — but more importantly, we can verify the exact
// semantics match the spec described in the commit messages.
// ---------------------------------------------------------------------------
interface ComputedEdge {
id: string
source: string
target: string
edgeType: string
}
function computeEdges(documents: GraphApiDocument[]): ComputedEdge[] {
if (!documents || documents.length === 0) return []
const result: ComputedEdge[] = []
// Pre-populate all node IDs (the key step from the forward-reference fix)
const allNodeIds = new Set<string>()
for (const doc of documents) {
allNodeIds.add(doc.id)
for (const mem of doc.memories) allNodeIds.add(mem.id)
}
// 1. Derives edges: document -> memory (structural)
for (const doc of documents) {
for (const mem of doc.memories) {
result.push({
id: `dm-${doc.id}-${mem.id}`,
source: doc.id,
target: mem.id,
edgeType: "derives",
})
}
}
// 2. Memory-to-memory relation edges from backend data.
// Uses memoryRelations Record<targetId, relationType> as primary source,
// falls back to parentMemoryId for legacy data.
for (const doc of documents) {
for (const mem of doc.memories) {
let relations: Record<string, string> = {}
if (
mem.memoryRelations &&
typeof mem.memoryRelations === "object" &&
Object.keys(mem.memoryRelations).length > 0
) {
relations = mem.memoryRelations
} else if (mem.parentMemoryId) {
// Legacy fallback: parentMemoryId implies "updates"
relations = { [mem.parentMemoryId]: "updates" }
}
for (const [targetId, relationType] of Object.entries(relations)) {
if (!allNodeIds.has(targetId)) continue
const edgeType =
relationType === "updates" ||
relationType === "extends" ||
relationType === "derives"
? relationType
: "updates"
result.push({
id: `rel-${targetId}-${mem.id}`,
source: targetId,
target: mem.id,
edgeType,
})
}
}
}
return result
}
// ---------------------------------------------------------------------------
// MCP transformData logic — pure extraction from mcp-app.ts transformData().
// Includes the forward-reference fix (nodeIds pre-populated before edges).
// ---------------------------------------------------------------------------
interface McpLink {
source: string
target: string
edgeType: "derives" | "updates" | "extends"
}
function mcpComputeLinks(
documents: Array<{
id: string
memories: Array<{
id: string
parentMemoryId: string | null
memoryRelations?: Record<string, string> | null
}>
}>,
): McpLink[] {
const links: McpLink[] = []
// Pre-populate all node IDs (the forward-reference fix)
const nodeIds = new Set<string>()
for (const doc of documents) {
nodeIds.add(doc.id)
for (const mem of doc.memories) nodeIds.add(mem.id)
}
for (const doc of documents) {
for (const mem of doc.memories) {
// Derives link (doc -> memory)
links.push({ source: doc.id, target: mem.id, edgeType: "derives" })
let relations: Record<string, string> = {}
if (
mem.memoryRelations &&
typeof mem.memoryRelations === "object" &&
Object.keys(mem.memoryRelations).length > 0
) {
relations = mem.memoryRelations
} else if (mem.parentMemoryId) {
relations = { [mem.parentMemoryId]: "updates" }
}
for (const [targetId, relationType] of Object.entries(relations)) {
if (!nodeIds.has(targetId)) continue
const edgeType =
relationType === "updates" ||
relationType === "extends" ||
relationType === "derives"
? relationType
: "updates"
links.push({ source: targetId, target: mem.id, edgeType })
}
}
}
return links
}
/** The BUGGY version of mcpComputeLinks: nodeIds populated lazily (per-memory)
* rather than upfront. Used to prove the regression test would catch the bug. */
function mcpComputeLinks_BUGGY(
documents: Array<{
id: string
memories: Array<{
id: string
parentMemoryId: string | null
memoryRelations?: Record<string, string> | null
}>
}>,
): McpLink[] {
const links: McpLink[] = []
const nodeIds = new Set<string>() // NOT pre-populated — reproduces the old bug
for (const doc of documents) {
nodeIds.add(doc.id)
for (const mem of doc.memories) {
nodeIds.add(mem.id) // only added as we reach this memory
links.push({ source: doc.id, target: mem.id, edgeType: "derives" })
let relations: Record<string, string> = {}
if (
mem.memoryRelations &&
typeof mem.memoryRelations === "object" &&
Object.keys(mem.memoryRelations).length > 0
) {
relations = mem.memoryRelations
} else if (mem.parentMemoryId) {
relations = { [mem.parentMemoryId]: "updates" }
}
for (const [targetId, relationType] of Object.entries(relations)) {
if (!nodeIds.has(targetId)) continue // forward refs silently dropped here!
const edgeType =
relationType === "updates" ||
relationType === "extends" ||
relationType === "derives"
? relationType
: "updates"
links.push({ source: targetId, target: mem.id, edgeType })
}
}
}
return links
}
// ---------------------------------------------------------------------------
// Helper factories
// ---------------------------------------------------------------------------
function makeMem(
overrides: Partial<GraphApiMemory> & { id: string },
): GraphApiMemory {
return {
memory: `Memory ${overrides.id}`,
isStatic: false,
spaceId: "default",
isLatest: true,
isForgotten: false,
forgetAfter: null,
forgetReason: null,
version: 1,
parentMemoryId: null,
rootMemoryId: null,
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
...overrides,
}
}
function makeDoc(id: string, memories: GraphApiMemory[]): GraphApiDocument {
return {
id,
title: `Doc ${id}`,
summary: null,
documentType: "text",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
memories,
}
}
// ===========================================================================
// (1) memoryRelations Record as PRIMARY source
// ===========================================================================
describe("use-graph-data edges: memoryRelations Record as primary source", () => {
it("creates an extends edge from memoryRelations { targetId: 'extends' }", () => {
const docs = [
makeDoc("d1", [
makeMem({ id: "m1" }),
makeMem({ id: "m2", memoryRelations: { m1: "extends" } }),
]),
]
const edges = computeEdges(docs)
const relEdge = edges.find((e) => e.source === "m1" && e.target === "m2")
expect(relEdge).toBeDefined()
expect(relEdge?.edgeType).toBe("extends")
})
it("creates an updates edge from memoryRelations { targetId: 'updates' }", () => {
const docs = [
makeDoc("d1", [
makeMem({ id: "m1" }),
makeMem({ id: "m2", memoryRelations: { m1: "updates" } }),
]),
]
const edges = computeEdges(docs)
const relEdge = edges.find((e) => e.source === "m1" && e.target === "m2")
expect(relEdge).toBeDefined()
expect(relEdge?.edgeType).toBe("updates")
})
it("creates a derives edge from memoryRelations { targetId: 'derives' }", () => {
const docs = [
makeDoc("d1", [
makeMem({ id: "m1" }),
makeMem({ id: "m2", memoryRelations: { m1: "derives" } }),
]),
]
const edges = computeEdges(docs)
const relEdge = edges.find(
(e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"),
)
expect(relEdge).toBeDefined()
expect(relEdge?.edgeType).toBe("derives")
})
it("creates multiple edges when memoryRelations has multiple targets", () => {
const docs = [
makeDoc("d1", [
makeMem({ id: "m1" }),
makeMem({ id: "m2" }),
makeMem({
id: "m3",
memoryRelations: { m1: "updates", m2: "extends" },
}),
]),
]
const edges = computeEdges(docs)
const relEdgesToM3 = edges.filter(
(e) => e.target === "m3" && e.id.startsWith("rel-"),
)
expect(relEdgesToM3.length).toBe(2)
const types = new Set(relEdgesToM3.map((e) => e.edgeType))
expect(types.has("updates")).toBe(true)
expect(types.has("extends")).toBe(true)
})
})
// ===========================================================================
// (2) memoryRelations TAKES PRECEDENCE over parentMemoryId
// ===========================================================================
describe("use-graph-data edges: memoryRelations wins over parentMemoryId", () => {
/**
* Critical regression test: if a memory has BOTH memoryRelations and
* parentMemoryId, only memoryRelations should be used. The fallback
* parentMemoryId path must NOT fire when memoryRelations is present.
*/
it("uses extends from memoryRelations, ignores parentMemoryId updates fallback", () => {
const docs = [
makeDoc("d1", [
makeMem({ id: "mParent" }),
makeMem({ id: "mOther" }),
makeMem({
id: "m3",
// parentMemoryId would imply an "updates" edge to mParent
parentMemoryId: "mParent",
// memoryRelations says extends to mOther — this should win
memoryRelations: { mOther: "extends" },
}),
]),
]
const edges = computeEdges(docs)
const relEdges = edges.filter(
(e) => e.id.startsWith("rel-") && e.target === "m3",
)
// Should be exactly ONE rel edge (extends to mOther), NOT two
expect(relEdges.length).toBe(1)
expect(relEdges[0]?.edgeType).toBe("extends")
expect(relEdges[0]?.source).toBe("mOther")
// The parentMemoryId-implied updates edge to mParent must NOT exist
const badEdge = relEdges.find((e) => e.source === "mParent")
expect(badEdge).toBeUndefined()
})
})
// ===========================================================================
// (3) parentMemoryId FALLBACK when memoryRelations absent/null/empty
// ===========================================================================
describe("use-graph-data edges: parentMemoryId fallback", () => {
it("creates updates edge from parentMemoryId when memoryRelations is absent", () => {
const docs = [
makeDoc("d1", [
makeMem({ id: "m1" }),
makeMem({ id: "m2", parentMemoryId: "m1" }),
]),
]
const edges = computeEdges(docs)
const relEdge = edges.find(
(e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"),
)
expect(relEdge).toBeDefined()
expect(relEdge?.edgeType).toBe("updates")
})
it("creates updates edge from parentMemoryId when memoryRelations is null", () => {
const docs = [
makeDoc("d1", [
makeMem({ id: "m1" }),
makeMem({ id: "m2", parentMemoryId: "m1", memoryRelations: null }),
]),
]
const edges = computeEdges(docs)
const relEdge = edges.find(
(e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"),
)
expect(relEdge).toBeDefined()
expect(relEdge?.edgeType).toBe("updates")
})
it("creates updates edge from parentMemoryId when memoryRelations is empty {}", () => {
const docs = [
makeDoc("d1", [
makeMem({ id: "m1" }),
makeMem({ id: "m2", parentMemoryId: "m1", memoryRelations: {} }),
]),
]
const edges = computeEdges(docs)
const relEdge = edges.find(
(e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"),
)
expect(relEdge).toBeDefined()
expect(relEdge?.edgeType).toBe("updates")
})
it("drops the parentMemoryId edge when the parent is not in the dataset", () => {
const docs = [
makeDoc("d1", [
makeMem({ id: "m1", parentMemoryId: "ghost_id_not_in_docs" }),
]),
]
const edges = computeEdges(docs)
const ghostEdges = edges.filter((e) => e.source === "ghost_id_not_in_docs")
expect(ghostEdges.length).toBe(0)
})
})
// ===========================================================================
// (4) Invalid relationType defaults to "updates"
// ===========================================================================
describe("use-graph-data edges: invalid relationType defaults to updates", () => {
it("maps an unknown relationType string to 'updates' edge type", () => {
const docs = [
makeDoc("d1", [
makeMem({ id: "m1" }),
makeMem({
id: "m2",
// biome-ignore lint/suspicious/noExplicitAny: intentional bad-data test
memoryRelations: { m1: "totally-bogus-type" as any },
}),
]),
]
const edges = computeEdges(docs)
const relEdge = edges.find(
(e) => e.source === "m1" && e.target === "m2" && e.id.startsWith("rel-"),
)
expect(relEdge).toBeDefined()
expect(relEdge?.edgeType).toBe("updates")
})
})
// ===========================================================================
// (5) MCP forward-reference fix — THE CRITICAL BUG
// ===========================================================================
describe("MCP transformData: forward-reference fix (pre-populated nodeIds)", () => {
/**
* SETUP: Two memories in the same document. m1 is processed first.
* m1 has memoryRelations pointing at m2, which appears AFTER m1.
*
* OLD BUG: nodeIds was built lazily when m1 was processed, m2 had not
* yet been added to nodeIds, so `!nodeIds.has("m2")` was true and the
* edge was silently skipped.
*
* FIX: All node IDs are pre-populated before any edge is evaluated, so
* m2 is always in nodeIds when m1's relations are checked.
*/
it("creates edge for forward-referenced memory within same document", () => {
const docs = [
{
id: "d1",
memories: [
// m1 comes FIRST and references m2 which comes SECOND
{
id: "m1",
parentMemoryId: null,
memoryRelations: { m2: "extends" },
},
{ id: "m2", parentMemoryId: null, memoryRelations: null },
],
},
]
// Fixed version creates the edge
const fixedLinks = mcpComputeLinks(docs)
const edge = fixedLinks.find((l) => l.source === "m2" && l.target === "m1")
expect(edge).toBeDefined()
expect(edge?.edgeType).toBe("extends")
})
it("creates edge for forward-referenced memory in a LATER document", () => {
// m1 in doc1 references m2 in doc2. m2 is only encountered during
// doc2's iteration, AFTER m1's relations are evaluated.
const docs = [
{
id: "d1",
memories: [
{
id: "m1",
parentMemoryId: null,
memoryRelations: { m2: "updates" },
},
],
},
{
id: "d2",
memories: [{ id: "m2", parentMemoryId: null, memoryRelations: null }],
},
]
const fixedLinks = mcpComputeLinks(docs)
const edge = fixedLinks.find((l) => l.source === "m2" && l.target === "m1")
expect(edge).toBeDefined()
expect(edge?.edgeType).toBe("updates")
})
/**
* REGRESSION PROOF: Run the same scenario through the BUGGY implementation.
* The buggy version MUST drop the edge. If this test fails (i.e. buggy code
* creates the edge too), the forward-reference scenario doesn't actually
* demonstrate the bug, and our fix tests prove nothing.
*/
it("proves the bug: lazy nodeIds population drops forward-referenced edges", () => {
const docs = [
{
id: "d1",
memories: [
{
id: "m1",
parentMemoryId: null,
memoryRelations: { m2: "extends" },
},
{ id: "m2", parentMemoryId: null, memoryRelations: null },
],
},
]
// The buggy version MUST drop the m1->m2 edge (forward reference)
const buggyLinks = mcpComputeLinks_BUGGY(docs)
const buggyEdge = buggyLinks.find(
(l) => l.source === "m2" && l.target === "m1",
)
expect(buggyEdge).toBeUndefined() // confirms the bug was real
// The fixed version MUST create it
const fixedLinks = mcpComputeLinks(docs)
const fixedEdge = fixedLinks.find(
(l) => l.source === "m2" && l.target === "m1",
)
expect(fixedEdge).toBeDefined() // confirms the fix works
})
it("proves the bug: cross-document forward reference also dropped by lazy code", () => {
const docs = [
{
id: "d1",
memories: [
{
id: "m1",
parentMemoryId: null,
memoryRelations: { m2: "updates" },
},
],
},
{
id: "d2",
memories: [{ id: "m2", parentMemoryId: null, memoryRelations: null }],
},
]
// Buggy: m2 is not in nodeIds when m1 is processed — edge dropped
const buggyLinks = mcpComputeLinks_BUGGY(docs)
expect(
buggyLinks.find((l) => l.source === "m2" && l.target === "m1"),
).toBeUndefined()
// Fixed: m2 is pre-populated — edge created
const fixedLinks = mcpComputeLinks(docs)
expect(
fixedLinks.find((l) => l.source === "m2" && l.target === "m1"),
).toBeDefined()
})
})
// ===========================================================================
// (6) Structural correctness: derives edges always created, doc->mem
// ===========================================================================
describe("use-graph-data edges: derives edges always present for all doc->mem pairs", () => {
it("creates exactly one derives edge per memory across multiple documents", () => {
const docs = [
makeDoc("d1", [makeMem({ id: "m1" }), makeMem({ id: "m2" })]),
makeDoc("d2", [
makeMem({ id: "m3" }),
makeMem({ id: "m4" }),
makeMem({ id: "m5" }),
]),
]
const edges = computeEdges(docs)
const derivesEdges = edges.filter((e) => e.edgeType === "derives")
// 2 memories in d1 + 3 in d2 = 5 derives edges
expect(derivesEdges.length).toBe(5)
expect(derivesEdges.map((e) => e.target).sort()).toEqual([
"m1",
"m2",
"m3",
"m4",
"m5",
])
})
it("returns empty array for empty documents input", () => {
expect(computeEdges([])).toEqual([])
})
it("creates no relation edges when all memories are standalone", () => {
const docs = [
makeDoc("d1", [
makeMem({ id: "m1" }), // no parentMemoryId, no memoryRelations
makeMem({ id: "m2" }),
]),
]
const edges = computeEdges(docs)
const relEdges = edges.filter((e) => e.id.startsWith("rel-"))
expect(relEdges.length).toBe(0)
})
})
// ===========================================================================
// (7) getEdgeVisualProps: the MemoryRelation type is the canonical source
// ===========================================================================
describe("getEdgeVisualProps: all MemoryRelation values return valid visual props", () => {
const relations = ["updates", "extends", "derives"] as const
for (const rel of relations) {
it(`returns positive opacity and thickness for '${rel}'`, () => {
const props = getEdgeVisualProps(rel)
expect(props.opacity).toBeGreaterThan(0)
expect(props.thickness).toBeGreaterThan(0)
})
}
it("extends edges have higher opacity than derives edges (rare but meaningful)", () => {
const ext = getEdgeVisualProps("extends")
const der = getEdgeVisualProps("derives")
expect(ext.opacity).toBeGreaterThan(der.opacity)
})
it("updates edges have higher opacity than derives edges (version chains are prominent)", () => {
const upd = getEdgeVisualProps("updates")
const der = getEdgeVisualProps("derives")
expect(upd.opacity).toBeGreaterThan(der.opacity)
})
it("unknown edge type returns default props (opacity 0.4, thickness 1.2)", () => {
// The default case returns { opacity: 0.4, thickness: 1.2 }.
// A safe conservative fallback matching derives (the most common edge type).
const unknown = getEdgeVisualProps("nonexistent")
expect(unknown.opacity).toBeCloseTo(0.4)
expect(unknown.thickness).toBeCloseTo(1.2)
})
})

View file

@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest"
import {
getMemoryBorderColor,
getEdgeVisualProps,
} from "../hooks/use-graph-data"
import { DEFAULT_COLORS } from "../constants"
import type { GraphApiMemory } from "../types"
function makeMemory(overrides: Partial<GraphApiMemory> = {}): GraphApiMemory {
return {
id: "m1",
memory: "test",
isStatic: false,
spaceId: "default",
isLatest: true,
isForgotten: false,
forgetAfter: null,
forgetReason: null,
version: 1,
parentMemoryId: null,
rootMemoryId: null,
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
...overrides,
}
}
describe("getMemoryBorderColor", () => {
const colors = DEFAULT_COLORS
it("returns forgotten color for forgotten memories", () => {
const mem = makeMemory({ isForgotten: true })
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderForgotten)
})
it("returns expiring color for memories expiring within 7 days", () => {
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString()
const mem = makeMemory({ forgetAfter: soon })
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderExpiring)
})
it("returns recent color for memories created within 24 hours", () => {
const recent = new Date(Date.now() - 1000).toISOString()
const mem = makeMemory({ createdAt: recent })
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderRecent)
})
it("returns default color for normal memories", () => {
const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
const mem = makeMemory({ createdAt: old })
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memStrokeDefault)
})
it("forgotten takes priority over expiring", () => {
const soon = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString()
const mem = makeMemory({ isForgotten: true, forgetAfter: soon })
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderForgotten)
})
})
describe("getEdgeVisualProps", () => {
it("returns correct props for derives edges", () => {
const props = getEdgeVisualProps("derives")
expect(props.opacity).toBeCloseTo(0.4)
expect(props.thickness).toBeCloseTo(1.2)
})
it("returns correct props for updates edges", () => {
const props = getEdgeVisualProps("updates")
expect(props.opacity).toBeCloseTo(0.7)
expect(props.thickness).toBeCloseTo(2)
})
it("returns correct props for extends edges", () => {
const props = getEdgeVisualProps("extends")
expect(props.opacity).toBeCloseTo(0.55)
expect(props.thickness).toBeCloseTo(1.5)
})
it("returns default props for unknown edge types", () => {
const props = getEdgeVisualProps("unknown")
expect(props.opacity).toBeCloseTo(0.4)
expect(props.thickness).toBeCloseTo(1.2)
})
})

View file

@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest"
import { generateMockGraphData } from "../mock-data"
describe("generateMockGraphData", () => {
it("produces deterministic output with same seed", () => {
const data1 = generateMockGraphData({ documentCount: 10, seed: 42 })
const data2 = generateMockGraphData({ documentCount: 10, seed: 42 })
expect(data1.documents.length).toBe(data2.documents.length)
expect(data1.documents[0]!.id).toBe(data2.documents[0]!.id)
expect(data1.documents[0]!.title).toBe(data2.documents[0]!.title)
})
it("produces different output with different seeds", () => {
const data1 = generateMockGraphData({ documentCount: 10, seed: 42 })
const data2 = generateMockGraphData({ documentCount: 10, seed: 99 })
// At least some documents should differ
const titles1 = data1.documents.map((d) => d.title).join(",")
const titles2 = data2.documents.map((d) => d.title).join(",")
expect(titles1).not.toBe(titles2)
})
it("generates correct number of documents", () => {
const data = generateMockGraphData({ documentCount: 25, seed: 1 })
expect(data.documents.length).toBe(25)
})
it("documents have required fields", () => {
const data = generateMockGraphData({ documentCount: 5, seed: 1 })
for (const doc of data.documents) {
expect(doc.id).toBeDefined()
expect(doc.title).toBeDefined()
expect(doc.summary).toBeDefined()
expect(doc.documentType).toBeDefined()
expect(doc.createdAt).toBeDefined()
expect(doc.updatedAt).toBeDefined()
expect(Array.isArray(doc.memories)).toBe(true)
}
})
it("memories have required fields", () => {
const data = generateMockGraphData({ documentCount: 5, seed: 1 })
const doc = data.documents.find((d) => d.memories.length > 0)
expect(doc).toBeDefined()
for (const mem of doc!.memories) {
expect(mem.id).toBeDefined()
expect(mem.memory).toBeDefined()
expect(typeof mem.isStatic).toBe("boolean")
expect(typeof mem.isForgotten).toBe("boolean")
expect(typeof mem.isLatest).toBe("boolean")
expect(typeof mem.version).toBe("number")
expect(mem.createdAt).toBeDefined()
expect(mem.updatedAt).toBeDefined()
}
})
it("handles zero documents", () => {
const data = generateMockGraphData({ documentCount: 0, seed: 1 })
expect(data.documents.length).toBe(0)
})
it("respects memoriesPerDoc range", () => {
const data = generateMockGraphData({
documentCount: 50,
memoriesPerDoc: [3, 3],
seed: 1,
})
for (const doc of data.documents) {
expect(doc.memories.length).toBe(3)
}
})
it("generates version chains for some documents", () => {
const data = generateMockGraphData({
documentCount: 50,
memoriesPerDoc: [3, 6],
seed: 42,
})
// With 50 docs and 30% chain probability, we should have some chains
const hasChain = data.documents.some((doc) =>
doc.memories.some((mem) => mem.parentMemoryId !== null),
)
expect(hasChain).toBe(true)
})
})

View file

@ -0,0 +1,57 @@
import { describe, expect, test } from "vitest"
import { lightenColor } from "../canvas/renderer"
describe("lightenColor", () => {
test("lightens a dark hex color", () => {
// #1B1F24 lightened by 0.08 → each channel +20 (0.08*255≈20)
const result = lightenColor("#1B1F24", 0.08)
// R: 0x1B(27)+20=47=0x2f, G: 0x1F(31)+20=51=0x33, B: 0x24(36)+20=56=0x38
expect(result).toBe("#2f3338")
})
test("clamps channels at 255", () => {
// #FFFFFF lightened by 0.1 → all channels clamped at 255
const result = lightenColor("#ffffff", 0.1)
expect(result).toBe("#ffffff")
})
test("handles zero amount (no change)", () => {
const result = lightenColor("#1B1F24", 0)
expect(result).toBe("#1b1f24")
})
test("returns input unchanged for 3-digit hex", () => {
expect(lightenColor("#abc", 0.1)).toBe("#abc")
})
test("returns input unchanged for rgb() format", () => {
expect(lightenColor("rgb(27, 31, 36)", 0.1)).toBe("rgb(27, 31, 36)")
})
test("returns input unchanged for 8-digit hex with alpha", () => {
expect(lightenColor("#1B1F24FF", 0.1)).toBe("#1B1F24FF")
})
test("caches result for repeated calls", () => {
const first = lightenColor("#0D2034", 0.08)
const second = lightenColor("#0D2034", 0.08)
expect(first).toBe(second)
})
test("cache invalidates on different input", () => {
const a = lightenColor("#0D2034", 0.08)
const b = lightenColor("#1B1F24", 0.08)
expect(a).not.toBe(b)
})
test("cache invalidates on different amount", () => {
const a = lightenColor("#1B1F24", 0.05)
const b = lightenColor("#1B1F24", 0.1)
expect(a).not.toBe(b)
})
test("handles hex without # prefix", () => {
const result = lightenColor("1B1F24", 0.08)
expect(result).toBe("#2f3338")
})
})

View file

@ -0,0 +1,114 @@
import { describe, it, expect } from "vitest"
import { ForceSimulation } from "../canvas/simulation"
import type { GraphNode, GraphEdge } from "../types"
function makeNode(id: string, x: number, y: number): GraphNode {
return {
id,
type: "document",
x,
y,
size: 50,
borderColor: "#fff",
isHovered: false,
isDragging: false,
data: {
id,
title: id,
summary: null,
type: "text",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
memories: [],
},
}
}
function makeEdge(source: string, target: string): GraphEdge {
return {
id: `e-${source}-${target}`,
source,
target,
visualProps: { opacity: 0.5, thickness: 1.5 },
edgeType: "derives",
}
}
describe("ForceSimulation", () => {
it("init creates simulation and isActive returns true", () => {
const sim = new ForceSimulation()
const nodes = [makeNode("a", 0, 0), makeNode("b", 100, 100)]
const edges = [makeEdge("a", "b")]
sim.init(nodes, edges)
expect(sim.isActive()).toBe(true)
sim.destroy()
})
it("destroy stops simulation", () => {
const sim = new ForceSimulation()
const nodes = [makeNode("a", 0, 0)]
sim.init(nodes, [])
sim.destroy()
expect(sim.isActive()).toBe(false)
})
it("init moves nodes from initial positions (pre-tick)", () => {
const sim = new ForceSimulation()
const nodes = [
makeNode("a", 0, 0),
makeNode("b", 0, 0), // Same position - repulsion should move them
]
sim.init(nodes, [])
// After init with pre-ticks, nodes at same position should have moved apart
const dx = nodes[0]!.x - nodes[1]!.x
const dy = nodes[0]!.y - nodes[1]!.y
const dist = Math.sqrt(dx * dx + dy * dy)
expect(dist).toBeGreaterThan(0)
sim.destroy()
})
it("update hot-swaps nodes without full re-init", () => {
const sim = new ForceSimulation()
const nodes = [makeNode("a", 0, 0), makeNode("b", 100, 100)]
sim.init(nodes, [])
// Update with same nodes but different positions
nodes[0]!.x = 50
expect(() => sim.update(nodes, [])).not.toThrow()
expect(sim.isActive()).toBe(true)
sim.destroy()
})
it("reheat increases simulation energy", () => {
const sim = new ForceSimulation()
const nodes = [makeNode("a", 0, 0)]
sim.init(nodes, [])
// Should not throw
expect(() => sim.reheat()).not.toThrow()
sim.destroy()
})
it("coolDown reduces simulation energy", () => {
const sim = new ForceSimulation()
const nodes = [makeNode("a", 0, 0)]
sim.init(nodes, [])
expect(() => sim.coolDown()).not.toThrow()
sim.destroy()
})
it("handles empty nodes array", () => {
const sim = new ForceSimulation()
expect(() => sim.init([], [])).not.toThrow()
sim.destroy()
})
it("handles edges with missing nodes gracefully", () => {
const sim = new ForceSimulation()
const nodes = [makeNode("a", 0, 0)]
const edges = [makeEdge("a", "nonexistent")]
// Should not throw even with dangling edge
expect(() => sim.init(nodes, edges)).not.toThrow()
sim.destroy()
})
})

View file

@ -0,0 +1,132 @@
import { describe, it, expect } from "vitest"
import { SpatialIndex } from "../canvas/hit-test"
import type { GraphNode } from "../types"
function makeNode(
id: string,
x: number,
y: number,
type: "document" | "memory" = "document",
size = 50,
): GraphNode {
return {
id,
type,
x,
y,
size,
borderColor: "#fff",
isHovered: false,
isDragging: false,
data: {
id,
title: id,
summary: null,
type: "text",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
memories: [],
},
}
}
describe("SpatialIndex", () => {
it("rebuild returns true on first build", () => {
const idx = new SpatialIndex()
const result = idx.rebuild([makeNode("a", 100, 100)])
expect(result).toBe(true)
})
it("rebuild returns false when hash unchanged", () => {
const idx = new SpatialIndex()
const nodes = [makeNode("a", 100, 100)]
idx.rebuild(nodes)
const result = idx.rebuild(nodes)
expect(result).toBe(false)
})
it("rebuild returns true when positions change", () => {
const idx = new SpatialIndex()
const nodes = [makeNode("a", 100, 100)]
idx.rebuild(nodes)
nodes[0]!.x = 500
const result = idx.rebuild(nodes)
expect(result).toBe(true)
})
it("rebuild detects sub-pixel movements (10x granularity)", () => {
const idx = new SpatialIndex()
const nodes = [makeNode("a", 100.0, 100.0)]
idx.rebuild(nodes)
// Move by 0.2 pixels — should be detected with 10x rounding
nodes[0]!.x = 100.2
const result = idx.rebuild(nodes)
expect(result).toBe(true)
})
it("queryPoint finds correct node (document - square hit test)", () => {
const idx = new SpatialIndex()
const node = makeNode("a", 100, 100, "document", 50)
idx.rebuild([node])
const found = idx.queryPoint(105, 105)
expect(found).not.toBeNull()
expect(found!.id).toBe("a")
})
it("queryPoint finds correct node (memory - circle hit test)", () => {
const idx = new SpatialIndex()
const node = makeNode("m1", 200, 200, "memory", 36)
idx.rebuild([node])
// Inside the circle (radius = 18)
const found = idx.queryPoint(210, 210)
expect(found).not.toBeNull()
expect(found!.id).toBe("m1")
})
it("queryPoint returns null for empty grid", () => {
const idx = new SpatialIndex()
idx.rebuild([])
expect(idx.queryPoint(100, 100)).toBeNull()
})
it("queryPoint returns null for distant coordinates", () => {
const idx = new SpatialIndex()
idx.rebuild([makeNode("a", 100, 100)])
expect(idx.queryPoint(5000, 5000)).toBeNull()
})
it("queryPoint handles overlapping nodes (returns last in render order)", () => {
const idx = new SpatialIndex()
const nodes = [
makeNode("a", 100, 100, "document", 50),
makeNode("b", 110, 110, "document", 50),
]
idx.rebuild(nodes)
// Both nodes overlap at (105, 105), should return the last one (higher z)
const found = idx.queryPoint(105, 105)
expect(found).not.toBeNull()
expect(found!.id).toBe("b")
})
it("queryPoint works across cell boundaries", () => {
const idx = new SpatialIndex()
// Node at cell boundary (cellSize = 200)
const node = makeNode("edge", 199, 199, "document", 50)
idx.rebuild([node])
// Query from adjacent cell
const found = idx.queryPoint(201, 201)
expect(found).not.toBeNull()
expect(found!.id).toBe("edge")
})
it("handles many nodes without errors", () => {
const idx = new SpatialIndex()
const nodes = Array.from({ length: 1000 }, (_, i) =>
makeNode(`n${i}`, Math.random() * 2000, Math.random() * 2000),
)
expect(() => idx.rebuild(nodes)).not.toThrow()
// Should find at least some nodes
const found = idx.queryPoint(nodes[0]!.x, nodes[0]!.y)
expect(found).not.toBeNull()
})
})

View file

@ -0,0 +1,406 @@
import { describe, it, expect } from "vitest"
import { VersionChainIndex } from "../canvas/version-chain"
import type { GraphApiDocument, GraphApiMemory } from "../types"
function makeMem(
overrides: Partial<GraphApiMemory> & { id: string },
): GraphApiMemory {
return {
memory: `Memory ${overrides.id}`,
isStatic: false,
spaceId: "default",
isLatest: true,
isForgotten: false,
forgetAfter: null,
forgetReason: null,
version: 1,
parentMemoryId: null,
rootMemoryId: null,
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
...overrides,
}
}
function makeDoc(id: string, memories: GraphApiMemory[]): GraphApiDocument {
return {
id,
title: `Doc ${id}`,
summary: null,
documentType: "text",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
memories,
}
}
describe("VersionChainIndex", () => {
it("getChain returns null for standalone memory (no parent, no children)", () => {
const idx = new VersionChainIndex()
const doc = makeDoc("d1", [makeMem({ id: "m1", version: 1 })])
idx.rebuild([doc])
// Single memory with no parent and no children — not a chain
expect(idx.getChain("m1")).toBeNull()
})
it("getChain from latest node returns full chain in version order", () => {
const idx = new VersionChainIndex()
const doc = makeDoc("d1", [
makeMem({ id: "m1", version: 1 }),
makeMem({
id: "m2",
parentMemoryId: "m1",
rootMemoryId: "m1",
version: 2,
}),
makeMem({
id: "m3",
parentMemoryId: "m2",
rootMemoryId: "m1",
version: 3,
}),
])
idx.rebuild([doc])
// Query from the latest (version 3) — walks back m3->m2->m1, reverses to [m1,m2,m3]
const chain = idx.getChain("m3")
expect(chain).not.toBeNull()
expect(chain!.length).toBe(3)
expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"])
})
it("getChain from middle element returns full chain (backward + forward)", () => {
const idx = new VersionChainIndex()
const doc = makeDoc("d1", [
makeMem({ id: "m1", version: 1 }),
makeMem({
id: "m2",
parentMemoryId: "m1",
rootMemoryId: "m1",
version: 2,
}),
makeMem({
id: "m3",
parentMemoryId: "m2",
rootMemoryId: "m1",
version: 3,
}),
])
idx.rebuild([doc])
// Query from m2 (version 2) — walks back to m1, forward to m3
const chain = idx.getChain("m2")
expect(chain).not.toBeNull()
expect(chain!.length).toBe(3)
expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"])
})
it("caches chain results for all entries in the chain", () => {
const idx = new VersionChainIndex()
const doc = makeDoc("d1", [
makeMem({ id: "m1", version: 1 }),
makeMem({
id: "m2",
parentMemoryId: "m1",
rootMemoryId: "m1",
version: 2,
}),
])
idx.rebuild([doc])
const chain1 = idx.getChain("m2")
// After querying m2, m1 should also be cached (same chain object)
const chain2 = idx.getChain("m1")
// m1 is version 1, but it was cached as part of m2's chain
expect(chain2).toBe(chain1) // same reference
})
it("getChain from v1 root with children returns full chain", () => {
const idx = new VersionChainIndex()
const doc = makeDoc("d1", [
makeMem({ id: "m1", version: 1 }),
makeMem({
id: "m2",
parentMemoryId: "m1",
rootMemoryId: "m1",
version: 2,
}),
makeMem({
id: "m3",
parentMemoryId: "m2",
rootMemoryId: "m1",
version: 3,
}),
])
idx.rebuild([doc])
// Query from v1 root — walks forward to m2, m3
const chain = idx.getChain("m1")
expect(chain).not.toBeNull()
expect(chain!.length).toBe(3)
expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"])
})
it("getChain returns null for unknown ID", () => {
const idx = new VersionChainIndex()
idx.rebuild([makeDoc("d1", [makeMem({ id: "m1", version: 1 })])])
expect(idx.getChain("nonexistent")).toBeNull()
})
it("handles empty documents array", () => {
const idx = new VersionChainIndex()
expect(() => idx.rebuild([])).not.toThrow()
expect(idx.getChain("anything")).toBeNull()
})
it("rebuild clears previous chains (new array reference)", () => {
const idx = new VersionChainIndex()
const doc1 = makeDoc("d1", [
makeMem({ id: "m1", version: 1 }),
makeMem({
id: "m2",
parentMemoryId: "m1",
rootMemoryId: "m1",
version: 2,
}),
])
idx.rebuild([doc1])
expect(idx.getChain("m2")).not.toBeNull()
// Rebuild with different data (new array reference)
const doc2 = makeDoc("d2", [makeMem({ id: "m3", version: 1 })])
idx.rebuild([doc2])
expect(idx.getChain("m2")).toBeNull()
expect(idx.getChain("m1")).toBeNull()
})
it("rebuild skips if same array reference", () => {
const idx = new VersionChainIndex()
const docs = [
makeDoc("d1", [
makeMem({ id: "m1", version: 1 }),
makeMem({
id: "m2",
parentMemoryId: "m1",
rootMemoryId: "m1",
version: 2,
}),
]),
]
idx.rebuild(docs)
const chain1 = idx.getChain("m2")
// Same reference — rebuild is a no-op
idx.rebuild(docs)
const chain2 = idx.getChain("m2")
expect(chain2).toBe(chain1)
})
it("handles multiple independent chains across documents", () => {
const idx = new VersionChainIndex()
const docs = [
makeDoc("d1", [
makeMem({ id: "m1", version: 1 }),
makeMem({
id: "m2",
parentMemoryId: "m1",
rootMemoryId: "m1",
version: 2,
}),
]),
makeDoc("d2", [
makeMem({ id: "m3", version: 1 }),
makeMem({
id: "m4",
parentMemoryId: "m3",
rootMemoryId: "m3",
version: 2,
}),
]),
]
idx.rebuild(docs)
const chain1 = idx.getChain("m2")
const chain2 = idx.getChain("m4")
expect(chain1).not.toBeNull()
expect(chain2).not.toBeNull()
expect(chain1!.map((e) => e.id)).toEqual(["m1", "m2"])
expect(chain2!.map((e) => e.id)).toEqual(["m3", "m4"])
})
it("handles circular parent references without infinite loop", () => {
const idx = new VersionChainIndex()
const doc = makeDoc("d1", [
makeMem({ id: "m1", version: 1, parentMemoryId: "m2" }),
makeMem({ id: "m2", version: 2, parentMemoryId: "m1" }),
])
idx.rebuild([doc])
// Cycle: m1->m2->m1. The visited set prevents infinite loops.
const chain = idx.getChain("m1")
expect(chain).not.toBeNull()
expect(chain!.length).toBe(2)
})
it("branching children: follows first child by document order", () => {
const idx = new VersionChainIndex()
const doc = makeDoc("d1", [
makeMem({ id: "m1", version: 1 }),
makeMem({
id: "m2a",
parentMemoryId: "m1",
rootMemoryId: "m1",
version: 2,
}),
makeMem({
id: "m2b",
parentMemoryId: "m1",
rootMemoryId: "m1",
version: 2,
}),
])
idx.rebuild([doc])
// m1 has two children; forward walk picks the first (m2a)
const chain = idx.getChain("m1")
expect(chain).not.toBeNull()
expect(chain!.length).toBe(2)
expect(chain!.map((e) => e.id)).toEqual(["m1", "m2a"])
})
it("chain entries have correct fields", () => {
const idx = new VersionChainIndex()
const doc = makeDoc("d1", [
makeMem({ id: "m1", version: 1, isForgotten: true, isLatest: false }),
makeMem({
id: "m2",
parentMemoryId: "m1",
rootMemoryId: "m1",
version: 2,
isLatest: true,
}),
])
idx.rebuild([doc])
const chain = idx.getChain("m2")
expect(chain).not.toBeNull()
expect(chain![0]).toEqual({
id: "m1",
version: 1,
memory: "Memory m1",
isForgotten: true,
isLatest: false,
})
expect(chain![1]).toEqual({
id: "m2",
version: 2,
memory: "Memory m2",
isForgotten: false,
isLatest: true,
})
})
// --- Additional edge cases ---
it("getChain returns null for orphaned non-root memory (v2+, no parent in index, no children)", () => {
const idx = new VersionChainIndex()
// m2 claims version 2 and has a parentMemoryId, but that parent is not in any document.
// The backward walk reaches a dead end after m2 itself (parent not in memoryMap).
// all.length === 1 → returns null, same as a standalone v1.
const doc = makeDoc("d1", [
makeMem({
id: "m2",
version: 2,
parentMemoryId: "m_ghost",
rootMemoryId: "m_ghost",
}),
])
idx.rebuild([doc])
expect(idx.getChain("m2")).toBeNull()
})
it("cross-document chain: parent in doc1, child in doc2 resolves correctly", () => {
// rebuild() walks all documents in one pass, so parentMemoryId references
// are resolved across document boundaries. This mirrors production usage where
// chainIndex.current.rebuild(limitedDocuments) receives all documents at once.
const idx = new VersionChainIndex()
const docs = [
makeDoc("d1", [makeMem({ id: "m1", version: 1 })]),
makeDoc("d2", [
makeMem({
id: "m2",
parentMemoryId: "m1",
rootMemoryId: "m1",
version: 2,
}),
]),
]
idx.rebuild(docs)
// Querying from child (in d2) should walk back to parent (in d1)
const chainFromChild = idx.getChain("m2")
expect(chainFromChild).not.toBeNull()
expect(chainFromChild!.map((e) => e.id)).toEqual(["m1", "m2"])
// Querying from parent (in d1) should walk forward to child (in d2)
const chainFromParent = idx.getChain("m1")
expect(chainFromParent).not.toBeNull()
expect(chainFromParent!.map((e) => e.id)).toEqual(["m1", "m2"])
})
it("circular reference: both IDs present in result (order is undefined for malformed cycles)", () => {
// The existing circular test asserts length=2 but not membership.
// Circular data (m1.parent=m2, m2.parent=m1) is malformed and will never
// appear in production — the visited set merely guarantees termination.
// Order is intentionally unspecified because the backward-walk start node
// determines which ID appears first, which has no semantic meaning for corrupt data.
const idx = new VersionChainIndex()
const doc = makeDoc("d1", [
makeMem({ id: "m1", version: 1, parentMemoryId: "m2" }),
makeMem({ id: "m2", version: 2, parentMemoryId: "m1" }),
])
idx.rebuild([doc])
const chain = idx.getChain("m1")
expect(chain).not.toBeNull()
expect(chain!.length).toBe(2)
// Both nodes must appear — order is implementation-defined for cycles
const ids = chain!.map((e) => e.id)
expect(ids).toContain("m1")
expect(ids).toContain("m2")
})
it("getChain from middle node with cold cache exercises real backward+forward traversal", () => {
// This test deliberately queries the MIDDLE node first (cache is empty at that point)
// to confirm the combined backward+forward traversal is the live code path being
// exercised — not merely the cache fast-path from a prior call to another node.
const idx = new VersionChainIndex()
const doc = makeDoc("d1", [
makeMem({ id: "m1", version: 1 }),
makeMem({
id: "m2",
parentMemoryId: "m1",
rootMemoryId: "m1",
version: 2,
}),
makeMem({
id: "m3",
parentMemoryId: "m2",
rootMemoryId: "m1",
version: 3,
}),
])
idx.rebuild([doc])
// Cold cache: getChain("m2") must walk backward to m1 AND forward to m3.
const chain = idx.getChain("m2")
expect(chain).not.toBeNull()
expect(chain!.length).toBe(3)
expect(chain!.map((e) => e.id)).toEqual(["m1", "m2", "m3"])
// After the traversal, neighboring nodes must return the same cached reference —
// confirming that the cache-population loop ran for all three entries.
expect(idx.getChain("m1")).toBe(chain)
expect(idx.getChain("m3")).toBe(chain)
})
})

View file

@ -0,0 +1,219 @@
import { describe, it, expect } from "vitest"
import { ViewportState } from "../canvas/viewport"
import type { GraphNode } from "../types"
function makeNode(id: string, x: number, y: number): GraphNode {
return {
id,
type: "document",
x,
y,
size: 50,
borderColor: "#fff",
isHovered: false,
isDragging: false,
data: {
id,
title: id,
summary: null,
type: "text",
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
memories: [],
},
}
}
/** Run tick() until animation converges (or max iterations) */
function tickUntilSettled(vp: ViewportState, maxIter = 500): void {
for (let i = 0; i < maxIter; i++) {
if (!vp.tick()) break
}
}
describe("ViewportState", () => {
it("constructor sets initial values", () => {
const vp = new ViewportState()
expect(vp.panX).toBe(0)
expect(vp.panY).toBe(0)
expect(vp.zoom).toBe(0.5) // default initial zoom
})
it("constructor accepts custom initial values", () => {
const vp = new ViewportState(10, 20, 1.5)
expect(vp.panX).toBe(10)
expect(vp.panY).toBe(20)
expect(vp.zoom).toBe(1.5)
})
it("worldToScreen and screenToWorld are inverse operations", () => {
const vp = new ViewportState(100, 50, 1.5)
const worldX = 300
const worldY = 400
const screen = vp.worldToScreen(worldX, worldY)
const world = vp.screenToWorld(screen.x, screen.y)
expect(world.x).toBeCloseTo(worldX, 5)
expect(world.y).toBeCloseTo(worldY, 5)
})
it("worldToScreen applies zoom and pan: screen = world * zoom + pan", () => {
const vp = new ViewportState(10, 20, 2)
const screen = vp.worldToScreen(100, 200)
expect(screen.x).toBe(100 * 2 + 10) // 210
expect(screen.y).toBe(200 * 2 + 20) // 420
})
it("screenToWorld reverses: world = (screen - pan) / zoom", () => {
const vp = new ViewportState(10, 20, 2)
const world = vp.screenToWorld(210, 420)
expect(world.x).toBeCloseTo(100, 5)
expect(world.y).toBeCloseTo(200, 5)
})
it("pan offsets correctly and accumulates", () => {
const vp = new ViewportState(0, 0, 1)
vp.pan(50, 30)
expect(vp.panX).toBe(50)
expect(vp.panY).toBe(30)
vp.pan(10, 20)
expect(vp.panX).toBe(60)
expect(vp.panY).toBe(50)
})
it("pan cancels any animated pan target", () => {
const vp = new ViewportState(0, 0, 1)
vp.centerOn(500, 500, 800, 600) // sets targetPanX/Y
vp.pan(10, 10) // should cancel the target
// After pan, tick should return false (no animation)
expect(vp.tick()).toBe(false)
})
it("zoomImmediate multiplies current zoom by delta", () => {
const vp = new ViewportState(0, 0, 1)
const initialZoom = vp.zoom
vp.zoomImmediate(2, 0, 0)
expect(vp.zoom).toBeCloseTo(initialZoom * 2)
})
it("zoomImmediate preserves world point under anchor", () => {
const vp = new ViewportState(100, 50, 1)
const anchorX = 400
const anchorY = 300
// Get world point under anchor before zoom
const worldBefore = vp.screenToWorld(anchorX, anchorY)
vp.zoomImmediate(2, anchorX, anchorY)
// After zoom, same screen point should map to same world point
const worldAfter = vp.screenToWorld(anchorX, anchorY)
expect(worldAfter.x).toBeCloseTo(worldBefore.x, 3)
expect(worldAfter.y).toBeCloseTo(worldBefore.y, 3)
})
it("zoomImmediate clamps to MIN_ZOOM (0.1)", () => {
const vp = new ViewportState(0, 0, 0.5)
// Try to zoom way down: 0.5 * 0.01 = 0.005, should clamp to 0.1
vp.zoomImmediate(0.01, 0, 0)
expect(vp.zoom).toBeCloseTo(0.1)
})
it("zoomImmediate clamps to MAX_ZOOM (5.0)", () => {
const vp = new ViewportState(0, 0, 2)
// Try to zoom way up: 2 * 100 = 200, should clamp to 5
vp.zoomImmediate(100, 0, 0)
expect(vp.zoom).toBeCloseTo(5.0)
})
it("zoomTo sets target zoom (animated via tick)", () => {
const vp = new ViewportState(0, 0, 0.5)
vp.zoomTo(2, 400, 300)
// Zoom hasn't changed yet — it's animated
expect(vp.zoom).toBe(0.5)
// After ticking, zoom should approach target
tickUntilSettled(vp)
expect(vp.zoom).toBeCloseTo(2, 1)
})
it("tick returns false when no animation is active", () => {
const vp = new ViewportState()
expect(vp.tick()).toBe(false)
})
it("tick returns true during inertia", () => {
const vp = new ViewportState()
vp.releaseWithVelocity(10, 10)
expect(vp.tick()).toBe(true)
})
it("tick returns true during zoom animation", () => {
const vp = new ViewportState(0, 0, 0.5)
vp.zoomTo(2, 0, 0)
expect(vp.tick()).toBe(true)
})
it("tick returns true during pan animation", () => {
const vp = new ViewportState(0, 0, 1)
vp.centerOn(500, 500, 800, 600)
expect(vp.tick()).toBe(true)
})
it("fitToNodes centers and scales to fit all nodes", () => {
const vp = new ViewportState(0, 0, 0.5)
const nodes = [
makeNode("a", 0, 0),
makeNode("b", 1000, 0),
makeNode("c", 0, 1000),
makeNode("d", 1000, 1000),
]
vp.fitToNodes(nodes, 800, 600)
tickUntilSettled(vp)
// After fitting, all nodes should be visible within the viewport
for (const node of nodes) {
const screen = vp.worldToScreen(node.x, node.y)
expect(screen.x).toBeGreaterThan(-100)
expect(screen.x).toBeLessThan(900)
expect(screen.y).toBeGreaterThan(-100)
expect(screen.y).toBeLessThan(700)
}
})
it("fitToNodes handles single node without throwing", () => {
const vp = new ViewportState()
expect(() =>
vp.fitToNodes([makeNode("a", 500, 500)], 800, 600),
).not.toThrow()
})
it("fitToNodes handles empty nodes array without throwing", () => {
const vp = new ViewportState()
const zoomBefore = vp.zoom
vp.fitToNodes([], 800, 600)
// Should be a no-op
expect(vp.zoom).toBe(zoomBefore)
})
it("centerOn animates pan to center a world point on screen", () => {
const vp = new ViewportState(0, 0, 1)
vp.centerOn(500, 300, 800, 600)
tickUntilSettled(vp)
// After settling, world point (500, 300) should map to screen center (400, 300)
const screen = vp.worldToScreen(500, 300)
expect(screen.x).toBeCloseTo(400, 0)
expect(screen.y).toBeCloseTo(300, 0)
})
it("inertia decays to zero", () => {
const vp = new ViewportState()
vp.releaseWithVelocity(100, 100)
tickUntilSettled(vp)
// After settling, tick should return false
expect(vp.tick()).toBe(false)
})
})

View file

@ -1,71 +1,42 @@
// Standalone TypeScript types for Memory Graph
// These mirror the API response types from @repo/validation/api
export type MemoryRelation = "updates" | "extends" | "derives"
export interface MemoryEntry {
id: string
customId?: string | null
documentId: string
content: string | null
summary?: string | null
title?: string | null
url?: string | null
type?: string | null
metadata?: Record<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
memory: string
content?: string | null
createdAt: string
updatedAt: string
spaceId?: string | null
embedding?: number[]
isStatic?: boolean
isForgotten?: boolean
forgetAfter?: string | null
forgetReason?: string | null
version?: number
parentMemoryId?: string | null
rootMemoryId?: string | null
isLatest?: boolean
// Relation fields from backend
relation?: MemoryRelation | null
updatesMemoryId?: string | null
nextVersionId?: string | null
memoryRelations?: Record<string, MemoryRelation> | null
// Source/join fields
sourceAddedAt?: string | 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
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

@ -0,0 +1,402 @@
/**
* Canvas-native document type icon drawing functions.
*
* Each function draws a small vector icon at the given (x, y) center
* within a bounding box of `size` pixels. All drawing uses the current
* canvas fill/stroke styles set by the caller.
*/
/** Shared rounded-rectangle path helper (also used by the renderer). */
export function roundRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
): void {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.arcTo(x + w, y, x + w, y + r, r)
ctx.lineTo(x + w, y + h - r)
ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
ctx.lineTo(x + r, y + h)
ctx.arcTo(x, y + h, x, y + h - r, r)
ctx.lineTo(x, y + r)
ctx.arcTo(x, y, x + r, y, r)
ctx.closePath()
}
/**
* Draw the appropriate document-type icon on the canvas.
*
* Wraps the drawing in save/restore so callers don't need to worry about
* state leaking. Only `iconColor` is needed from the theme.
*/
export function drawDocIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
type: string,
iconColor: string,
): void {
ctx.save()
ctx.fillStyle = iconColor
ctx.strokeStyle = iconColor
ctx.lineWidth = Math.max(1, size / 12)
ctx.lineCap = "round"
ctx.lineJoin = "round"
switch (type) {
case "webpage":
case "url":
drawGlobeIcon(ctx, x, y, size)
break
case "pdf":
drawTextLabel(ctx, x, y, size, "PDF", 0.35)
break
case "md":
case "markdown":
drawTextLabel(ctx, x, y, size, "MD", 0.3)
break
case "doc":
case "docx":
case "word":
case "microsoft_word":
drawTextLabel(ctx, x, y, size, "W", 0.4)
break
case "csv":
case "excel":
case "microsoft_excel":
case "google_sheet":
drawGridIcon(ctx, x, y, size)
break
case "json":
drawBracesIcon(ctx, x, y, size)
break
case "notion":
case "notion_doc":
drawNotionIcon(ctx, x, y, size)
break
case "google_doc":
drawGoogleDocIcon(ctx, x, y, size)
break
case "google_slide":
case "powerpoint":
case "microsoft_powerpoint":
drawSlidesIcon(ctx, x, y, size)
break
case "google_drive":
case "onedrive":
drawCloudIcon(ctx, x, y, size)
break
case "tweet":
drawXIcon(ctx, x, y, size)
break
case "youtube":
case "video":
drawPlayIcon(ctx, x, y, size)
break
case "image":
drawImageIcon(ctx, x, y, size)
break
case "text":
case "note":
drawTextNoteIcon(ctx, x, y, size)
break
case "onenote":
case "microsoft_onenote":
drawTextLabel(ctx, x, y, size, "N", 0.4)
break
case "mcp":
drawTextLabel(ctx, x, y, size, "MCP", 0.25)
break
default:
drawDocOutline(ctx, x, y, size)
break
}
ctx.restore()
}
// ---------------------------------------------------------------------------
// Individual icon drawing helpers
// ---------------------------------------------------------------------------
function drawTextLabel(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
text: string,
fontRatio: number,
): void {
ctx.font = `bold ${size * fontRatio}px sans-serif`
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillText(text, x, y)
}
function drawGlobeIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const r = size * 0.4
ctx.beginPath()
ctx.arc(x, y, r, 0, Math.PI * 2)
ctx.stroke()
ctx.beginPath()
ctx.ellipse(x, y, r * 0.4, r, 0, 0, Math.PI * 2)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(x - r, y)
ctx.lineTo(x + r, y)
ctx.stroke()
}
function drawGridIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const w = size * 0.7
const h = size * 0.7
ctx.strokeRect(x - w / 2, y - h / 2, w, h)
ctx.beginPath()
ctx.moveTo(x, y - h / 2)
ctx.lineTo(x, y + h / 2)
ctx.moveTo(x - w / 2, y)
ctx.lineTo(x + w / 2, y)
ctx.stroke()
}
function drawBracesIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const w = size * 0.6
const h = size * 0.8
ctx.beginPath()
ctx.moveTo(x - w / 4, y - h / 2)
ctx.quadraticCurveTo(x - w / 2, y - h / 3, x - w / 2, y)
ctx.quadraticCurveTo(x - w / 2, y + h / 3, x - w / 4, y + h / 2)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(x + w / 4, y - h / 2)
ctx.quadraticCurveTo(x + w / 2, y - h / 3, x + w / 2, y)
ctx.quadraticCurveTo(x + w / 2, y + h / 3, x + w / 4, y + h / 2)
ctx.stroke()
}
function drawDocOutline(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const w = size * 0.7
const h = size * 0.85
const fold = size * 0.2
ctx.beginPath()
ctx.moveTo(x - w / 2, y - h / 2)
ctx.lineTo(x + w / 2 - fold, y - h / 2)
ctx.lineTo(x + w / 2, y - h / 2 + fold)
ctx.lineTo(x + w / 2, y + h / 2)
ctx.lineTo(x - w / 2, y + h / 2)
ctx.closePath()
ctx.stroke()
const sp = size * 0.15
const lw = size * 0.4
ctx.beginPath()
ctx.moveTo(x - lw / 2, y - sp)
ctx.lineTo(x + lw / 2, y - sp)
ctx.moveTo(x - lw / 2, y)
ctx.lineTo(x + lw / 2, y)
ctx.moveTo(x - lw / 2, y + sp)
ctx.lineTo(x + lw / 2, y + sp)
ctx.stroke()
}
/** Draw a simplified Notion "N" logo mark */
function drawNotionIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const w = size * 0.5
const h = size * 0.6
const r = size * 0.08
ctx.lineWidth = Math.max(1, size / 14)
roundRect(ctx, x - w / 2, y - h / 2, w, h, r)
ctx.stroke()
// Inner "N" shape
const inset = size * 0.12
const left = x - w / 2 + inset
const right = x + w / 2 - inset
const top = y - h / 2 + inset
const bottom = y + h / 2 - inset
ctx.beginPath()
ctx.moveTo(left, top)
ctx.lineTo(left, bottom)
ctx.moveTo(left, top)
ctx.lineTo(right, bottom)
ctx.moveTo(right, top)
ctx.lineTo(right, bottom)
ctx.stroke()
}
/** Draw a Google Docs icon (document with lines) */
function drawGoogleDocIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const w = size * 0.55
const h = size * 0.7
const fold = size * 0.15
ctx.beginPath()
ctx.moveTo(x - w / 2, y - h / 2)
ctx.lineTo(x + w / 2 - fold, y - h / 2)
ctx.lineTo(x + w / 2, y - h / 2 + fold)
ctx.lineTo(x + w / 2, y + h / 2)
ctx.lineTo(x - w / 2, y + h / 2)
ctx.closePath()
ctx.stroke()
const lineW = w * 0.6
const sp = size * 0.1
ctx.beginPath()
ctx.moveTo(x - lineW / 2, y - sp)
ctx.lineTo(x + lineW / 2, y - sp)
ctx.moveTo(x - lineW / 2, y + sp * 0.3)
ctx.lineTo(x + lineW / 2, y + sp * 0.3)
ctx.moveTo(x - lineW / 2, y + sp * 1.6)
ctx.lineTo(x + lineW / 3, y + sp * 1.6)
ctx.stroke()
}
/** Draw a slides/presentation icon (rectangle with play triangle) */
function drawSlidesIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const w = size * 0.7
const h = size * 0.5
ctx.strokeRect(x - w / 2, y - h / 2, w, h)
const triSize = size * 0.15
ctx.beginPath()
ctx.moveTo(x - triSize * 0.5, y - triSize * 0.7)
ctx.lineTo(x - triSize * 0.5, y + triSize * 0.7)
ctx.lineTo(x + triSize * 0.7, y)
ctx.closePath()
ctx.fill()
}
/** Draw a cloud icon for drive/storage types */
function drawCloudIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const s = size * 0.35
ctx.beginPath()
ctx.arc(x - s * 0.3, y + s * 0.1, s * 0.5, Math.PI * 0.7, Math.PI * 1.9)
ctx.arc(x + s * 0.1, y - s * 0.3, s * 0.55, Math.PI * 1.1, Math.PI * 0.3)
ctx.arc(x + s * 0.5, y + s * 0.1, s * 0.4, Math.PI * 1.4, Math.PI * 0.6)
ctx.lineTo(x - s * 0.7, y + s * 0.45)
ctx.closePath()
ctx.stroke()
}
/** Draw an X (formerly Twitter) icon */
function drawXIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const s = size * 0.3
ctx.lineWidth = Math.max(1.5, size / 10)
ctx.beginPath()
ctx.moveTo(x - s, y - s)
ctx.lineTo(x + s, y + s)
ctx.moveTo(x + s, y - s)
ctx.lineTo(x - s, y + s)
ctx.stroke()
}
/** Draw a play button icon for video/youtube */
function drawPlayIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const r = size * 0.38
const w = r * 2.2
const h = r * 1.5
const cr = size * 0.08
roundRect(ctx, x - w / 2, y - h / 2, w, h, cr)
ctx.stroke()
const triH = size * 0.22
ctx.beginPath()
ctx.moveTo(x - triH * 0.45, y - triH)
ctx.lineTo(x - triH * 0.45, y + triH)
ctx.lineTo(x + triH * 0.7, y)
ctx.closePath()
ctx.fill()
}
/** Draw an image/photo icon (landscape with mountain) */
function drawImageIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const w = size * 0.7
const h = size * 0.55
ctx.strokeRect(x - w / 2, y - h / 2, w, h)
ctx.beginPath()
ctx.moveTo(x - w / 2 + w * 0.1, y + h / 2 - h * 0.1)
ctx.lineTo(x - w * 0.05, y - h * 0.05)
ctx.lineTo(x + w * 0.15, y + h * 0.15)
ctx.lineTo(x + w * 0.25, y - h * 0.02)
ctx.lineTo(x + w / 2 - w * 0.1, y + h / 2 - h * 0.1)
ctx.stroke()
const sunR = size * 0.06
ctx.beginPath()
ctx.arc(x - w * 0.15, y - h * 0.15, sunR, 0, Math.PI * 2)
ctx.fill()
}
/** Draw a text/note icon (lines of text) */
function drawTextNoteIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number,
): void {
const w = size * 0.55
const h = size * 0.6
const sp = h / 5
ctx.beginPath()
for (let i = 0; i < 4; i++) {
const lineY = y - h / 2 + sp * (i + 0.5)
const lineW = i === 3 ? w * 0.6 : w
ctx.moveTo(x - w / 2, lineY)
ctx.lineTo(x - w / 2 + lineW, lineY)
}
ctx.stroke()
}

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,9 +58,16 @@ export class SpatialIndex {
private computeHash(nodes: GraphNode[]): number {
let hash = nodes.length
for (const n of nodes) {
// Round to nearest integer to avoid false rebuilds from tiny physics jitter
hash = (hash * 31 + (Math.round(n.x) | 0)) | 0
hash = (hash * 31 + (Math.round(n.y) | 0)) | 0
// Use finer granularity (10x) to detect sub-pixel movements
// and incorporate a simple string hash of the ID to avoid
// false matches when nodes swap positions
let idHash = 0
for (let i = 0; i < n.id.length; i++) {
idHash = ((idHash << 5) - idHash + n.id.charCodeAt(i)) | 0
}
hash = (hash * 31 + idHash) | 0
hash = (hash * 31 + (Math.round(n.x * 10) | 0)) | 0
hash = (hash * 31 + (Math.round(n.y * 10) | 0)) | 0
}
return hash
}

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

@ -0,0 +1,661 @@
import type {
DocumentNodeData,
GraphEdge,
GraphNode,
GraphThemeColors,
MemoryNodeData,
} from "../types"
import type { ViewportState } from "./viewport"
import { drawDocIcon, roundRect } from "./document-icons"
export interface RenderState {
selectedNodeId: string | null
hoveredNodeId: string | null
highlightIds: Set<string>
dimProgress: number
}
// Module-level reusable batch map cleared each frame instead of reallocating
const edgeBatches = new Map<string, PreparedEdge[]>()
/** Group items by their `color` property into batches for efficient canvas drawing */
function groupByColor<T extends { color: string }>(
items: T[],
): Map<string, T[]> {
const map = new Map<string, T[]>()
for (const item of items) {
let batch = map.get(item.color)
if (!batch) {
batch = []
map.set(item.color, batch)
}
batch.push(item)
}
return map
}
// Cache for lightenColor results to avoid per-frame hex parsing
let _lightenCache: { input: string; amount: number; result: string } | null =
null
export function renderFrame(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
edges: GraphEdge[],
viewport: ViewportState,
width: number,
height: number,
state: RenderState,
nodeMap: Map<string, GraphNode>,
colors: GraphThemeColors,
): void {
ctx.clearRect(0, 0, width, height)
drawEdges(ctx, edges, viewport, width, height, state, nodeMap, colors)
drawNodes(ctx, nodes, viewport, width, height, state, colors)
}
function edgeStyle(
edge: GraphEdge,
colors: GraphThemeColors,
): { color: string; width: number; opacity: number } {
if (edge.edgeType === "derives")
return { color: colors.edgeDerives, width: 1.2, opacity: 0.4 }
if (edge.edgeType === "updates")
return { color: colors.edgeUpdates, width: 2, opacity: 0.7 }
// "extends" and any unknown edge types
return { color: colors.edgeExtends, width: 1.5, opacity: 0.55 }
}
function batchKey(style: {
color: string
width: number
opacity: number
}): string {
return `${style.color}|${style.width}|${style.opacity}`
}
interface PreparedEdge {
startX: number
startY: number
endX: number
endY: number
connected: boolean
style: { color: string; width: number; opacity: number }
edgeType: string
arrowSize: number
}
function drawEdges(
ctx: CanvasRenderingContext2D,
edges: GraphEdge[],
viewport: ViewportState,
width: number,
height: number,
state: RenderState,
nodeMap: Map<string, GraphNode>,
colors: GraphThemeColors,
): void {
const margin = 100
const hasDim = state.selectedNodeId !== null && state.dimProgress > 0
const prepared: PreparedEdge[] = []
for (const edge of edges) {
// Zoom-based edge culling for extends edges at very low zoom
if (edge.edgeType === "extends") {
if (viewport.zoom < 0.08) continue
}
const src =
typeof edge.source === "string" ? nodeMap.get(edge.source) : edge.source
const tgt =
typeof edge.target === "string" ? nodeMap.get(edge.target) : edge.target
if (!src || !tgt) continue
if (edge.edgeType === "derives") {
const mem = src.type === "memory" ? src : tgt
if (mem.size * viewport.zoom < 3) continue
}
const s = viewport.worldToScreen(src.x, src.y)
const t = viewport.worldToScreen(tgt.x, tgt.y)
if (
(s.x < -margin && t.x < -margin) ||
(s.x > width + margin && t.x > width + margin) ||
(s.y < -margin && t.y < -margin) ||
(s.y > height + margin && t.y > height + margin)
)
continue
const dx = t.x - s.x
const dy = t.y - s.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < 1) continue
const ux = dx / dist
const uy = dy / dist
const sr = src.size * viewport.zoom * 0.5
const tr = tgt.size * viewport.zoom * 0.5
let connected = true
if (hasDim) {
const srcId =
typeof edge.source === "string" ? edge.source : edge.source.id
const tgtId =
typeof edge.target === "string" ? edge.target : edge.target.id
connected =
srcId === state.selectedNodeId || tgtId === state.selectedNodeId
}
prepared.push({
startX: s.x + ux * sr,
startY: s.y + uy * sr,
endX: t.x - ux * tr,
endY: t.y - uy * tr,
connected,
style: edgeStyle(edge, colors),
edgeType: edge.edgeType ?? "derives",
arrowSize:
edge.edgeType === "updates" ? Math.max(6, 8 * viewport.zoom) : 0,
})
}
// Reuse module-level batch map
edgeBatches.clear()
for (const e of prepared) {
const dimKey = hasDim ? (e.connected ? "|c" : "|d") : ""
const key = `${e.edgeType}|${batchKey(e.style)}${dimKey}`
let batch = edgeBatches.get(key)
if (!batch) {
batch = []
edgeBatches.set(key, batch)
}
batch.push(e)
}
ctx.setLineDash([])
for (const [key, batch] of edgeBatches) {
const first = batch[0]
if (!first) continue
const isDimmed = key.endsWith("|d")
const batchEdgeType = first.edgeType
// Draw glow pass behind all edge types for luminous aesthetic
if (!isDimmed) {
const glowAlpha =
batchEdgeType === "updates"
? first.style.opacity * 0.4
: first.style.opacity * 0.3
const glowWidth =
batchEdgeType === "updates"
? first.style.width + 2
: first.style.width + 1.5
ctx.save()
ctx.globalAlpha = glowAlpha
ctx.strokeStyle = first.style.color
ctx.lineWidth = glowWidth
if (batchEdgeType === "extends") ctx.setLineDash([6, 4])
ctx.beginPath()
for (const e of batch) {
ctx.moveTo(e.startX, e.startY)
ctx.lineTo(e.endX, e.endY)
}
ctx.stroke()
ctx.restore()
}
const baseAlpha = first.style.opacity
ctx.globalAlpha = isDimmed
? baseAlpha * (1 - state.dimProgress * 0.8)
: baseAlpha
ctx.strokeStyle = first.style.color
ctx.lineWidth = first.style.width
// Extends edges use dashed lines
if (batchEdgeType === "extends") ctx.setLineDash([6, 4])
ctx.beginPath()
for (const e of batch) {
ctx.moveTo(e.startX, e.startY)
ctx.lineTo(e.endX, e.endY)
}
ctx.stroke()
if (batchEdgeType === "extends") ctx.setLineDash([])
// Arrowheads for updates edges
if (batchEdgeType === "updates") {
ctx.globalAlpha = isDimmed
? first.style.opacity * 0.6 * (1 - state.dimProgress * 0.8)
: first.style.opacity * 0.6
ctx.fillStyle = first.style.color
for (const e of batch) {
drawArrowHead(ctx, e.startX, e.startY, e.endX, e.endY, e.arrowSize)
}
}
}
ctx.globalAlpha = 1
}
function drawArrowHead(
ctx: CanvasRenderingContext2D,
fromX: number,
fromY: number,
toX: number,
toY: number,
size: number,
): void {
const angle = Math.atan2(toY - fromY, toX - fromX)
ctx.beginPath()
ctx.moveTo(toX, toY)
ctx.lineTo(
toX - size * Math.cos(angle - Math.PI / 6),
toY - size * Math.sin(angle - Math.PI / 6),
)
ctx.lineTo(
toX - size * Math.cos(angle + Math.PI / 6),
toY - size * Math.sin(angle + Math.PI / 6),
)
ctx.closePath()
ctx.fill()
}
function drawNodes(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
viewport: ViewportState,
width: number,
height: number,
state: RenderState,
colors: GraphThemeColors,
): void {
const margin = 60
const memDots: {
x: number
y: number
r: number
color: string
dimmed: boolean
}[] = []
const docDots: { x: number; y: number; s: number }[] = []
for (const node of nodes) {
const screen = viewport.worldToScreen(node.x, node.y)
const screenSize = node.size * viewport.zoom
const cullSize = Math.max(screenSize, 2)
if (
screen.x + cullSize < -margin ||
screen.x - cullSize > width + margin ||
screen.y + cullSize < -margin ||
screen.y - cullSize > height + margin
)
continue
const isSelected = node.id === state.selectedNodeId
const isHovered = node.id === state.hoveredNodeId
const isHighlighted = state.highlightIds.has(node.id)
if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) {
if (node.type === "document") {
docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) })
} else {
const md = node.data as MemoryNodeData
memDots.push({
x: screen.x,
y: screen.y,
r: Math.max(2, screenSize * 0.45),
color: node.borderColor || colors.memStrokeDefault,
dimmed: md.isLatest === false,
})
}
continue
}
let alpha = 1
if (state.selectedNodeId && state.dimProgress > 0 && !isSelected) {
alpha = 1 - state.dimProgress * 0.7
}
ctx.globalAlpha = alpha
if (node.type === "document") {
drawDocumentNode(
ctx,
screen.x,
screen.y,
screenSize,
node,
isSelected,
isHovered,
isHighlighted,
colors,
)
} else {
drawMemoryNode(
ctx,
screen.x,
screen.y,
screenSize,
node,
isSelected,
isHovered,
isHighlighted,
colors,
)
}
if (isSelected || isHighlighted || isHovered) {
drawGlow(
ctx,
screen.x,
screen.y,
screenSize,
node.type,
colors,
isHovered && !isSelected,
)
}
}
const dimAlpha =
state.selectedNodeId && state.dimProgress > 0
? 1 - state.dimProgress * 0.7
: 1
if (docDots.length > 0) {
ctx.fillStyle = colors.docFill
ctx.strokeStyle = colors.docStroke
ctx.lineWidth = 1
ctx.globalAlpha = dimAlpha
for (const d of docDots) {
const h = d.s * 0.5
ctx.fillRect(d.x - h, d.y - h, d.s, d.s)
ctx.strokeRect(d.x - h, d.y - h, d.s, d.s)
}
}
if (memDots.length > 0) {
// Draw normal (latest) memory dots
const normalDots = memDots.filter((d) => !d.dimmed)
const dimmedDots = memDots.filter((d) => d.dimmed)
if (normalDots.length > 0) {
// Subtle glow behind memory dots for luminous effect
ctx.globalAlpha = dimAlpha * 0.25
for (const [color, batch] of groupByColor(normalDots)) {
ctx.fillStyle = color
ctx.beginPath()
for (const d of batch) {
ctx.moveTo(d.x + d.r * 2.5, d.y)
ctx.arc(d.x, d.y, d.r * 2.5, 0, Math.PI * 2)
}
ctx.fill()
}
// Filled dot
ctx.globalAlpha = dimAlpha
ctx.fillStyle = colors.memFill
ctx.beginPath()
for (const d of normalDots) {
ctx.moveTo(d.x + d.r, d.y)
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
}
ctx.fill()
// Colored border
ctx.lineWidth = 1.5
for (const [color, batch] of groupByColor(normalDots)) {
ctx.strokeStyle = color
ctx.beginPath()
for (const d of batch) {
ctx.moveTo(d.x + d.r, d.y)
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
}
ctx.stroke()
}
}
// Draw dimmed (superseded) memory dots at reduced opacity
if (dimmedDots.length > 0) {
ctx.globalAlpha = dimAlpha * 0.5
ctx.fillStyle = colors.memFill
ctx.beginPath()
for (const d of dimmedDots) {
ctx.moveTo(d.x + d.r, d.y)
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
}
ctx.fill()
ctx.lineWidth = 1
for (const [color, batch] of groupByColor(dimmedDots)) {
ctx.strokeStyle = color
ctx.beginPath()
for (const d of batch) {
ctx.moveTo(d.x + d.r, d.y)
ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2)
}
ctx.stroke()
}
}
}
ctx.globalAlpha = 1
}
function drawDocumentNode(
ctx: CanvasRenderingContext2D,
sx: number,
sy: number,
size: number,
node: GraphNode,
isSelected: boolean,
isHovered: boolean,
isHighlighted: boolean,
colors: GraphThemeColors,
): void {
const half = size * 0.5
const cornerR = 8 * (size / 50)
// Drop shadow for selected/hovered nodes
if (isSelected || isHovered) {
ctx.save()
ctx.shadowColor = colors.accent
ctx.shadowBlur = isSelected ? 16 : 10
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
// Subtle gradient fill for document nodes
const grad = ctx.createLinearGradient(
sx - half,
sy - half,
sx + half,
sy + half,
)
grad.addColorStop(0, colors.docFill)
grad.addColorStop(1, lightenColor(colors.docFill, 0.08))
ctx.fillStyle = grad
ctx.strokeStyle =
isSelected || isHighlighted || isHovered ? colors.accent : colors.docStroke
ctx.lineWidth = isSelected || isHighlighted ? 2.5 : isHovered ? 1.5 : 1
roundRect(ctx, sx - half, sy - half, size, size, cornerR)
ctx.fill()
ctx.stroke()
if (isSelected || isHovered) {
ctx.restore()
}
const innerSize = size * 0.72
const innerHalf = innerSize * 0.5
const innerR = 6 * (size / 50)
ctx.fillStyle = colors.docInnerFill
roundRect(ctx, sx - innerHalf, sy - innerHalf, innerSize, innerSize, innerR)
ctx.fill()
const iconSize = size * 0.35
const docType =
node.type === "document" ? (node.data as DocumentNodeData).type : "text"
drawDocIcon(ctx, sx, sy, iconSize, docType || "text", colors.iconColor)
}
function drawMemoryNode(
ctx: CanvasRenderingContext2D,
sx: number,
sy: number,
size: number,
node: GraphNode,
isSelected: boolean,
isHovered: boolean,
_isHighlighted: boolean,
colors: GraphThemeColors,
): void {
const memData = node.data as MemoryNodeData
const isSuperseded = memData.isLatest === false
const isForgotten = memData.isForgotten
const radius = size * 0.5
// Dim superseded (non-latest) memory nodes with strikethrough effect
if (isSuperseded && !isSelected && !isHovered) {
const prevAlpha = ctx.globalAlpha
ctx.globalAlpha = prevAlpha * 0.5
ctx.fillStyle = colors.memFill
drawHexagon(ctx, sx, sy, radius)
ctx.fill()
ctx.strokeStyle = node.borderColor || colors.memStrokeDefault
ctx.lineWidth = 1
ctx.setLineDash([3, 3])
ctx.stroke()
ctx.setLineDash([])
// Draw diagonal strikethrough for superseded nodes (visual clarity)
const strikeR = radius * 0.55
ctx.beginPath()
ctx.moveTo(sx - strikeR, sy - strikeR)
ctx.lineTo(sx + strikeR, sy + strikeR)
ctx.strokeStyle = colors.textMuted
ctx.lineWidth = 1.5
ctx.stroke()
ctx.globalAlpha = prevAlpha
return
}
// Drop shadow for selected/hovered memory nodes
if (isSelected || isHovered) {
ctx.save()
const shadowColor = isSelected ? colors.accent : colors.glowColor
ctx.shadowColor = shadowColor
ctx.shadowBlur = isSelected ? 18 : 12
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
}
ctx.fillStyle = isHovered ? colors.memFillHover : colors.memFill
drawHexagon(ctx, sx, sy, radius)
ctx.fill()
const borderColor = node.borderColor || colors.memStrokeDefault
ctx.strokeStyle = isSelected ? colors.accent : borderColor
ctx.lineWidth = isSelected ? 2.5 : isHovered ? 2 : 1.5
ctx.stroke()
if (isSelected || isHovered) {
ctx.restore()
}
// Draw X icon for forgotten nodes
if (isForgotten && size > 14) {
const iconR = radius * 0.3
ctx.save()
ctx.strokeStyle = colors.memBorderForgotten
ctx.lineWidth = Math.max(1.5, size / 20)
ctx.lineCap = "round"
ctx.globalAlpha = 0.9
ctx.beginPath()
ctx.moveTo(sx - iconR, sy - iconR)
ctx.lineTo(sx + iconR, sy + iconR)
ctx.moveTo(sx + iconR, sy - iconR)
ctx.lineTo(sx - iconR, sy + iconR)
ctx.stroke()
ctx.restore()
}
}
function drawGlow(
ctx: CanvasRenderingContext2D,
sx: number,
sy: number,
size: number,
nodeType: "document" | "memory",
colors: GraphThemeColors,
isHoverOnly = false,
): void {
ctx.strokeStyle = colors.glowColor
ctx.lineWidth = isHoverOnly ? 1.5 : 2
ctx.setLineDash(isHoverOnly ? [4, 4] : [3, 3])
ctx.globalAlpha = isHoverOnly ? 0.5 : 0.8
const scale = isHoverOnly ? 1.1 : 1.15
if (nodeType === "document") {
const glowSize = size * scale
const half = glowSize * 0.5
const r = 8 * (glowSize / 50)
roundRect(ctx, sx - half, sy - half, glowSize, glowSize, r)
} else {
drawHexagon(ctx, sx, sy, size * 0.5 * scale)
}
ctx.stroke()
ctx.setLineDash([])
ctx.globalAlpha = 1
}
function drawHexagon(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
radius: number,
): void {
ctx.beginPath()
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i - Math.PI / 6
const x = cx + radius * Math.cos(angle)
const y = cy + radius * Math.sin(angle)
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
}
ctx.closePath()
}
/** Lighten a 6-digit hex color by a fraction (0-1). Cached to avoid per-frame parsing. */
export function lightenColor(hex: string, amount: number): string {
if (
_lightenCache &&
_lightenCache.input === hex &&
_lightenCache.amount === amount
) {
return _lightenCache.result
}
const h = hex.replace("#", "")
// Only handle standard 6-digit hex; return input unchanged for other formats
if (h.length !== 6) return hex
const r = Math.min(
255,
Number.parseInt(h.substring(0, 2), 16) + Math.round(255 * amount),
)
const g = Math.min(
255,
Number.parseInt(h.substring(2, 4), 16) + Math.round(255 * amount),
)
const b = Math.min(
255,
Number.parseInt(h.substring(4, 6), 16) + Math.round(255 * amount),
)
const result = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`
_lightenCache = { input: hex, amount, result }
return result
}

View file

@ -0,0 +1,98 @@
import * as d3 from "d3-force"
import type { GraphEdge, GraphNode } from "../types"
import { FORCE_CONFIG } from "../constants"
export class ForceSimulation {
private sim: d3.Simulation<GraphNode, GraphEdge> | null = null
init(nodes: GraphNode[], edges: GraphEdge[]): void {
this.destroy()
try {
// Only use structural edges (derives, updates) for the force layout.
// "extends" edges are visual-only -- they connect documents sharing a
// spaceId but should not pull documents together into a single mass.
const structuralEdges = edges.filter((e) => e.edgeType !== "extends")
this.sim = d3
.forceSimulation<GraphNode>(nodes)
.alphaDecay(FORCE_CONFIG.alphaDecay)
.alphaMin(FORCE_CONFIG.alphaMin)
.velocityDecay(FORCE_CONFIG.velocityDecay)
this.sim.force(
"link",
d3
.forceLink<GraphNode, GraphEdge>(structuralEdges)
.id((d) => d.id)
.distance((link) =>
link.edgeType === "derives"
? FORCE_CONFIG.docMemoryDistance
: FORCE_CONFIG.linkDistance,
)
.strength((link) => {
if (link.edgeType === "derives")
return FORCE_CONFIG.linkStrength.docMemory
if (link.edgeType === "updates")
return FORCE_CONFIG.linkStrength.version
return FORCE_CONFIG.linkStrength.fallback
}),
)
this.sim.force(
"charge",
d3.forceManyBody<GraphNode>().strength(FORCE_CONFIG.chargeStrength),
)
this.sim.force(
"collide",
d3
.forceCollide<GraphNode>()
.radius((d) =>
d.type === "document"
? FORCE_CONFIG.collisionRadius.document
: FORCE_CONFIG.collisionRadius.memory,
)
.strength(FORCE_CONFIG.collisionStrength),
)
this.sim.force("x", d3.forceX().strength(FORCE_CONFIG.centeringStrength))
this.sim.force("y", d3.forceY().strength(FORCE_CONFIG.centeringStrength))
this.sim.stop()
this.sim.alpha(1)
for (let i = 0; i < FORCE_CONFIG.preSettleTicks; i++) this.sim.tick()
this.sim.alphaTarget(0).restart()
} catch (e) {
console.error("ForceSimulation.init failed:", e)
this.destroy()
}
}
update(nodes: GraphNode[], edges: GraphEdge[]): void {
if (!this.sim) return
this.sim.nodes(nodes)
const linkForce = this.sim.force<d3.ForceLink<GraphNode, GraphEdge>>("link")
if (linkForce)
linkForce.links(edges.filter((e) => e.edgeType !== "extends"))
}
reheat(): void {
this.sim?.alphaTarget(FORCE_CONFIG.alphaTarget).restart()
}
coolDown(): void {
this.sim?.alphaTarget(0)
}
isActive(): boolean {
return (this.sim?.alpha() ?? 0) > FORCE_CONFIG.alphaMin
}
destroy(): void {
if (this.sim) {
this.sim.stop()
this.sim = null
}
}
}

View file

@ -0,0 +1,97 @@
import type { GraphApiDocument, GraphApiMemory } from "../types"
export interface ChainEntry {
id: string
version: number
memory: string
isForgotten: boolean
isLatest: boolean
}
export class VersionChainIndex {
private memoryMap = new Map<string, GraphApiMemory>()
private childrenMap = new Map<string, string[]>()
private cache = new Map<string, ChainEntry[]>()
private lastDocs: GraphApiDocument[] | null = null
rebuild(documents: GraphApiDocument[]): void {
if (documents === this.lastDocs) return
this.lastDocs = documents
this.memoryMap.clear()
this.childrenMap.clear()
this.cache.clear()
for (const doc of documents) {
for (const m of doc.memories) {
this.memoryMap.set(m.id, m)
if (m.parentMemoryId) {
let children = this.childrenMap.get(m.parentMemoryId)
if (!children) {
children = []
this.childrenMap.set(m.parentMemoryId, children)
}
children.push(m.id)
}
}
}
}
getChain(memoryId: string): ChainEntry[] | null {
const cached = this.cache.get(memoryId)
if (cached) return cached
const mem = this.memoryMap.get(memoryId)
if (!mem) return null
// Walk backward to root
const backward: GraphApiMemory[] = []
const visited = new Set<string>()
let current: GraphApiMemory | undefined = mem
while (current && !visited.has(current.id)) {
visited.add(current.id)
backward.push(current)
current = current.parentMemoryId
? this.memoryMap.get(current.parentMemoryId)
: undefined
}
backward.reverse()
// Walk forward from the selected node to find descendants.
// Version chains are linear (each memory has one parent), so we
// follow the first child at each step. If branching occurs, only
// the first branch (by document order) is included.
const forward: GraphApiMemory[] = []
let cursor: GraphApiMemory | undefined = mem
while (cursor) {
const children = this.childrenMap.get(cursor.id)
if (!children || children.length === 0) break
const firstChildId = children[0]
if (!firstChildId) break
const child = this.memoryMap.get(firstChildId)
if (!child || visited.has(child.id)) break
visited.add(child.id)
forward.push(child)
cursor = child
}
// Combine: backward (root..selected) + forward (selected+1..latest)
const all = [...backward, ...forward]
// A single-entry chain (standalone v1 with no children) is not useful
if (all.length <= 1) return null
const chain: ChainEntry[] = all.map((m) => ({
id: m.id,
version: m.version,
memory: m.memory,
isForgotten: m.isForgotten,
isLatest: m.isLatest,
}))
for (const entry of chain) {
this.cache.set(entry.id, chain)
}
return chain
}
}

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,442 @@
"use client"
import { memo, useState } from "react"
import type { GraphEdge, GraphNode, GraphThemeColors } from "../types"
import { useIsMobile } from "@/hooks/use-mobile"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/ui/collapsible"
import { GlassMenuEffect } from "@/ui/glass-effect"
import { Brain, ChevronDown, ChevronUp, FileText } from "lucide-react"
import { memo, useEffect, useState } from "react"
import { colors } from "@/constants"
import type { GraphEdge, GraphNode, LegendProps } from "@/types"
import * as styles from "./legend.css"
// Cookie utility functions for legend state
const setCookie = (name: string, value: string, days = 365) => {
if (typeof document === "undefined") return
const expires = new Date()
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`
}
const getCookie = (name: string): string | null => {
if (typeof document === "undefined") return null
const nameEQ = `${name}=`
const ca = document.cookie.split(";")
for (let i = 0; i < ca.length; i++) {
let c = ca[i]
if (!c) continue
while (c.charAt(0) === " ") c = c.substring(1, c.length)
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length)
}
return null
}
interface ExtendedLegendProps extends LegendProps {
id?: string
interface LegendProps {
nodes?: GraphNode[]
edges?: GraphEdge[]
isLoading?: boolean
colors: GraphThemeColors
}
function HexagonIcon({
fill,
stroke,
size = 12,
}: {
fill: string
stroke: string
size?: number
}) {
return (
<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: `2.5px ${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 }}
aria-hidden="true"
>
<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 }}
aria-hidden="true"
>
<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.edgeDerives} />
<span style={edgeLabelStyle}>Derives</span>
</div>
</div>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeUpdates} />
<span style={edgeLabelStyle}>Updates</span>
</div>
</div>
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeExtends} dashed />
<span style={edgeLabelStyle}>Extends</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,638 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { ChainEntry } from "../canvas/version-chain"
import type {
DocumentNodeData,
GraphNode,
GraphThemeColors,
MemoryNodeData,
} from "../types"
export interface NodeHoverPopoverProps {
node: GraphNode
screenX: number
screenY: number
nodeRadius: number
containerBounds?: DOMRect
versionChain?: ChainEntry[] | null
colors: GraphThemeColors
onNavigateNext?: () => void
onNavigatePrev?: () => void
onNavigateUp?: () => void
onNavigateDown?: () => void
onSelectNode?: (nodeId: string) => void
}
function useCopyToClipboard(timeout = 2000) {
const [copied, setCopied] = useState(false)
const timeoutRef = useRef<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.35,
version: 0.6,
docDocBase: 0.0,
fallback: 0.05,
},
linkDistance: 300, // Desired spring length
// Charge force (repulsion between nodes)
chargeStrength: -1000, // Negative = repulsion, higher magnitude = stronger push
// Collision force (prevents node overlap)
collisionRadius: {
document: 80, // Collision radius for document nodes
memory: 40, // Collision radius for memory nodes
},
// Simulation behavior
alphaDecay: 0.03, // How fast simulation cools down (higher = faster cooldown)
alphaMin: 0.001, // Threshold to stop simulation (when alpha drops below this)
velocityDecay: 0.6, // Friction/damping (0 = no friction, 1 = instant stop) - increased for less movement
alphaTarget: 0.3, // Target alpha when reheating (on drag start)
linkDistance: 300,
docMemoryDistance: 180,
chargeStrength: -2000,
collisionRadius: { document: 70, memory: 35 },
collisionStrength: 0.7,
centeringStrength: 0.06,
alphaDecay: 0.025,
alphaMin: 0.001,
velocityDecay: 0.45,
alphaTarget: 0.3,
preSettleTicks: 150,
}
// Graph view settings
export const GRAPH_SETTINGS = {
console: {
initialZoom: 0.8, // Higher zoom for console - better overview
initialPanX: 0,
initialPanY: 0,
},
consumer: {
initialZoom: 0.5, // Changed from 0.1 to 0.5 for better initial visibility
initialPanX: 400, // Pan towards center to compensate for larger layout
initialPanY: 300, // Pan towards center to compensate for larger layout
},
console: { initialZoom: 0.8, initialPanX: 0, initialPanY: 0 },
consumer: { initialZoom: 0.5, initialPanX: 400, initialPanY: 300 },
}
// Animation settings
export const ANIMATION = {
// Dim effect duration - shortened for better UX
dimDuration: 1500, // milliseconds
dimDuration: 1500,
}
// Responsive positioning for different app variants
export const POSITIONING = {
console: {
legend: {
desktop: "bottom-4 right-4",
mobile: "bottom-4 right-4",
},
loadingIndicator: "top-20 right-4",
spacesSelector: "top-4 left-4",
viewToggle: "", // Not used in console
nodeDetail: "top-4 right-4",
},
consumer: {
legend: {
desktop: "top-18 right-4",
mobile: "bottom-[180px] left-4",
},
loadingIndicator: "top-20 right-4",
spacesSelector: "", // Hidden in consumer
viewToggle: "top-4 right-4", // Consumer has view toggle
nodeDetail: "top-4 right-4",
},
export const DEFAULT_COLORS: GraphThemeColors = {
bg: "#0f1419",
docFill: "#1B1F24",
docStroke: "#2A2F36",
docInnerFill: "#13161A",
memFill: "#0D2034",
memFillHover: "#112840",
memStrokeDefault: "#3B73B8",
accent: "#3B73B8",
textPrimary: "#ffffff",
textSecondary: "#e2e8f0",
textMuted: "#94a3b8",
edgeDerives: "#7094B8",
edgeUpdates: "#A78BFA",
edgeExtends: "#38BDF8",
memBorderForgotten: "#EF4444",
memBorderExpiring: "#F59E0B",
memBorderRecent: "#10B981",
glowColor: "#3B73B8",
iconColor: "#3B73B8",
popoverBg: "#1a1f29",
popoverBorder: "#2A2F36",
popoverTextPrimary: "#ffffff",
popoverTextSecondary: "#e2e8f0",
popoverTextMuted: "#94a3b8",
controlBg: "#1a1f29",
controlBorder: "#2A2F36",
}

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,245 @@
"use client"
import {
calculateSemanticSimilarity,
getConnectionVisualProps,
getMagicalConnectionColor,
} from "@/lib/similarity"
import { useMemo, useRef, useEffect } from "react"
import { colors, LAYOUT_CONSTANTS, SIMILARITY_CONFIG } from "@/constants"
import { useEffect, useMemo, useRef } from "react"
import type {
DocumentsResponse,
DocumentWithMemories,
DocumentNodeData,
GraphApiDocument,
GraphApiMemory,
GraphEdge,
GraphNode,
MemoryEntry,
MemoryRelation,
} from "@/types"
GraphThemeColors,
MemoryNodeData,
} from "../types"
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
const ONE_DAY_MS = 24 * 60 * 60 * 1000
const MEMORY_ORBIT_BASE = 200
export function getMemoryBorderColor(
mem: GraphApiMemory,
colors: GraphThemeColors,
): string {
if (mem.isForgotten) return colors.memBorderForgotten
if (mem.forgetAfter) {
const msLeft = new Date(mem.forgetAfter).getTime() - Date.now()
if (msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring
}
const age = Date.now() - new Date(mem.createdAt).getTime()
if (age < ONE_DAY_MS) return colors.memBorderRecent
return colors.memStrokeDefault
}
export function getEdgeVisualProps(edgeType: string) {
switch (edgeType) {
case "derives":
return { opacity: 0.4, thickness: 1.2 }
case "updates":
return { opacity: 0.7, thickness: 2 }
case "extends":
return { opacity: 0.55, thickness: 1.5 }
default:
return { opacity: 0.4, thickness: 1.2 }
}
}
/**
* Simple deterministic hash of a string to a number in [0, 1).
* Used for initial node placement so the force simulation has a
* deterministic starting layout.
*/
function hashToUnit(str: string): number {
let h = 0
for (let i = 0; i < str.length; i++) {
h = (Math.imul(31, h) + str.charCodeAt(i)) | 0
}
return ((h >>> 0) % 10000) / 10000
}
/**
* Pure function that computes graph edges from documents.
* Extracted from the hook for testability.
*/
export function computeEdges(documents: GraphApiDocument[]): GraphEdge[] {
if (!documents || documents.length === 0) return []
const result: GraphEdge[] = []
const allNodeIds = new Set<string>()
for (const doc of documents) {
allNodeIds.add(doc.id)
for (const mem of doc.memories) allNodeIds.add(mem.id)
}
// 1. Derives edges: document -> memory (structural)
for (const doc of documents) {
for (const mem of doc.memories) {
result.push({
id: `dm-${doc.id}-${mem.id}`,
source: doc.id,
target: mem.id,
visualProps: getEdgeVisualProps("derives"),
edgeType: "derives",
})
}
}
// 2. Memory-to-memory relation edges from backend data.
// Uses memoryRelations (Record<targetId, relationType>) as primary source,
// falls back to parentMemoryId for legacy data.
for (const doc of documents) {
for (const mem of doc.memories) {
let relations: Record<string, string> = {}
// Defensive: API may return unexpected types at runtime
if (
mem.memoryRelations &&
typeof mem.memoryRelations === "object" &&
Object.keys(mem.memoryRelations).length > 0
) {
relations = mem.memoryRelations
} else if (mem.parentMemoryId) {
// Legacy fallback: parentMemoryId implies "updates"
relations = { [mem.parentMemoryId]: "updates" }
}
for (const [targetId, relationType] of Object.entries(relations)) {
if (!allNodeIds.has(targetId)) continue
const edgeType =
relationType === "updates" ||
relationType === "extends" ||
relationType === "derives"
? relationType
: "updates"
result.push({
id: `rel-${targetId}-${mem.id}`,
source: targetId,
target: mem.id,
visualProps: getEdgeVisualProps(edgeType),
edgeType,
})
}
}
}
return result
}
export function useGraphData(
data: DocumentsResponse | null,
selectedSpace: string,
nodePositions: Map<
string,
{
x: number
y: number
parentDocId?: string
offsetX?: number
offsetY?: number
}
>,
documents: GraphApiDocument[],
draggingNodeId: string | null,
memoryLimit?: number,
maxNodes?: number,
canvasWidth: number,
canvasHeight: number,
colors: GraphThemeColors,
) {
// Cache nodes to preserve d3-force mutations (x, y, vx, vy, fx, fy)
const nodeCache = useRef<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 nodes = useMemo(() => {
if (!documents || documents.length === 0) return []
// Sort documents by most recent first
const sortedDocs = [...data.documents].sort((a, b) => {
const dateA = new Date(a.updatedAt || a.createdAt).getTime()
const dateB = new Date(b.updatedAt || b.createdAt).getTime()
return dateB - dateA // Most recent first
})
const result: GraphNode[] = []
// Spiral layout: documents form a compact spiral core, memories orbit
// around their parent documents. The force simulation then gently
// pushes memories outward to create the constellation/starburst effect.
const cx = canvasWidth / 2
const cy = canvasHeight / 2
const docCount = documents.length
// Wide spiral so documents start well-separated. The simulation
// refines positions but the initial spread prevents clustering.
const spiralScale = Math.sqrt(docCount) * 60
// Golden angle (~137.5 deg) produces optimal packing in a spiral
const goldenAngle = Math.PI * (3 - Math.sqrt(5))
// Filter by space and prepare documents
let processedDocs = sortedDocs.map((doc) => {
let memories =
selectedSpace === "all"
? doc.memoryEntries
: doc.memoryEntries.filter(
(memory) =>
(memory.spaceContainerTag ?? memory.spaceId ?? "default") ===
selectedSpace,
)
for (let docIdx = 0; docIdx < docCount; docIdx++) {
const doc = documents[docIdx]
const angle = docIdx * goldenAngle
const radius = spiralScale * Math.sqrt((docIdx + 1) / docCount)
const initialX = cx + Math.cos(angle) * radius
const initialY = cy + Math.sin(angle) * radius
// Sort memories by relevance score (if available) or recency
memories = memories.sort((a, b) => {
// Prioritize sourceRelevanceScore if available
if (a.sourceRelevanceScore != null && b.sourceRelevanceScore != null) {
return b.sourceRelevanceScore - a.sourceRelevanceScore // Higher score first
}
// Fall back to most recent
const dateA = new Date(a.updatedAt || a.createdAt).getTime()
const dateB = new Date(b.updatedAt || b.createdAt).getTime()
return dateB - dateA // Most recent first
})
return {
...doc,
memoryEntries: memories,
let docNode = nodeCache.current.get(doc.id)
const docData: DocumentNodeData = {
id: doc.id,
title: doc.title,
summary: doc.summary,
type: doc.documentType,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
memories: doc.memories,
}
})
// Apply maxNodes limit using Option B (dynamic cap per document)
if (maxNodes && maxNodes > 0) {
const totalDocs = processedDocs.length
if (totalDocs > 0) {
// Calculate memories per document to stay within maxNodes budget
const memoriesPerDoc = Math.floor(maxNodes / totalDocs)
if (docNode) {
docNode.data = docData
docNode.borderColor = colors.docStroke
docNode.isDragging = draggingNodeId === doc.id
} else {
docNode = {
id: doc.id,
type: "document",
x: initialX,
y: initialY,
data: docData,
size: 50,
borderColor: colors.docStroke,
isHovered: false,
isDragging: false,
}
nodeCache.current.set(doc.id, docNode)
}
result.push(docNode)
// If we need to limit, slice memories for each document
if (memoriesPerDoc > 0) {
let totalNodes = 0
processedDocs = processedDocs.map((doc) => {
// Limit memories to calculated amount per doc
const limitedMemories = doc.memoryEntries.slice(0, memoriesPerDoc)
totalNodes += limitedMemories.length
return {
...doc,
memoryEntries: limitedMemories,
}
})
const memCount = doc.memories.length
for (let i = 0; i < memCount; i++) {
const mem = doc.memories[i]
if (!mem) continue
let memNode = nodeCache.current.get(mem.id)
const memData: MemoryNodeData = {
...mem,
documentId: doc.id,
content: mem.memory,
}
// If we still have budget left, distribute remaining nodes to first docs
let remainingBudget = maxNodes - totalNodes
if (remainingBudget > 0) {
for (
let i = 0;
i < processedDocs.length && remainingBudget > 0;
i++
) {
const doc = processedDocs[i]
if (!doc) continue
const originalDoc = sortedDocs.find((d) => d.id === doc.id)
if (!originalDoc) continue
const currentMemCount = doc.memoryEntries.length
const originalMemCount = originalDoc.memoryEntries.filter(
(m) =>
selectedSpace === "all" ||
(m.spaceContainerTag ?? m.spaceId ?? "default") ===
selectedSpace,
).length
// Can we add more memories to this doc?
const canAdd = originalMemCount - currentMemCount
if (canAdd > 0) {
const toAdd = Math.min(canAdd, remainingBudget)
const additionalMems = doc.memoryEntries.slice(
0,
currentMemCount + toAdd,
)
processedDocs[i] = {
...doc,
memoryEntries: originalDoc.memoryEntries
.filter(
(m) =>
selectedSpace === "all" ||
(m.spaceContainerTag ?? m.spaceId ?? "default") ===
selectedSpace,
)
.sort((a, b) => {
if (
a.sourceRelevanceScore != null &&
b.sourceRelevanceScore != null
) {
return b.sourceRelevanceScore - a.sourceRelevanceScore
}
const dateA = new Date(
a.updatedAt || a.createdAt,
).getTime()
const dateB = new Date(
b.updatedAt || b.createdAt,
).getTime()
return dateB - dateA
})
.slice(0, currentMemCount + toAdd),
}
remainingBudget -= toAdd
}
}
}
if (memNode) {
memNode.data = memData
memNode.borderColor = getMemoryBorderColor(mem, colors)
memNode.isDragging = draggingNodeId === mem.id
} else {
// If memoriesPerDoc is 0, we need to limit the number of documents shown
// Show at least 1 memory per document, up to maxNodes documents
processedDocs = processedDocs.slice(0, maxNodes).map((doc) => ({
...doc,
memoryEntries: doc.memoryEntries.slice(0, 1),
}))
}
}
}
// Apply legacy memoryLimit if provided and a specific space is selected
else if (selectedSpace !== "all" && memoryLimit && memoryLimit > 0) {
processedDocs = processedDocs.map((doc) => ({
...doc,
memoryEntries: doc.memoryEntries.slice(0, memoryLimit),
}))
}
return processedDocs
}, [data, selectedSpace, memoryLimit, maxNodes])
// Memo 2: Calculate similarity edges using k-NN approach
const similarityEdges = useMemo(() => {
const edges: GraphEdge[] = []
// k-NN: Each document compares with k neighbors (configurable)
const { maxComparisonsPerDoc, threshold } = SIMILARITY_CONFIG
for (let i = 0; i < filteredDocuments.length; i++) {
const docI = filteredDocuments[i]
if (!docI) continue
// Only compare with next k documents (k-nearest neighbors approach)
const endIdx = Math.min(
i + maxComparisonsPerDoc + 1,
filteredDocuments.length,
)
for (let j = i + 1; j < endIdx; j++) {
const docJ = filteredDocuments[j]
if (!docJ) continue
const sim = calculateSemanticSimilarity(
docI.summaryEmbedding ? Array.from(docI.summaryEmbedding) : null,
docJ.summaryEmbedding ? Array.from(docJ.summaryEmbedding) : null,
)
if (sim > threshold) {
edges.push({
id: `doc-doc-${docI.id}-${docJ.id}`,
source: docI.id,
target: docJ.id,
similarity: sim,
visualProps: getConnectionVisualProps(sim),
color: getMagicalConnectionColor(sim, 200),
edgeType: "doc-doc",
})
}
}
}
return edges
}, [filteredDocuments])
// Memo 3: Build full graph data (nodes + edges)
return useMemo(() => {
if (!data?.documents || filteredDocuments.length === 0) {
return { nodes: [], edges: [] }
}
const allNodes: GraphNode[] = []
const allEdges: GraphEdge[] = []
// Group documents by space for better clustering
const documentsBySpace = new Map<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,
// Place memories in a ring around their parent document,
// with slight randomness from hash for organic feel
const memAngle =
(i / memCount) * 2 * Math.PI + hashToUnit(mem.id) * 0.5
const memRadius = MEMORY_ORBIT_BASE + hashToUnit(`${mem.id}-r`) * 120
memNode = {
id: mem.id,
type: "memory",
x: docNode.x + Math.cos(memAngle) * memRadius,
y: docNode.y + Math.sin(memAngle) * memRadius,
data: memData,
size: 36,
borderColor: getMemoryBorderColor(mem, colors),
isHovered: false,
isDragging: draggingNodeId === doc.id,
} satisfies GraphNode
nodeCache.current.set(doc.id, node)
}
documentNodes.push(node)
})
spaceIndex++
})
/* 2. Manual collision avoidance removed - now handled by d3-force simulation */
// The initial circular layout provides good starting positions
// D3-force will handle collision avoidance and spacing dynamically
allNodes.push(...documentNodes)
/* 3. Add memories around documents WITH doc-memory connections */
documentNodes.forEach((docNode) => {
const memoryNodeMap = new Map<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)
}
if (!memoryNodeMap.has(memoryId)) {
// Check if memory node exists in cache (preserves d3-force mutations)
let memoryNode = nodeCache.current.get(memoryId)
if (memoryNode) {
// Update existing node's data, preserve physics properties
memoryNode.data = memory
memoryNode.isDragging = draggingNodeId === memoryId
// Don't reset x/y - they're managed by d3-force
} else {
// Create new node with initial position
memoryNode = {
id: memoryId,
type: "memory",
x: finalMemX,
y: finalMemY,
data: memory,
size: Math.max(
32,
Math.min(48, (memory.memory?.length || 50) * 0.5),
),
color: colors.memory.primary,
isHovered: false,
isDragging: draggingNodeId === memoryId,
}
nodeCache.current.set(memoryId, memoryNode)
}
memoryNodeMap.set(memoryId, memoryNode)
allNodes.push(memoryNode)
}
// Create doc-memory edge with similarity
allEdges.push({
id: `edge-${docNode.id}-${memory.id}`,
source: docNode.id,
target: memoryId,
similarity: 1,
visualProps: getConnectionVisualProps(1),
color: colors.connection.memory,
edgeType: "doc-memory",
})
})
})
// Build mapping of memoryId -> nodeId for version chains
const memNodeIdMap = new Map<string, string>()
allNodes.forEach((n) => {
if (n.type === "memory") {
memNodeIdMap.set((n.data as MemoryEntry).id, n.id)
result.push(memNode)
}
})
}
// Add version-chain edges (old -> new)
data.documents.forEach((doc) => {
doc.memoryEntries.forEach((mem: MemoryEntry) => {
// Support both new object structure and legacy array/single parent fields
let parentRelations: Record<string, MemoryRelation> =
(mem.memoryRelations ?? {}) as Record<string, MemoryRelation>
return result
}, [documents, canvasWidth, canvasHeight, draggingNodeId, colors])
if (
mem.memoryRelations &&
Array.isArray(mem.memoryRelations) &&
mem.memoryRelations.length > 0
) {
// Convert array to Record
parentRelations = mem.memoryRelations.reduce(
(acc, rel) => {
acc[rel.targetMemoryId] = rel.relationType
return acc
},
{} as Record<string, MemoryRelation>,
)
} else if (mem.parentMemoryId) {
parentRelations = {
[mem.parentMemoryId]: "updates" as MemoryRelation,
}
}
Object.entries(parentRelations).forEach(([pid, relationType]) => {
const fromId = memNodeIdMap.get(pid)
const toId = memNodeIdMap.get(mem.id)
if (fromId && toId) {
allEdges.push({
id: `version-${fromId}-${toId}`,
source: fromId,
target: toId,
similarity: 1,
visualProps: {
opacity: 0.8,
thickness: 1,
glow: 0,
pulseDuration: 3000,
},
// choose color based on relation type
color: colors.relations[relationType] ?? colors.relations.updates,
edgeType: "version",
relationType: relationType as MemoryRelation,
})
}
})
})
})
const edges = useMemo(() => computeEdges(documents), [documents])
// Append similarity edges (calculated in separate memo)
allEdges.push(...similarityEdges)
return { nodes: allNodes, edges: allEdges }
}, [data, filteredDocuments, nodePositions, draggingNodeId, similarityEdges])
return { nodes, edges }
}

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,121 @@
import { useEffect, useMemo, useState } from "react"
import type { GraphThemeColors } from "../types"
import { DEFAULT_COLORS } from "../constants"
function readCssVar(name: string, fallback: string): string {
if (typeof document === "undefined") return fallback
const val = getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim()
return val || fallback
}
function resolveColors(): GraphThemeColors {
return {
bg: readCssVar("--graph-bg", DEFAULT_COLORS.bg),
docFill: readCssVar("--graph-doc-fill", DEFAULT_COLORS.docFill),
docStroke: readCssVar("--graph-doc-stroke", DEFAULT_COLORS.docStroke),
docInnerFill: readCssVar("--graph-doc-inner", DEFAULT_COLORS.docInnerFill),
memFill: readCssVar("--graph-mem-fill", DEFAULT_COLORS.memFill),
memFillHover: readCssVar(
"--graph-mem-fill-hover",
DEFAULT_COLORS.memFillHover,
),
memStrokeDefault: readCssVar(
"--graph-mem-stroke",
DEFAULT_COLORS.memStrokeDefault,
),
accent: readCssVar("--graph-accent", DEFAULT_COLORS.accent),
textPrimary: readCssVar("--graph-text-primary", DEFAULT_COLORS.textPrimary),
textSecondary: readCssVar(
"--graph-text-secondary",
DEFAULT_COLORS.textSecondary,
),
textMuted: readCssVar("--graph-text-muted", DEFAULT_COLORS.textMuted),
edgeDerives: readCssVar("--graph-edge-derives", DEFAULT_COLORS.edgeDerives),
edgeUpdates: readCssVar("--graph-edge-updates", DEFAULT_COLORS.edgeUpdates),
edgeExtends: readCssVar("--graph-edge-extends", DEFAULT_COLORS.edgeExtends),
memBorderForgotten: readCssVar(
"--graph-mem-border-forgotten",
DEFAULT_COLORS.memBorderForgotten,
),
memBorderExpiring: readCssVar(
"--graph-mem-border-expiring",
DEFAULT_COLORS.memBorderExpiring,
),
memBorderRecent: readCssVar(
"--graph-mem-border-recent",
DEFAULT_COLORS.memBorderRecent,
),
glowColor: readCssVar("--graph-glow", DEFAULT_COLORS.glowColor),
iconColor: readCssVar("--graph-icon", DEFAULT_COLORS.iconColor),
popoverBg: readCssVar("--graph-popover-bg", DEFAULT_COLORS.popoverBg),
popoverBorder: readCssVar(
"--graph-popover-border",
DEFAULT_COLORS.popoverBorder,
),
popoverTextPrimary: readCssVar(
"--graph-popover-text-primary",
DEFAULT_COLORS.popoverTextPrimary,
),
popoverTextSecondary: readCssVar(
"--graph-popover-text-secondary",
DEFAULT_COLORS.popoverTextSecondary,
),
popoverTextMuted: readCssVar(
"--graph-popover-text-muted",
DEFAULT_COLORS.popoverTextMuted,
),
controlBg: readCssVar("--graph-control-bg", DEFAULT_COLORS.controlBg),
controlBorder: readCssVar(
"--graph-control-border",
DEFAULT_COLORS.controlBorder,
),
}
}
export function useGraphTheme(
overrides?: Partial<GraphThemeColors>,
): 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)
}
}, [])
// Serialize overrides to a stable string key so useMemo only recomputes
// when the actual override values change, not on every render.
const overrideKey = overrides
? Object.entries(overrides)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}:${v}`)
.join(",")
: ""
const merged = useMemo(
() => (overrides ? { ...colors, ...overrides } : colors),
// biome-ignore lint/correctness/useExhaustiveDependencies: overrideKey tracks overrides by value
[colors, overrideKey],
)
return merged
}

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,39 @@
// Export the main component
// Components
export { MemoryGraph } from "./components/memory-graph"
export { GraphCanvas } from "./components/graph-canvas"
// Export style injector for manual use if needed
export { injectStyles } from "./lib/inject-styles"
// Hooks
export { useGraphData } from "./hooks/use-graph-data"
export { useGraphTheme } from "./hooks/use-graph-theme"
// Export types for consumers
export type { MemoryGraphProps } from "./types"
// Engine classes (for advanced usage)
export { ForceSimulation } from "./canvas/simulation"
export { ViewportState } from "./canvas/viewport"
export { SpatialIndex } from "./canvas/hit-test"
export { VersionChainIndex } from "./canvas/version-chain"
// Constants
export { DEFAULT_COLORS, FORCE_CONFIG, GRAPH_SETTINGS } from "./constants"
// Types
export type {
MemoryGraphProps,
GraphNode,
GraphEdge,
GraphThemeColors,
GraphCanvasProps,
GraphApiDocument,
GraphApiMemory,
GraphApiEdge,
DocumentNodeData,
MemoryNodeData,
ChainEntry,
} from "./types"
// Backward-compatible API types
export type {
DocumentWithMemories,
MemoryEntry,
DocumentsResponse,
} from "./api-types"
export type {
GraphNode,
GraphEdge,
MemoryRelation,
} from "./types"
// Export theme system for custom theming
export { themeContract, defaultTheme } from "./styles/theme.css"
export { sprinkles } from "./styles/sprinkles.css"
export type { Sprinkles } from "./styles/sprinkles.css"
} from "./api-types"

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,414 @@
import type { GraphApiDocument, GraphApiMemory } from "./types"
export interface MockGraphOptions {
documentCount?: number
memoriesPerDoc?: number | [number, number]
seed?: number
}
/**
* Simple seeded PRNG (Mulberry32).
* Returns a function that produces values in [0, 1).
*/
function createSeededRandom(seed: number): () => number {
let s = seed | 0
return () => {
s = (s + 0x6d2b79f5) | 0
let t = Math.imul(s ^ (s >>> 15), 1 | s)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const DOCUMENT_TYPES = [
"webpage",
"pdf",
"md",
"doc",
"csv",
"json",
"notion",
"text",
"google_doc",
"google_sheet",
"google_slide",
"tweet",
"youtube",
"image",
"note",
"google_drive",
"video",
"mcp",
"word",
] as const
const TITLE_PREFIXES = [
"Project Planning",
"API Documentation",
"Meeting Minutes",
"Architecture Design",
"Sprint Retrospective",
"User Research",
"Product Roadmap",
"Technical Spec",
"Onboarding Guide",
"Release Notes",
"Performance Report",
"Security Audit",
"Database Schema",
"Deployment Guide",
"Team Standup",
"Bug Triage",
"Feature Request",
"Code Review",
"Integration Test",
"Data Pipeline",
]
const TITLE_SUFFIXES = [
"Notes",
"Q3",
"Q4",
"v2",
"v3",
"Final",
"Draft",
"2024",
"2025",
"Summary",
"Overview",
"Detailed",
"Analysis",
"Report",
"Update",
]
const MEMORY_TEMPLATES = [
"The user prefers {preference} when working with {topic}",
"Key decision: {topic} will be implemented using {approach}",
"Important constraint: {topic} must handle {requirement}",
"The {component} module depends on {dependency} for {purpose}",
"Performance target: {metric} should be under {threshold}",
"The team agreed to {action} before {deadline}",
"User feedback indicates {observation} about {feature}",
"Best practice: always {action} when {condition}",
"Known issue: {component} has {issue} under {condition}",
"Migration plan: move from {oldThing} to {newThing} by {deadline}",
]
const TEMPLATE_FILLS: Record<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[]
} {
const {
documentCount = 100,
memoriesPerDoc = [2, 6] as [number, number],
seed = 42,
} = options
const random = createSeededRandom(seed)
// Time range: roughly Jan 2024 to Jun 2025
const baseTime = new Date("2024-01-01T00:00:00Z").getTime()
const timeRange = 1000 * 60 * 60 * 24 * 540 // ~540 days
const spaceIds = [
"space-default",
"space-work",
"space-personal",
"space-research",
]
const documents: GraphApiDocument[] = []
for (let d = 0; d < documentCount; d++) {
const docId = `doc-${String(d).padStart(4, "0")}`
const docCreatedAt = generateISODate(random, baseTime, timeRange)
const docUpdatedAt = generateISODate(
random,
new Date(docCreatedAt).getTime(),
1000 * 60 * 60 * 24 * 30, // up to 30 days after creation
)
// Determine memory count
let memCount: number
if (typeof memoriesPerDoc === "number") {
memCount = memoriesPerDoc
} else {
const [min, max] = memoriesPerDoc
memCount = min + Math.floor(random() * (max - min + 1))
}
const spaceId = spaceIds[Math.floor(random() * spaceIds.length)]
const memories: GraphApiMemory[] = []
// Decide if this document has a version chain (30% chance)
const hasVersionChain = random() < 0.3 && memCount >= 3
let chainRootId: string | null = null
let chainPrevId: string | null = null
let chainVersion = 1
for (let m = 0; m < memCount; m++) {
const memId = `mem-${docId}-${String(m).padStart(3, "0")}`
const memCreatedAt = generateISODate(
random,
new Date(docCreatedAt).getTime(),
1000 * 60 * 60 * 24 * 14,
)
const memUpdatedAt = generateISODate(
random,
new Date(memCreatedAt).getTime(),
1000 * 60 * 60 * 24 * 7,
)
let parentMemoryId: string | null = null
let rootMemoryId: string | null = null
let version = 1
let isLatest = true
let isForgotten = false
let memoryRelations: Record<string, "updates" | "extends" | "derives"> =
{}
// Build version chain for first few memories if applicable
if (hasVersionChain && m < 3) {
if (m === 0) {
// Root of the chain
chainRootId = memId
chainPrevId = memId
chainVersion = 1
version = 1
isLatest = false
isForgotten = random() < 0.3
} else {
parentMemoryId = chainPrevId
rootMemoryId = chainRootId
chainVersion++
version = chainVersion
chainPrevId = memId
isLatest = m === 2 // last in the 3-memory chain
isForgotten = !isLatest && random() < 0.2
// Add "updates" relation to parent
if (parentMemoryId) {
memoryRelations = { [parentMemoryId]: "updates" }
}
}
} else {
// Standalone memory
isForgotten = random() < 0.1
isLatest = true
version = 1
// Randomly add extends/derives relations to earlier memories in this doc
if (m > 0 && random() < 0.2) {
const targetIdx = Math.floor(random() * m)
const targetMem = memories[targetIdx]
if (targetMem) {
const relType = random() < 0.5 ? "extends" : "derives"
memoryRelations = {
[targetMem.id]: relType,
}
}
}
}
// Determine forgetAfter (for some non-forgotten memories, set a future expiry)
let forgetAfter: string | null = null
let forgetReason: string | null = null
if (isForgotten) {
forgetReason = random() < 0.5 ? "superseded" : "user-requested"
} else if (random() < 0.1) {
// Expiring memory
const expiryMs =
Date.now() + Math.floor(random() * 1000 * 60 * 60 * 24 * 30)
forgetAfter = new Date(expiryMs).toISOString()
}
memories.push({
id: memId,
memory: generateMemoryContent(random),
isStatic: random() < 0.15,
spaceId,
isLatest,
isForgotten,
forgetAfter,
forgetReason,
version,
parentMemoryId,
rootMemoryId,
createdAt: memCreatedAt,
updatedAt: memUpdatedAt,
memoryRelations:
Object.keys(memoryRelations).length > 0 ? memoryRelations : undefined,
})
}
documents.push({
id: docId,
title: generateTitle(random),
summary: generateSummary(random),
documentType:
DOCUMENT_TYPES[Math.floor(random() * DOCUMENT_TYPES.length)],
createdAt: docCreatedAt,
updatedAt: docUpdatedAt,
memories,
})
}
return { documents }
}

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,204 @@
import type {
DocumentsResponse,
DocumentWithMemories,
MemoryEntry,
} from "./api-types"
// Graph API types matching backend response
// Re-export for convenience
export type { DocumentsResponse, DocumentWithMemories, MemoryEntry }
import type { MemoryRelation } from "./api-types"
export interface GraphApiMemory {
id: string
memory: string
content?: string | null
isStatic: boolean
spaceId: string
isLatest: boolean
isForgotten: boolean
forgetAfter: string | null
forgetReason: string | null
version: number
parentMemoryId: string | null
rootMemoryId: string | null
createdAt: string
updatedAt: string
// Relation fields from backend
relation?: MemoryRelation | null
updatesMemoryId?: string | null
nextVersionId?: string | null
memoryRelations?: Record<string, MemoryRelation> | null
// Source/join fields
spaceContainerTag?: string | null
}
export interface GraphApiDocument {
id: string
title: string | null
summary: string | null
documentType: string
createdAt: string
updatedAt: string
memories: GraphApiMemory[]
}
export interface GraphApiEdge {
source: string
target: string
edgeType: MemoryRelation
}
// Typed node data
export interface DocumentNodeData {
id: string
title: string | null
summary: string | null
type: string
createdAt: string
updatedAt: string
memories: GraphApiMemory[]
}
export interface MemoryNodeData {
id: string
memory: string
content: string
documentId: string
isStatic: boolean
isLatest: boolean
isForgotten: boolean
forgetAfter: string | null
forgetReason: string | null
version: number
parentMemoryId: string | null
spaceId: string
createdAt: string
updatedAt: string
relation?: MemoryRelation | null
memoryRelations?: Record<string, MemoryRelation> | null
}
export interface GraphNode {
id: string
type: "document" | "memory"
x: number
y: number
data: DocumentWithMemories | MemoryEntry
data: DocumentNodeData | MemoryNodeData
size: number
color: string
borderColor: string
isHovered: boolean
isDragging: boolean
// D3-force simulation properties
vx?: number // velocity x
vy?: number // velocity y
fx?: number | null // fixed x position (for pinning during drag)
fy?: number | null // fixed y position (for pinning during drag)
vx?: number
vy?: number
fx?: number | null
fy?: number | null
}
export type MemoryRelation = "updates" | "extends" | "derives"
export interface GraphEdge {
id: string
// D3-force mutates source/target from string IDs to node references during simulation
source: string | GraphNode
target: string | GraphNode
similarity: number
visualProps: {
opacity: number
thickness: number
glow: number
pulseDuration: number
}
color: string
edgeType: "doc-memory" | "doc-doc" | "version"
relationType?: MemoryRelation
edgeType: MemoryRelation
}
export interface SpacesDropdownProps {
selectedSpace: string
availableSpaces: string[]
spaceMemoryCounts: Record<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
edgeDerives: string
edgeUpdates: string
edgeExtends: string
memBorderForgotten: string
memBorderExpiring: string
memBorderRecent: string
glowColor: string
iconColor: string
popoverBg: string
popoverBorder: string
popoverTextPrimary: string
popoverTextSecondary: string
popoverTextMuted: string
controlBg: string
controlBorder: string
}
export interface GraphCanvasProps {
nodes: GraphNode[]
edges: GraphEdge[]
panX: number
panY: number
zoom: number
width: number
height: number
onNodeHover: (nodeId: string | null) => void
onNodeClick: (nodeId: string) => void
onNodeDragStart: (nodeId: string, e: React.MouseEvent) => void
onNodeDragMove: (e: React.MouseEvent) => void
onNodeDragEnd: () => void
onPanStart: (e: React.MouseEvent) => void
onPanMove: (e: React.MouseEvent) => void
onPanEnd: () => void
onWheel: (e: React.WheelEvent) => void
onDoubleClick: (e: React.MouseEvent) => void
onTouchStart?: (e: React.TouchEvent) => void
onTouchMove?: (e: React.TouchEvent) => void
onTouchEnd?: (e: React.TouchEvent) => void
draggingNodeId: string | null
// Optional list of document IDs (customId or internal id) to highlight
colors: GraphThemeColors
highlightDocumentIds?: string[]
// Physics simulation state
isSimulationActive?: boolean
// Selected node ID - dims all other nodes and edges
selectedNodeId?: string | null
onNodeHover: (nodeId: string | null) => void
onNodeClick: (nodeId: string | null) => void
onNodeDragStart: (nodeId: string) => void
onNodeDragEnd: () => void
onViewportChange?: (zoom: number, popoverVisible: boolean) => void
canvasRef?: React.RefObject<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[]
/** Whether data is loading */
isLoading?: boolean
/** Error that occurred during data fetching */
error?: Error | null
/** Optional children to render when no documents exist */
children?: React.ReactNode
/** Whether more data is being loaded (for pagination) */
/** Whether more data is being loaded */
isLoadingMore?: boolean
/** Total number of documents loaded */
totalLoaded?: number
/** Callback to load more documents */
onLoadMore?: () => void
/** Whether there are more documents to load */
hasMore?: boolean
/** Callback to load more documents (for pagination) */
loadMoreDocuments?: () => Promise<void>
/** Show/hide the spaces filter dropdown */
showSpacesSelector?: boolean
/** Visual variant - "console" for full view, "consumer" for embedded */
/** Error from data fetching */
error?: Error | null
/** Children to render when no documents */
children?: React.ReactNode
/** Visual variant */
variant?: "console" | "consumer"
/** Optional ID for the legend component */
/** Optional legend ID */
legendId?: string
/** Document IDs to highlight in the graph */
/** Document IDs to highlight */
highlightDocumentIds?: string[]
/** Whether highlights are currently visible */
/** Whether highlights are visible */
highlightsVisible?: boolean
/** Pixels occluded on the right side of the viewport */
occludedRightPx?: number
/** Whether to auto-load more documents based on viewport visibility */
autoLoadOnViewport?: boolean
/** Theme class name to apply */
themeClassName?: string
// External space control
/** Currently selected space (for controlled component) */
selectedSpace?: string
/** Callback when space selection changes (for controlled component) */
onSpaceChange?: (spaceId: string) => void
// Memory limit control
/** Maximum number of memories to display per document when a space is selected */
memoryLimit?: number
/** Maximum total number of memory nodes to display across all documents (default: unlimited) */
/** Container tags for filtering (used by apps with their own API hooks) */
containerTags?: string[]
/** Specific document IDs to show */
documentIds?: string[]
/** Max nodes to display */
maxNodes?: number
// Feature flags
/** Enable experimental features */
isExperimental?: boolean
// Slideshow control
/** Whether slideshow mode is currently active */
/** Show FPS counter overlay */
showFps?: boolean
/** Slideshow mode */
isSlideshowActive?: boolean
/** Callback when slideshow selects a new node (provides node ID) */
onSlideshowNodeChange?: (nodeId: string | null) => void
/** Callback when user clicks outside during slideshow (to stop it) */
onSlideshowStop?: () => void
/** Canvas ref for external access (e.g. screenshot export) */
canvasRef?: React.RefObject<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 +216,9 @@ export interface LoadingIndicatorProps {
variant?: "console" | "consumer"
}
export interface ControlsProps {
onZoomIn: () => void
onZoomOut: () => void
onResetView: () => void
variant?: "console" | "consumer"
}
// Re-export api-types for backward compatibility
export type {
DocumentWithMemories,
MemoryEntry,
DocumentsResponse,
} from "./api-types"

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": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": false,
"skipLibCheck": true,
"strict": true,
"esModuleInterop": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noEmit": false
"declaration": true,
"declarationDir": "./dist",
"emitDeclarationOnly": true,
"outDir": "./dist",
"rootDir": "./src",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"]
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,93 +1,36 @@
import { defineConfig, type Plugin } from "vite"
import { defineConfig } from "vitest/config"
import react from "@vitejs/plugin-react"
import { resolve } from "path"
import { readFileSync } from "fs"
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"
import { resolve } from "node:path"
/**
* Custom plugin to embed CSS content into the JS bundle for runtime injection.
* This allows the package to work with any bundler (Vite, webpack, Next.js, etc.)
*/
function injectCssPlugin(): Plugin {
let cssContent = ""
return {
name: "inject-css-content",
enforce: "post",
generateBundle(_, bundle) {
// Find the generated CSS file
for (const [fileName, chunk] of Object.entries(bundle)) {
if (fileName.endsWith(".css") && chunk.type === "asset") {
cssContent = chunk.source as string
break
}
}
// Replace placeholder in JS files with actual CSS content
for (const [fileName, chunk] of Object.entries(bundle)) {
if (
(fileName.endsWith(".js") || fileName.endsWith(".cjs")) &&
chunk.type === "chunk"
) {
// Escape the CSS for embedding in JS string
const escapedCss = JSON.stringify(cssContent)
chunk.code = chunk.code.replace(/__MEMORY_GRAPH_CSS__/g, escapedCss)
}
}
},
}
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), vanillaExtractPlugin(), injectCssPlugin()],
build: {
lib: {
entry: resolve(__dirname, "src/index.tsx"),
name: "MemoryGraph",
formats: ["es", "cjs"],
fileName: (format) => {
if (format === "es") return "memory-graph.js"
if (format === "cjs") return "memory-graph.cjs"
return "memory-graph.js"
},
},
rollupOptions: {
// Externalize only peer dependencies (React)
external: ["react", "react-dom", "react/jsx-runtime"],
output: {
// Provide global variables for UMD build (if needed later)
globals: {
react: "React",
"react-dom": "ReactDOM",
"react/jsx-runtime": "react/jsx-runtime",
},
// Preserve CSS as separate file (for manual import fallback)
assetFileNames: (assetInfo) => {
// Vanilla-extract generates index.css, rename to memory-graph.css
if (
assetInfo.name === "index.css" ||
assetInfo.name === "style.css"
) {
return "memory-graph.css"
}
return assetInfo.name || "asset"
},
// Don't preserve modules - bundle everything except externals
preserveModules: false,
},
},
// Ensure CSS is extracted
cssCodeSplit: false,
// Generate sourcemaps for debugging
sourcemap: true,
// Optimize deps
minify: "esbuild",
target: "esnext",
},
plugins: [react()],
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
},
},
test: {
include: ["src/**/*.test.ts"],
environment: "node",
},
build: {
lib: {
entry: {
"memory-graph": resolve(__dirname, "src/index.tsx"),
"mock-data": resolve(__dirname, "src/mock-data.ts"),
},
formats: ["es", "cjs"],
},
rollupOptions: {
external: ["react", "react-dom", "react/jsx-runtime"],
output: {
globals: {
react: "React",
"react-dom": "ReactDOM",
},
},
},
sourcemap: true,
minify: false,
},
})