diff --git a/apps/web/app/new/page.tsx b/apps/web/app/new/page.tsx index 0eb8613c..30fddde7 100644 --- a/apps/web/app/new/page.tsx +++ b/apps/web/app/new/page.tsx @@ -4,6 +4,7 @@ import { useState, useCallback, useEffect } from "react" import { Header } from "@/components/new/header" import { ChatSidebar } from "@/components/new/chat" import { MemoriesGrid } from "@/components/new/memories-grid" +import { GraphLayoutView } from "@/components/new/graph-layout-view" import { AnimatedGradientBackground } from "@/components/new/animated-gradient-background" import { AddDocumentModal } from "@/components/new/add-document" import { MCPModal } from "@/components/new/mcp-modal" @@ -25,6 +26,7 @@ import { useDocumentMutations } from "@/hooks/use-document-mutations" import { useQuery } from "@tanstack/react-query" import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" import type { z } from "zod" +import { useViewMode } from "@/lib/view-mode-context" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -32,6 +34,7 @@ type DocumentWithMemories = DocumentsResponse["documents"][0] export default function NewPage() { const isMobile = useIsMobile() const { selectedProject } = useProject() + const { viewMode } = useViewMode() const [isAddDocumentOpen, setIsAddDocumentOpen] = useState(false) const [isMCPModalOpen, setIsMCPModalOpen] = useState(false) const [isSearchOpen, setIsSearchOpen] = useState(false) @@ -195,26 +198,32 @@ export default function NewPage() { }} />
-
- -
+ {viewMode === "graph" && !isMobile ? ( +
+ +
+ ) : ( +
+ +
+ )}
-type DocumentWithMemories = DocumentsResponse["documents"][0] - +/** + * Graph Dialog component + */ export function GraphDialog() { const { user } = useAuth() const { documentIds: allHighlightDocumentIds } = useGraphHighlights() const { selectedProject } = useProject() - const { isOpen } = useChatOpen() + const { isOpen: isChatOpen } = useChatOpen() const { isOpen: showGraphModal, setIsOpen: setShowGraphModal } = useGraphModal() - const [injectedDocs, setInjectedDocs] = useState([]) const [showAddMemoryView, setShowAddMemoryView] = useState(false) const [showConnectAIModal, setShowConnectAIModal] = useState(false) const isMobile = useIsMobile() - const IS_DEV = process.env.NODE_ENV === "development" - const PAGE_SIZE = IS_DEV ? 100 : 100 - const MAX_TOTAL = 1000 - - const { - data, - error, - isPending, - isFetchingNextPage, - hasNextPage, - fetchNextPage, - } = useInfiniteQuery({ - queryKey: ["documents-with-memories", selectedProject], - initialPageParam: 1, - queryFn: async ({ pageParam }) => { - const response = await $fetch("@post/documents/documents", { - body: { - page: pageParam as number, - limit: (pageParam as number) === 1 ? (IS_DEV ? 500 : 500) : PAGE_SIZE, - sort: "createdAt", - order: "desc", - containerTags: selectedProject ? [selectedProject] : undefined, - }, - disableValidation: true, - }) - - if (response.error) { - throw new Error(response.error?.message || "Failed to fetch documents") - } - - return response.data - }, - getNextPageParam: (lastPage, allPages) => { - if (!lastPage || !lastPage.pagination) return undefined - if (!Array.isArray(allPages)) return undefined - - const loaded = allPages.reduce( - (acc, p) => acc + (p.documents?.length ?? 0), - 0, - ) - if (loaded >= MAX_TOTAL) return undefined - - const { currentPage, totalPages } = lastPage.pagination - if (currentPage < totalPages) { - return currentPage + 1 - } - return undefined - }, - staleTime: 5 * 60 * 1000, - enabled: !!user, // Only run query if user is authenticated - }) - - const baseDocuments = useMemo(() => { - return ( - data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? [] - ) - }, [data]) - - const allDocuments = useMemo(() => { - if (injectedDocs.length === 0) return baseDocuments - const byId = new Map() - for (const d of injectedDocs) byId.set(d.id, d) - for (const d of baseDocuments) if (!byId.has(d.id)) byId.set(d.id, d) - return Array.from(byId.values()) - }, [baseDocuments, injectedDocs]) - - const totalLoaded = allDocuments.length - const hasMore = hasNextPage - const isLoadingMore = isFetchingNextPage - - const loadMoreDocuments = useCallback(async (): Promise => { - if (hasNextPage && !isFetchingNextPage) { - await fetchNextPage() - return - } - return - }, [hasNextPage, isFetchingNextPage, fetchNextPage]) - - // Handle highlighted documents injection for chat - useEffect(() => { - if (!isOpen) return - if (!allHighlightDocumentIds || allHighlightDocumentIds.length === 0) return - const present = new Set() - for (const d of [...baseDocuments, ...injectedDocs]) { - if (d.id) present.add(d.id) - if (d.customId) present.add(d.customId as string) - } - const missing = allHighlightDocumentIds.filter( - (id: string) => !present.has(id), - ) - if (missing.length === 0) return - let cancelled = false - const run = async () => { - try { - const resp = await $fetch("@post/documents/documents/by-ids", { - body: { - ids: missing, - by: "customId", - containerTags: selectedProject ? [selectedProject] : undefined, - }, - disableValidation: true, - }) - if (cancelled || resp?.error) return - const extraDocs = resp?.data?.documents as - | DocumentWithMemories[] - | undefined - if (!extraDocs || extraDocs.length === 0) return - setInjectedDocs((prev) => { - const seen = new Set([ - ...prev.map((d) => d.id), - ...baseDocuments.map((d) => d.id), - ]) - const merged = [...prev] - for (const doc of extraDocs) { - if (!seen.has(doc.id)) { - merged.push(doc) - seen.add(doc.id) - } - } - return merged - }) - } catch {} - } - void run() - return () => { - cancelled = true - } - }, [ - isOpen, - allHighlightDocumentIds, - baseDocuments, - injectedDocs, - selectedProject, - ]) - if (!user) return null + // Convert selectedProject to containerTags array + const containerTags = selectedProject ? [selectedProject] : undefined + return ( <> Memory Graph
{!isMobile ? ( diff --git a/apps/web/components/new/graph-layout-view.tsx b/apps/web/components/new/graph-layout-view.tsx new file mode 100644 index 00000000..83bd2ce9 --- /dev/null +++ b/apps/web/components/new/graph-layout-view.tsx @@ -0,0 +1,58 @@ +"use client" + +import { memo, useState, useCallback } from "react" +import { MemoryGraph } from "./memory-graph/memory-graph" +import { useProject } from "@/stores" +import { useGraphHighlights } from "@/stores/highlights" +import { Share2 } from "lucide-react" +import { Button } from "@ui/components/button" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/lib/fonts" + +interface GraphLayoutViewProps { + isChatOpen: boolean +} + +export const GraphLayoutView = memo(({ isChatOpen }) => { + const { selectedProject } = useProject() + const { documentIds: allHighlightDocumentIds } = useGraphHighlights() + const [_isShareModalOpen, setIsShareModalOpen] = useState(false) + + const containerTags = selectedProject ? [selectedProject] : undefined + + const handleShare = useCallback(() => { + setIsShareModalOpen(true) + }, []) + + return ( +
+ {/* Full-width graph */} +
+ +
+ + {/* Share graph button - top left */} +
+ +
+
+ ) +}) + +GraphLayoutView.displayName = "GraphLayoutView" diff --git a/apps/web/components/new/header.tsx b/apps/web/components/new/header.tsx index c244bf88..957d8fd4 100644 --- a/apps/web/components/new/header.tsx +++ b/apps/web/components/new/header.tsx @@ -38,6 +38,7 @@ import { useIsMobile } from "@hooks/use-mobile" import { useOrgOnboarding } from "@hooks/use-org-onboarding" import { useState } from "react" import { FeedbackModal } from "./feedback-modal" +import { useViewMode } from "@/lib/view-mode-context" interface HeaderProps { onAddMemory?: () => void @@ -59,6 +60,7 @@ export function Header({ const isMobile = useIsMobile() const { resetOrgOnboarded } = useOrgOnboarding() const [isFeedbackOpen, setIsFeedbackOpen] = useState(false) + const { viewMode, setViewMode } = useViewMode() const handleTryOnboarding = () => { resetOrgOnboarded() @@ -151,7 +153,10 @@ export function Header({ )}
{!isMobile && ( - + setViewMode(v === "grid" ? "list" : "graph")} + > )}
- setIsFeedbackOpen(false)} /> + setIsFeedbackOpen(false)} + />
) } diff --git a/apps/web/components/new/memories-grid.tsx b/apps/web/components/new/memories-grid.tsx index e9dac883..d7df8d55 100644 --- a/apps/web/components/new/memories-grid.tsx +++ b/apps/web/components/new/memories-grid.tsx @@ -25,6 +25,7 @@ import { McpPreview } from "./document-cards/mcp-preview" import { getFaviconUrl } from "@/lib/url-helpers" import { QuickNoteCard } from "./quick-note-card" import { HighlightsCard, type HighlightItem } from "./highlights-card" +import { GraphCard } from "./memory-graph" import { Button } from "@ui/components/button" // Document category type @@ -65,7 +66,7 @@ const MAX_TOTAL = 1000 type MasonryItem = | { type: "quick-note"; id: string } | { type: "highlights-card"; id: string } - | { type: "highlights-card-spacer"; id: string } + | { type: "graph-card"; id: string } | { type: "document"; id: string; data: DocumentWithMemories } interface QuickNoteProps { @@ -199,10 +200,10 @@ export function MemoriesGrid({ } if (hasHighlights) { items.push({ type: "highlights-card", id: "highlights-card" }) - // Add spacer to occupy the second column space for the 2-column highlights card + // Add graph card to occupy the second column space (below quick note) items.push({ - type: "highlights-card-spacer", - id: "highlights-card-spacer", + type: "graph-card", + id: "graph-card", }) } } @@ -278,16 +279,15 @@ export function MemoriesGrid({ ) } - if (data.type === "highlights-card-spacer") { + if (data.type === "graph-card") { return ( -
+
+ +
) } @@ -304,7 +304,14 @@ export function MemoriesGrid({ return null }, - [handleCardClick, quickNoteProps, highlightsProps], + [ + handleCardClick, + quickNoteProps, + highlightsProps, + documents, + isPending, + error, + ], ) if (!user) { diff --git a/apps/web/components/new/memory-graph/api-types.ts b/apps/web/components/new/memory-graph/api-types.ts new file mode 100644 index 00000000..0ebc86ee --- /dev/null +++ b/apps/web/components/new/memory-graph/api-types.ts @@ -0,0 +1,79 @@ +// Standalone TypeScript types for Memory Graph +// These mirror the API response types from @repo/validation/api + +export interface MemoryEntry { + id: string + customId?: string | null + documentId: string + content: string | null + summary?: string | null + title?: string | null + url?: string | null + type?: string | null + metadata?: Record | null + embedding?: number[] | null + embeddingModel?: string | null + tokenCount?: number | null + createdAt: string | Date + updatedAt: string | Date + // Fields from join relationship + sourceAddedAt?: Date | null + sourceRelevanceScore?: number | null + sourceMetadata?: Record | null + spaceContainerTag?: string | null + // Version chain fields + updatesMemoryId?: string | null + nextVersionId?: string | null + relation?: "updates" | "extends" | "derives" | null + // Memory status fields + isForgotten?: boolean + forgetAfter?: Date | string | null + isLatest?: boolean + // Space/container fields + spaceId?: string | null + // Legacy fields + memory?: string | null + memoryRelations?: Array<{ + relationType: "updates" | "extends" | "derives" + targetMemoryId: string + }> | null + parentMemoryId?: string | null +} + +export interface DocumentWithMemories { + id: string + customId?: string | null + contentHash: string | null + orgId: string + userId: string + connectionId?: string | null + title?: string | null + content?: string | null + summary?: string | null + url?: string | null + source?: string | null + type?: string | null + status: "pending" | "processing" | "done" | "failed" + metadata?: Record | null + processingMetadata?: Record | null + raw?: string | null + tokenCount?: number | null + wordCount?: number | null + chunkCount?: number | null + averageChunkSize?: number | null + summaryEmbedding?: number[] | null + summaryEmbeddingModel?: string | null + createdAt: string | Date + updatedAt: string | Date + memoryEntries: MemoryEntry[] +} + +export interface DocumentsResponse { + documents: DocumentWithMemories[] + pagination: { + currentPage: number + limit: number + totalItems: number + totalPages: number + } +} diff --git a/apps/web/components/new/memory-graph/assets/icons.tsx b/apps/web/components/new/memory-graph/assets/icons.tsx new file mode 100644 index 00000000..5eb38b42 --- /dev/null +++ b/apps/web/components/new/memory-graph/assets/icons.tsx @@ -0,0 +1,208 @@ +export const OneDrive = ({ className }: { className?: string }) => ( + + OneDrive + + + + + +) + +export const GoogleDrive = ({ className }: { className?: string }) => ( + + Google Drive + + + + + + + +) + +export const Notion = ({ className }: { className?: string }) => ( + + Notion + + + +) + +export const GoogleDocs = ({ className }: { className?: string }) => ( + + Google Docs + + +) + +export const GoogleSheets = ({ className }: { className?: string }) => ( + + Google Sheets + + +) + +export const GoogleSlides = ({ className }: { className?: string }) => ( + + Google Slides + + +) + +export const NotionDoc = ({ className }: { className?: string }) => ( + + Notion Doc + + +) + +export const MicrosoftWord = ({ className }: { className?: string }) => ( + + Microsoft Word + + +) + +export const MicrosoftExcel = ({ className }: { className?: string }) => ( + + Microsoft Excel + + +) + +export const MicrosoftPowerpoint = ({ className }: { className?: string }) => ( + + Microsoft PowerPoint + + +) + +export const MicrosoftOneNote = ({ className }: { className?: string }) => ( + + Microsoft OneNote + + +) + +export const PDF = ({ className }: { className?: string }) => ( + + PDF + + + + +) diff --git a/apps/web/components/new/memory-graph/constants.ts b/apps/web/components/new/memory-graph/constants.ts new file mode 100644 index 00000000..8d41a23d --- /dev/null +++ b/apps/web/components/new/memory-graph/constants.ts @@ -0,0 +1,154 @@ +// Enhanced color palette matching Figma design +export const colors = { + background: { + primary: "#0f1419", // Deep dark blue-gray + secondary: "#1a1f29", // Slightly lighter + accent: "#252a35", // Card backgrounds + }, + // Hexagon node colors (Figma design) + hexagon: { + // Active/highlighted/selected state + active: { + fill: "#0D2034", // Background fill + stroke: "#3B73B8", // Border color + strokeWidth: 1.68, + }, + // Inactive/dimmed state + inactive: { + fill: "#0B1826", // Background fill + stroke: "#3D4857", // Border color + strokeWidth: 1.4, + }, + // Hovered state + hovered: { + fill: "#112840", // Slightly brighter + stroke: "#4A8AD0", // Brighter border + strokeWidth: 2, + }, + }, + // Node sizes in pixels (flat-to-flat diameter) + hexagonSizes: { + large: 33.57, + medium: 27.18, + small: 23.98, + tiny: 20, + }, + 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 + }, + // Edge/connection colors (Figma design) + connection: { + teal: "#00BFAC", // Teal connections + blueGray: "#5070A1", // Blue-gray connections + blue: "#0054D1", // Blue connections + purple: "#7800AB", // Purple connections + // Legacy/fallback + weak: "rgba(35, 189, 255, 0.3)", + memory: "rgba(148, 163, 184, 0.35)", + medium: "rgba(35, 189, 255, 0.6)", + strong: "rgba(35, 189, 255, 0.9)", + }, + 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 + }, +} + +// Edge color palette for similarity-based coloring +export const EDGE_COLORS = [ + colors.connection.teal, + colors.connection.blueGray, + colors.connection.blue, + colors.connection.purple, +] as const + +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, +} + +// 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) + linkStrength: { + docMemory: 0.8, // Strong for doc-memory connections + version: 1.0, // Strongest for version chains + docDocBase: 0.3, // Base for doc-doc similarity + }, + 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) +} + +// 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 + }, +} + +// Animation settings +export const ANIMATION = { + // Dim effect duration - shortened for better UX + dimDuration: 1500, // milliseconds +} diff --git a/apps/web/components/new/memory-graph/graph-canvas.tsx b/apps/web/components/new/memory-graph/graph-canvas.tsx new file mode 100644 index 00000000..5d913af5 --- /dev/null +++ b/apps/web/components/new/memory-graph/graph-canvas.tsx @@ -0,0 +1,689 @@ +"use client" + +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react" +import { colors, ANIMATION, EDGE_COLORS } from "./constants" +import type { DocumentWithMemories, GraphCanvasProps, GraphNode } from "./types" +import { drawDocumentIcon } from "./utils/document-icons" + +// Helper to draw a flat-topped regular hexagon +function drawHexagon( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + radius: number, +) { + ctx.beginPath() + for (let i = 0; i < 6; i++) { + // Flat-top hexagon: start at -30° (top-right flat edge) + const angle = (Math.PI / 3) * i - Math.PI / 6 + const x = cx + radius * Math.cos(angle) + const y = cy + radius * Math.sin(angle) + if (i === 0) { + ctx.moveTo(x, y) + } else { + ctx.lineTo(x, y) + } + } + ctx.closePath() +} + +// Helper to draw an arrow head at the end of an edge +function drawArrowHead( + ctx: CanvasRenderingContext2D, + fromX: number, + fromY: number, + toX: number, + toY: number, + arrowSize: number, +) { + const angle = Math.atan2(toY - fromY, toX - fromX) + ctx.beginPath() + ctx.moveTo(toX, toY) + ctx.lineTo( + toX - arrowSize * Math.cos(angle - Math.PI / 6), + toY - arrowSize * Math.sin(angle - Math.PI / 6), + ) + ctx.lineTo( + toX - arrowSize * Math.cos(angle + Math.PI / 6), + toY - arrowSize * Math.sin(angle + Math.PI / 6), + ) + ctx.closePath() + ctx.fill() +} + +// Get edge color based on similarity and edge type +function getEdgeColor(similarity: number, edgeType: string): string { + if (edgeType === "doc-memory") { + return colors.connection.blueGray + } + if (edgeType === "version") { + return colors.connection.purple + } + // Map similarity (0.7-1.0) to color index + if (similarity >= 0.9) { + return EDGE_COLORS[0] // teal - strongest + } + if (similarity >= 0.85) { + return EDGE_COLORS[1] // blueGray + } + if (similarity >= 0.8) { + return EDGE_COLORS[2] // blue + } + return EDGE_COLORS[3] // purple - weakest visible +} + +export const GraphCanvas = memo( + ({ + nodes, + edges, + panX, + panY, + zoom, + width, + height, + onNodeHover, + onNodeClick, + onNodeDragStart, + onNodeDragMove, + onNodeDragEnd, + onPanStart, + onPanMove, + onPanEnd, + onWheel, + onDoubleClick, + onTouchStart, + onTouchMove, + onTouchEnd, + draggingNodeId, + highlightDocumentIds, + isSimulationActive = false, + selectedNodeId = null, + }) => { + const canvasRef = useRef(null) + const animationRef = useRef(0) + const startTimeRef = useRef(Date.now()) + const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) + const currentHoveredNode = useRef(null) + const dimProgress = useRef(selectedNodeId ? 1 : 0) + const dimAnimationRef = useRef(0) + const [, forceRender] = useState(0) + + // Initialize start time once + useEffect(() => { + startTimeRef.current = Date.now() + }, []) + + // Initialize canvas quality settings once + useLayoutEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const ctx = canvas.getContext("2d") + if (!ctx) return + + // Set high quality rendering once instead of every frame + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = "high" + }, []) + + // Smooth dimming animation + useEffect(() => { + const targetDim = selectedNodeId ? 1 : 0 + const duration = ANIMATION.dimDuration // Match physics settling time + const startDim = dimProgress.current + const startTime = Date.now() + + const animate = () => { + const elapsed = Date.now() - startTime + const progress = Math.min(elapsed / duration, 1) + + // Ease-out cubic easing for smooth deceleration + const eased = 1 - (1 - progress) ** 3 + dimProgress.current = startDim + (targetDim - startDim) * eased + + // Force re-render to update canvas during animation + forceRender((prev) => prev + 1) + + if (progress < 1) { + dimAnimationRef.current = requestAnimationFrame(animate) + } + } + + if (dimAnimationRef.current) { + cancelAnimationFrame(dimAnimationRef.current) + } + animate() + + return () => { + if (dimAnimationRef.current) { + cancelAnimationFrame(dimAnimationRef.current) + } + } + }, [selectedNodeId]) + + // Spatial grid for optimized hit detection (20-25% FPS improvement for large graphs) + const spatialGrid = useMemo(() => { + const GRID_CELL_SIZE = 150 // Grid cell size in screen pixels + const grid = new Map() + + // Build spatial grid + nodes.forEach((node) => { + const screenX = node.x * zoom + panX + const screenY = node.y * zoom + panY + + // Calculate which grid cell this node belongs to + const cellX = Math.floor(screenX / GRID_CELL_SIZE) + const cellY = Math.floor(screenY / GRID_CELL_SIZE) + const cellKey = `${cellX},${cellY}` + + // Add node to grid cell + if (!grid.has(cellKey)) { + grid.set(cellKey, []) + } + grid.get(cellKey)!.push(node) + }) + + return { grid, cellSize: GRID_CELL_SIZE } + }, [nodes, panX, panY, zoom]) + + // Efficient hit detection using spatial grid + const getNodeAtPosition = useCallback( + (x: number, y: number): string | null => { + const { grid, cellSize } = spatialGrid + + // Determine which grid cell the click is in + const cellX = Math.floor(x / cellSize) + const cellY = Math.floor(y / cellSize) + const cellKey = `${cellX},${cellY}` + + // Only check nodes in the clicked cell (and neighboring cells for edge cases) + const cellsToCheck = [ + cellKey, + `${cellX - 1},${cellY}`, + `${cellX + 1},${cellY}`, + `${cellX},${cellY - 1}`, + `${cellX},${cellY + 1}`, + ] + + // Check from top-most to bottom-most: memory nodes are drawn after documents + for (const key of cellsToCheck) { + const cellNodes = grid.get(key) + if (!cellNodes) continue + + // Iterate backwards (top-most first) + for (let i = cellNodes.length - 1; i >= 0; i--) { + const node = cellNodes[i]! + const screenX = node.x * zoom + panX + const screenY = node.y * zoom + panY + const nodeSize = node.size * zoom + + // Both document and memory nodes are now hexagons + // Use circular approximation for hit detection (good enough for hexagons) + const hexRadius = nodeSize * 0.5 + const dx = x - screenX + const dy = y - screenY + const distance = Math.sqrt(dx * dx + dy * dy) + + if (distance <= hexRadius) { + return node.id + } + } + } + return null + }, + [spatialGrid, panX, panY, zoom], + ) + + // Handle mouse events + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + const canvas = canvasRef.current + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + + mousePos.current = { x, y } + + const nodeId = getNodeAtPosition(x, y) + if (nodeId !== currentHoveredNode.current) { + currentHoveredNode.current = nodeId + onNodeHover(nodeId) + } + + // Handle node dragging + if (draggingNodeId) { + onNodeDragMove(e) + } + }, + [getNodeAtPosition, onNodeHover, draggingNodeId, onNodeDragMove], + ) + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + const canvas = canvasRef.current + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + + const nodeId = getNodeAtPosition(x, y) + if (nodeId) { + // When starting a node drag, prevent initiating pan + e.stopPropagation() + onNodeDragStart(nodeId, e) + return + } + onPanStart(e) + }, + [getNodeAtPosition, onNodeDragStart, onPanStart], + ) + + const handleClick = useCallback( + (e: React.MouseEvent) => { + const canvas = canvasRef.current + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + + const nodeId = getNodeAtPosition(x, y) + if (nodeId) { + onNodeClick(nodeId) + } + }, + [getNodeAtPosition, onNodeClick], + ) + + // Memoize nodeMap for O(1) lookup + const nodeMap = useMemo(() => { + const map = new Map() + nodes.forEach((node) => { + map.set(node.id, node) + }) + return map + }, [nodes]) + + // Main render function + const render = useCallback(() => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext("2d") + if (!ctx) return + + // Clear canvas + ctx.clearRect(0, 0, width, height) + + // Fill with background color + ctx.fillStyle = colors.background.primary + ctx.fillRect(0, 0, width, height) + + // Draw edges first (behind nodes) + edges.forEach((edge) => { + const sourceNode = + typeof edge.source === "string" + ? nodeMap.get(edge.source) + : edge.source + const targetNode = + typeof edge.target === "string" + ? nodeMap.get(edge.target) + : edge.target + + if (!sourceNode || !targetNode) return + + const sourceX = sourceNode.x * zoom + panX + const sourceY = sourceNode.y * zoom + panY + const targetX = targetNode.x * zoom + panX + const targetY = targetNode.y * zoom + panY + + // Calculate edge endpoint offset to stop at node edge + const sourceRadius = sourceNode.size * zoom * 0.5 + const targetRadius = targetNode.size * zoom * 0.5 + const dx = targetX - sourceX + const dy = targetY - sourceY + const dist = Math.sqrt(dx * dx + dy * dy) + if (dist < 1) return // Skip very short edges + + const ux = dx / dist + const uy = dy / dist + + // Offset start/end points to node edges + const startX = sourceX + ux * sourceRadius + const startY = sourceY + uy * sourceRadius + const endX = targetX - ux * targetRadius + const endY = targetY - uy * targetRadius + + // Apply dimming based on selection + let edgeOpacity = edge.visualProps.opacity + if (selectedNodeId && dimProgress.current > 0) { + const isConnectedToSelected = + (typeof edge.source === "string" ? edge.source : edge.source.id) === + selectedNodeId || + (typeof edge.target === "string" ? edge.target : edge.target.id) === + selectedNodeId + + if (!isConnectedToSelected) { + edgeOpacity *= 1 - dimProgress.current * 0.8 + } + } + + // Get edge color from Figma palette + const edgeColor = getEdgeColor(edge.similarity, edge.edgeType) + ctx.globalAlpha = edgeOpacity + + // Draw the edge line + ctx.beginPath() + ctx.moveTo(startX, startY) + ctx.lineTo(endX, endY) + ctx.strokeStyle = edgeColor + ctx.lineWidth = Math.max(1, edge.visualProps.thickness * 0.8) + ctx.setLineDash([]) + ctx.stroke() + + // Draw arrow head at target end + const arrowSize = Math.max(6, 8 * zoom) + ctx.fillStyle = edgeColor + drawArrowHead(ctx, startX, startY, endX, endY, arrowSize) + + ctx.globalAlpha = 1 + }) + + // Draw nodes - all nodes are now hexagons + nodes.forEach((node) => { + const screenX = node.x * zoom + panX + const screenY = node.y * zoom + panY + const nodeSize = node.size * zoom + + // Determine node state for styling + const isSelected = node.id === selectedNodeId + const isHovered = currentHoveredNode.current === node.id + const highlightSet = new Set(highlightDocumentIds ?? []) + const doc = node.data as DocumentWithMemories + const isHighlighted = + highlightSet.has(node.id) || + (doc.customId && highlightSet.has(doc.customId)) + + // Apply dimming based on selection + let nodeOpacity = 1 + if (selectedNodeId && dimProgress.current > 0) { + if (!isSelected) { + nodeOpacity = 1 - dimProgress.current * 0.7 + } + } + + ctx.globalAlpha = nodeOpacity + + // Calculate hexagon radius (nodeSize is diameter) + const hexRadius = nodeSize * 0.5 + + // Determine hexagon style based on state + let hexStyle: { + fill: string + stroke: string + strokeWidth: number + } + + if (isSelected || isHighlighted) { + hexStyle = colors.hexagon.active + } else if (isHovered) { + hexStyle = colors.hexagon.hovered + } else { + hexStyle = colors.hexagon.inactive + } + + // Draw the flat-topped hexagon + drawHexagon(ctx, screenX, screenY, hexRadius) + + // Fill + ctx.fillStyle = hexStyle.fill + ctx.fill() + + // Stroke + ctx.strokeStyle = hexStyle.stroke + ctx.lineWidth = hexStyle.strokeWidth + ctx.stroke() + + // Draw document type icon for document nodes (centered in hexagon) + if (node.type === "document") { + const iconSize = hexRadius * 0.7 + drawDocumentIcon(ctx, screenX, screenY, iconSize, doc.type || "text") + } + + // Draw glow effect for highlighted/selected nodes + const shouldGlow = isHighlighted || isSelected + if (shouldGlow) { + const glowColor = colors.hexagon.active.stroke + ctx.strokeStyle = glowColor + ctx.lineWidth = 2 + ctx.setLineDash([3, 3]) + ctx.globalAlpha = 0.8 + + // Hexagonal glow (10% larger than node) + const glowRadius = hexRadius * 1.15 + drawHexagon(ctx, screenX, screenY, glowRadius) + ctx.stroke() + ctx.setLineDash([]) + } + }) + + ctx.globalAlpha = 1 + }, [ + nodes, + edges, + panX, + panY, + zoom, + width, + height, + highlightDocumentIds, + nodeMap, + selectedNodeId, + ]) + + // Hybrid rendering: continuous when simulation active, change-based when idle + const lastRenderParams = useRef(0) + + // Create a render key that changes when visual state changes + // Optimized: use cheap hash instead of building long strings + const renderKey = useMemo(() => { + // Hash node positions to a single number (cheaper than string concatenation) + const positionHash = nodes.reduce((hash, n) => { + // Round to 1 decimal to avoid unnecessary re-renders from tiny movements + const x = Math.round(n.x * 10) + const y = Math.round(n.y * 10) + const dragging = n.isDragging ? 1 : 0 + const hovered = currentHoveredNode.current === n.id ? 1 : 0 + // Simple XOR hash (fast and sufficient for change detection) + return hash ^ (x + y + dragging + hovered) + }, 0) + + const highlightHash = (highlightDocumentIds ?? []).reduce((hash, id) => { + return hash ^ id.length + }, 0) + + // Combine all factors into a single number + return ( + positionHash ^ + edges.length ^ + Math.round(panX) ^ + Math.round(panY) ^ + Math.round(zoom * 100) ^ + width ^ + height ^ + highlightHash + ) + }, [ + nodes, + edges.length, + panX, + panY, + zoom, + width, + height, + highlightDocumentIds, + ]) + + // Render based on simulation state + useEffect(() => { + if (isSimulationActive) { + // Continuous rendering during physics simulation + const renderLoop = () => { + render() + animationRef.current = requestAnimationFrame(renderLoop) + } + renderLoop() + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + } + } + } + // Change-based rendering when simulation is idle + if (renderKey !== lastRenderParams.current) { + lastRenderParams.current = renderKey + render() + } + }, [isSimulationActive, renderKey, render]) + + // Cleanup any existing animation frames + useEffect(() => { + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + } + } + }, []) + + // Add native wheel event listener to prevent browser zoom + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const handleNativeWheel = (e: WheelEvent) => { + e.preventDefault() + e.stopPropagation() + + // Call the onWheel handler with a synthetic-like event + // @ts-expect-error - partial WheelEvent object + onWheel({ + deltaY: e.deltaY, + deltaX: e.deltaX, + clientX: e.clientX, + clientY: e.clientY, + currentTarget: canvas, + nativeEvent: e, + preventDefault: () => {}, + stopPropagation: () => {}, + } as React.WheelEvent) + } + + // Add listener with passive: false to ensure preventDefault works + canvas.addEventListener("wheel", handleNativeWheel, { passive: false }) + + // Also prevent gesture events for touch devices + const handleGesture = (e: Event) => { + e.preventDefault() + } + + canvas.addEventListener("gesturestart", handleGesture, { + passive: false, + }) + canvas.addEventListener("gesturechange", handleGesture, { + passive: false, + }) + canvas.addEventListener("gestureend", handleGesture, { passive: false }) + + return () => { + canvas.removeEventListener("wheel", handleNativeWheel) + canvas.removeEventListener("gesturestart", handleGesture) + canvas.removeEventListener("gesturechange", handleGesture) + canvas.removeEventListener("gestureend", handleGesture) + } + }, [onWheel]) + + // High-DPI handling -------------------------------------------------- + const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1 + + useLayoutEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + // Maximum safe canvas size (most browsers support up to 16384px) + const MAX_CANVAS_SIZE = 16384 + + // Calculate effective DPR that keeps us within safe limits + // Prevent division by zero by checking for valid dimensions + const maxDpr = + width > 0 && height > 0 + ? Math.min(MAX_CANVAS_SIZE / width, MAX_CANVAS_SIZE / height, dpr) + : dpr + + // upscale backing store with clamped dimensions + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + canvas.width = Math.min(width * maxDpr, MAX_CANVAS_SIZE) + canvas.height = Math.min(height * maxDpr, MAX_CANVAS_SIZE) + + const ctx = canvas.getContext("2d") + ctx?.scale(maxDpr, maxDpr) + }, [width, height, dpr]) + // ----------------------------------------------------------------------- + + return ( + { + if (draggingNodeId) { + onNodeDragEnd() + } else { + onPanEnd() + } + }} + onMouseMove={(e) => { + handleMouseMove(e) + if (!draggingNodeId) { + onPanMove(e) + } + }} + onMouseUp={() => { + if (draggingNodeId) { + onNodeDragEnd() + } else { + onPanEnd() + } + }} + onTouchStart={onTouchStart} + onTouchMove={onTouchMove} + onTouchEnd={onTouchEnd} + ref={canvasRef} + style={{ + cursor: draggingNodeId + ? "grabbing" + : currentHoveredNode.current + ? "grab" + : "move", + touchAction: "none", + userSelect: "none", + WebkitUserSelect: "none", + }} + /> + ) + }, +) + +GraphCanvas.displayName = "GraphCanvas" diff --git a/apps/web/components/new/memory-graph/graph-card.tsx b/apps/web/components/new/memory-graph/graph-card.tsx new file mode 100644 index 00000000..54de9e8b --- /dev/null +++ b/apps/web/components/new/memory-graph/graph-card.tsx @@ -0,0 +1,132 @@ +"use client" + +import { memo, useState, useCallback } from "react" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/lib/fonts" +import { Expand } from "lucide-react" +import { MemoryGraph } from "./memory-graph" +import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog" +import { useGraphApi } from "./hooks/use-graph-api" + +interface GraphCardProps { + containerTags?: string[] + width?: number + height?: number + className?: string +} + +/** + * GraphCard component - shows a preview of the memory graph and opens a full modal on click + */ +export const GraphCard = memo( + ({ containerTags, width = 216, height = 220, className }) => { + const [isModalOpen, setIsModalOpen] = useState(false) + + // Use the graph API to get stats for the preview + const { data, isLoading, error } = useGraphApi({ + containerTags, + includeMemories: true, + limit: 20, // Small limit for preview + enabled: true, + }) + + const handleOpenModal = useCallback(() => { + setIsModalOpen(true) + }, []) + + if (error) { + return ( +
+

+ Failed to load graph +

+
+ ) + } + + const stats = data.stats + const documentCount = stats?.documentsWithSpatial ?? 0 + const memoryCount = stats?.memoriesWithSpatial ?? 0 + + return ( + <> + {/* Card Preview */} + + + {/* Full Graph Modal */} + + + Memory Graph +
+ +
+
+
+ + ) + }, +) + +GraphCard.displayName = "GraphCard" diff --git a/apps/web/components/new/memory-graph/hooks/use-force-simulation.ts b/apps/web/components/new/memory-graph/hooks/use-force-simulation.ts new file mode 100644 index 00000000..8b5de2f5 --- /dev/null +++ b/apps/web/components/new/memory-graph/hooks/use-force-simulation.ts @@ -0,0 +1,179 @@ +"use client" + +import { useEffect, useRef, useCallback } from "react" +import * as d3 from "d3-force" +import { FORCE_CONFIG } from "../constants" +import type { GraphNode, GraphEdge } from "../types" + +export interface ForceSimulationControls { + /** The d3 simulation instance */ + simulation: d3.Simulation | null + /** Reheat the simulation (call on drag start) */ + reheat: () => void + /** Cool down the simulation (call on drag end) */ + coolDown: () => void + /** Check if simulation is currently active */ + isActive: () => boolean + /** Stop the simulation completely */ + stop: () => void + /** Get current alpha value */ + getAlpha: () => number +} + +/** + * Custom hook to manage d3-force simulation lifecycle + * Simulation only runs during interactions (drag) for performance + */ +export function useForceSimulation( + nodes: GraphNode[], + edges: GraphEdge[], + onTick: () => void, + enabled = true, +): ForceSimulationControls { + const simulationRef = useRef | null>(null) + + // Initialize simulation ONCE + useEffect(() => { + if (!enabled || nodes.length === 0) { + return + } + + // Only create simulation once + if (!simulationRef.current) { + const simulation = d3 + .forceSimulation(nodes) + .alphaDecay(FORCE_CONFIG.alphaDecay) + .alphaMin(FORCE_CONFIG.alphaMin) + .velocityDecay(FORCE_CONFIG.velocityDecay) + .on("tick", () => { + // Trigger re-render by calling onTick + // D3 has already mutated node.x and node.y + onTick() + }) + + // Configure forces + // 1. Link force - spring connections between nodes + simulation.force( + "link", + d3 + .forceLink(edges) + .id((d) => d.id) + .distance(FORCE_CONFIG.linkDistance) + .strength((link) => { + // Different strength based on edge type + if (link.edgeType === "doc-memory") { + return FORCE_CONFIG.linkStrength.docMemory + } + if (link.edgeType === "version") { + return FORCE_CONFIG.linkStrength.version + } + // doc-doc: variable strength based on similarity + return link.similarity * FORCE_CONFIG.linkStrength.docDocBase + }), + ) + + // 2. Charge force - repulsion between nodes + simulation.force( + "charge", + d3.forceManyBody().strength(FORCE_CONFIG.chargeStrength), + ) + + // 3. Collision force - prevent node overlap + simulation.force( + "collide", + d3 + .forceCollide() + .radius((d) => + d.type === "document" + ? FORCE_CONFIG.collisionRadius.document + : FORCE_CONFIG.collisionRadius.memory, + ) + .strength(0.7), + ) + + // 4. forceX and forceY - weak centering forces (like reference code) + simulation.force("x", d3.forceX().strength(0.05)) + simulation.force("y", d3.forceY().strength(0.05)) + + // Store reference + simulationRef.current = simulation + + // Quick pre-settle to avoid initial chaos, then animate the rest + // This gives best of both worlds: fast initial render + smooth settling + simulation.alpha(1) + for (let i = 0; i < 50; ++i) simulation.tick() // Just 50 ticks = ~5-10ms + simulation.alphaTarget(0).restart() // Continue animating to full stability + } + + // Cleanup on unmount + return () => { + if (simulationRef.current) { + simulationRef.current.stop() + simulationRef.current = null + } + } + // Only run on mount/unmount, not when nodes/edges/onTick change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled]) + + // Update simulation nodes and edges together to prevent race conditions + useEffect(() => { + if (!simulationRef.current) return + + // Update nodes + if (nodes.length > 0) { + simulationRef.current.nodes(nodes) + } + + // Update edges + if (edges.length > 0) { + const linkForce = + simulationRef.current.force>("link") + if (linkForce) { + linkForce.links(edges) + } + } + }, [nodes, edges]) + + // Reheat simulation (called on drag start) + const reheat = useCallback(() => { + if (simulationRef.current) { + simulationRef.current.alphaTarget(FORCE_CONFIG.alphaTarget).restart() + } + }, []) + + // Cool down simulation (called on drag end) + const coolDown = useCallback(() => { + if (simulationRef.current) { + simulationRef.current.alphaTarget(0) + } + }, []) + + // Check if simulation is active + const isActive = useCallback(() => { + if (!simulationRef.current) return false + return simulationRef.current.alpha() > FORCE_CONFIG.alphaMin + }, []) + + // Stop simulation completely + const stop = useCallback(() => { + if (simulationRef.current) { + simulationRef.current.stop() + } + }, []) + + // Get current alpha + const getAlpha = useCallback(() => { + if (!simulationRef.current) return 0 + return simulationRef.current.alpha() + }, []) + + return { + simulation: simulationRef.current, + reheat, + coolDown, + isActive, + stop, + getAlpha, + } +} diff --git a/apps/web/components/new/memory-graph/hooks/use-graph-api.ts b/apps/web/components/new/memory-graph/hooks/use-graph-api.ts new file mode 100644 index 00000000..d3a61c73 --- /dev/null +++ b/apps/web/components/new/memory-graph/hooks/use-graph-api.ts @@ -0,0 +1,359 @@ +"use client" + +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useCallback, useMemo, useState, useRef, useEffect } from "react" +import { $fetch } from "@repo/lib/api" + +// Graph API response types +export interface GraphApiNode { + id: string + type: "document" | "memory" + title: string | null + content: string | null + createdAt: string + updatedAt: string + x: number // 0-1000 + y: number // 0-1000 + // document-specific + documentType?: string + // memory-specific + isStatic?: boolean + spaceId?: string +} + +export interface GraphApiEdge { + source: string + target: string + similarity: number // 0-1 +} + +export interface GraphViewportResponse { + nodes: GraphApiNode[] + 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 + totalMemories: number + memoriesWithSpatial: number + totalMemoryEdges: number +} + +interface ViewportParams { + minX: number + maxX: number + minY: number + maxY: number +} + +interface UseGraphApiOptions { + containerTags?: string[] + spaceId?: string + includeMemories?: boolean + limit?: number + enabled?: boolean +} + +/** + * Hook to fetch graph data from the new Graph API + * Handles viewport-based loading and caching + */ +export function useGraphApi(options: UseGraphApiOptions = {}) { + const { + containerTags, + spaceId, + includeMemories = true, + limit = 200, + enabled = true, + } = options + + const queryClient = useQueryClient() + + // Track current viewport for fetching + const [viewport, setViewport] = useState({ + minX: 0, + maxX: 1000, + minY: 0, + maxY: 1000, + }) + + // Debounce viewport changes to avoid excessive API calls + const viewportTimeoutRef = useRef(null) + const pendingViewportRef = useRef(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) // 150ms debounce + }, []) + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (viewportTimeoutRef.current) { + clearTimeout(viewportTimeoutRef.current) + } + } + }, []) + + // Fetch graph bounds to know the data extent + const boundsQuery = useQuery({ + queryKey: [ + "graph-bounds", + containerTags?.join(","), + spaceId, + includeMemories, + ], + queryFn: async (): Promise => { + const params = new URLSearchParams() + if (containerTags && containerTags.length > 0) { + params.set("containerTags", JSON.stringify(containerTags)) + } + if (spaceId) { + params.set("spaceId", spaceId) + } + params.set("includeMemories", String(includeMemories)) + + 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, // 5 minutes + enabled, + }) + + // Fetch graph stats + const statsQuery = useQuery({ + queryKey: ["graph-stats", containerTags?.join(","), spaceId], + queryFn: async (): Promise => { + const params = new URLSearchParams() + if (containerTags && containerTags.length > 0) { + params.set("containerTags", JSON.stringify(containerTags)) + } + if (spaceId) { + params.set("spaceId", spaceId) + } + + 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, + }) + + // Fetch viewport data + const viewportQuery = useQuery({ + queryKey: [ + "graph-viewport", + viewport.minX, + viewport.maxX, + viewport.minY, + viewport.maxY, + containerTags?.join(","), + spaceId, + includeMemories, + limit, + ], + queryFn: async (): Promise => { + const response = await $fetch("@post/graph/viewport", { + body: { + viewport: { + minX: viewport.minX, + maxX: viewport.maxX, + minY: viewport.minY, + maxY: viewport.maxY, + }, + containerTags, + spaceId, + includeMemories, + limit, + }, + disableValidation: true, + }) + + if (response.error) { + throw new Error( + response.error?.message || "Failed to fetch graph viewport", + ) + } + + return response.data as GraphViewportResponse + }, + staleTime: 30 * 1000, // 30 seconds for viewport data + enabled, + }) + + // Prefetch adjacent viewports for smoother panning + const prefetchAdjacentViewports = useCallback( + (currentViewport: ViewportParams) => { + const viewportWidth = currentViewport.maxX - currentViewport.minX + const viewportHeight = currentViewport.maxY - currentViewport.minY + + // Prefetch in all 4 directions + const offsets = [ + { dx: viewportWidth * 0.5, dy: 0 }, // right + { dx: -viewportWidth * 0.5, dy: 0 }, // left + { dx: 0, dy: viewportHeight * 0.5 }, // down + { dx: 0, dy: -viewportHeight * 0.5 }, // up + ] + + offsets.forEach(({ dx, dy }) => { + const prefetchViewport = { + minX: Math.max(0, Math.min(1000, currentViewport.minX + dx)), + maxX: Math.max(0, Math.min(1000, currentViewport.maxX + dx)), + minY: Math.max(0, Math.min(1000, currentViewport.minY + dy)), + maxY: Math.max(0, Math.min(1000, currentViewport.maxY + dy)), + } + + queryClient.prefetchQuery({ + queryKey: [ + "graph-viewport", + prefetchViewport.minX, + prefetchViewport.maxX, + prefetchViewport.minY, + prefetchViewport.maxY, + containerTags?.join(","), + spaceId, + includeMemories, + limit, + ], + queryFn: async () => { + const response = await $fetch("@post/graph/viewport", { + body: { + viewport: prefetchViewport, + containerTags, + spaceId, + includeMemories, + 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, spaceId, includeMemories, limit], + ) + + // Compute derived state + const data = useMemo(() => { + return { + nodes: viewportQuery.data?.nodes ?? [], + 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 + + return { + data, + isLoading, + isRefetching, + error, + viewport, + updateViewport, + prefetchAdjacentViewports, + refetch: viewportQuery.refetch, + } +} + +/** + * Scales backend coordinates (0-1000) to graph canvas coordinates + * The backend uses a fixed 0-1000 range for spatial coordinates + */ +export function scaleBackendToCanvas( + x: number, + y: number, + canvasWidth: number, + canvasHeight: number, +): { x: number; y: number } { + // Scale from 0-1000 to canvas size, maintaining aspect ratio + 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, + } +} diff --git a/apps/web/components/new/memory-graph/hooks/use-graph-data.ts b/apps/web/components/new/memory-graph/hooks/use-graph-data.ts new file mode 100644 index 00000000..c92ffffc --- /dev/null +++ b/apps/web/components/new/memory-graph/hooks/use-graph-data.ts @@ -0,0 +1,305 @@ +"use client" + +import { useMemo, useRef, useEffect } from "react" +import { colors } from "../constants" +import { + getConnectionVisualProps, + getMagicalConnectionColor, +} from "../lib/similarity" +import type { + GraphNode, + GraphEdge, + GraphApiNode, + GraphApiEdge, + DocumentWithMemories, + MemoryEntry, +} from "../types" + +// Simplified data types for API nodes - compatible with node-popover display +interface ApiDocumentData { + id: string + title: string | null + summary: string | null + type: string + createdAt: string + updatedAt: string + memoryEntries: MemoryEntry[] +} + +interface ApiMemoryData { + id: string + documentId: string + memory: string | null + content: string | null + spaceId: string + isStatic?: boolean + createdAt: string + updatedAt: string +} + +/** + * Transform API nodes to graph nodes with visual properties + * Uses backend-provided spatial coordinates (0-1000 range) + */ +export function useGraphData( + apiNodes: GraphApiNode[], + apiEdges: GraphApiEdge[], + draggingNodeId: string | null, + canvasWidth: number, + canvasHeight: number, +) { + // Cache nodes to preserve d3-force mutations (vx, vy, fx, fy) during interactions + const nodeCache = useRef>(new Map()) + + // Cleanup stale nodes from cache + useEffect(() => { + if (!apiNodes || apiNodes.length === 0) return + + const currentNodeIds = new Set(apiNodes.map((n) => n.id)) + + for (const [id] of nodeCache.current.entries()) { + if (!currentNodeIds.has(id)) { + nodeCache.current.delete(id) + } + } + }, [apiNodes]) + + // Calculate scale factor to map 0-1000 backend coordinates to canvas + const { scale, offsetX, offsetY } = useMemo(() => { + if (canvasWidth === 0 || canvasHeight === 0) { + return { scale: 1, offsetX: 0, offsetY: 0 } + } + // Use a consistent scale that fits the 0-1000 range into the canvas + // with some padding for visual breathing room + 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]) + + // Transform API nodes to GraphNode format + const nodes = useMemo(() => { + if (!apiNodes || apiNodes.length === 0) { + return [] + } + + const result: GraphNode[] = [] + + for (const apiNode of apiNodes) { + // Scale backend coordinates to canvas coordinates + const scaledX = apiNode.x * scale + offsetX + const scaledY = apiNode.y * scale + offsetY + + // Check cache for existing node (preserves physics state) + let node = nodeCache.current.get(apiNode.id) + + const nodeData = createNodeData(apiNode) + + if (node) { + // Update data while preserving physics properties + node.data = nodeData as DocumentWithMemories | MemoryEntry + node.isDragging = draggingNodeId === apiNode.id + // Only update position if not being dragged and no fixed position + if (!node.isDragging && node.fx === null && node.fy === null) { + node.x = scaledX + node.y = scaledY + } + } else { + // Create new node + node = { + id: apiNode.id, + type: apiNode.type, + x: scaledX, + y: scaledY, + data: nodeData as DocumentWithMemories | MemoryEntry, + size: + apiNode.type === "document" + ? 58 + : calculateMemorySize(apiNode.content), + color: + apiNode.type === "document" + ? colors.document.primary + : colors.memory.primary, + isHovered: false, + isDragging: draggingNodeId === apiNode.id, + } + nodeCache.current.set(apiNode.id, node) + } + + result.push(node) + } + + return result + }, [apiNodes, scale, offsetX, offsetY, draggingNodeId]) + + // Transform API edges to GraphEdge format with visual properties + const edges = useMemo(() => { + if (!apiEdges || apiEdges.length === 0) { + return [] + } + + // Create a set of valid node IDs for quick lookup + const validNodeIds = new Set(apiNodes.map((n) => n.id)) + + const result: GraphEdge[] = [] + + for (const apiEdge of apiEdges) { + // Skip edges that reference nodes not in current viewport + if ( + !validNodeIds.has(apiEdge.source) || + !validNodeIds.has(apiEdge.target) + ) { + continue + } + + // Determine edge type based on connected node types + const sourceNode = apiNodes.find((n) => n.id === apiEdge.source) + const targetNode = apiNodes.find((n) => n.id === apiEdge.target) + + let edgeType: "doc-memory" | "doc-doc" | "version" = "doc-doc" + if (sourceNode && targetNode) { + if (sourceNode.type === "document" && targetNode.type === "memory") { + edgeType = "doc-memory" + } else if ( + sourceNode.type === "memory" && + targetNode.type === "document" + ) { + edgeType = "doc-memory" + } else if ( + sourceNode.type === "memory" && + targetNode.type === "memory" + ) { + edgeType = "version" + } + } + + // Calculate visual properties based on similarity + const visualProps = getConnectionVisualProps(apiEdge.similarity) + const edgeColor = + edgeType === "doc-memory" + ? colors.connection.memory + : getMagicalConnectionColor(apiEdge.similarity, 200) + + result.push({ + id: `edge-${apiEdge.source}-${apiEdge.target}`, + source: apiEdge.source, + target: apiEdge.target, + similarity: apiEdge.similarity, + visualProps, + color: edgeColor, + edgeType, + }) + } + + return result + }, [apiEdges, apiNodes]) + + return { nodes, edges, scale, offsetX, offsetY } +} + +/** + * Create node data object from API node + * This creates a minimal data object compatible with the existing popover/detail components + */ +function createNodeData( + apiNode: GraphApiNode, +): ApiDocumentData | ApiMemoryData { + if (apiNode.type === "document") { + return { + id: apiNode.id, + title: apiNode.title, + summary: apiNode.content, + type: apiNode.documentType || "document", + createdAt: apiNode.createdAt, + updatedAt: apiNode.updatedAt, + memoryEntries: [], // Documents from API don't include nested memories + } satisfies ApiDocumentData + } + // Memory node + return { + id: apiNode.id, + documentId: "", // Not available from API, but required for type compatibility + memory: apiNode.content, + content: apiNode.content, + spaceId: apiNode.spaceId || "default", + isStatic: apiNode.isStatic, + createdAt: apiNode.createdAt, + updatedAt: apiNode.updatedAt, + } satisfies ApiMemoryData +} + +/** + * Calculate memory node size based on content length + */ +function calculateMemorySize(content: string | null): number { + const length = content?.length || 50 + return Math.max(32, Math.min(48, length * 0.5)) +} + +/** + * Convert screen coordinates to backend coordinates (0-1000 range) + * Useful for updating viewport based on pan/zoom + */ +export function screenToBackendCoords( + screenX: number, + screenY: number, + panX: number, + panY: number, + zoom: number, + canvasWidth: number, + canvasHeight: number, +): { x: number; y: number } { + // First convert screen to canvas coordinates + const canvasX = (screenX - panX) / zoom + const canvasY = (screenY - panY) / zoom + + // Then convert canvas to backend coordinates + const paddingFactor = 0.8 + const scale = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000 + const offsetX = (canvasWidth - 1000 * scale) / 2 + const offsetY = (canvasHeight - 1000 * scale) / 2 + + return { + x: Math.max(0, Math.min(1000, (canvasX - offsetX) / scale)), + y: Math.max(0, Math.min(1000, (canvasY - offsetY) / scale)), + } +} + +/** + * Calculate the visible viewport in backend coordinates based on current pan/zoom + */ +export function calculateBackendViewport( + panX: number, + panY: number, + zoom: number, + canvasWidth: number, + canvasHeight: number, +): { minX: number; maxX: number; minY: number; maxY: number } { + // Calculate the four corners of the visible area in screen coordinates + 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.min(1000, Math.max(topLeft.x, bottomRight.x)), + minY: Math.max(0, Math.min(topLeft.y, bottomRight.y)), + maxY: Math.min(1000, Math.max(topLeft.y, bottomRight.y)), + } +} diff --git a/apps/web/components/new/memory-graph/hooks/use-graph-interactions.ts b/apps/web/components/new/memory-graph/hooks/use-graph-interactions.ts new file mode 100644 index 00000000..c8d2734c --- /dev/null +++ b/apps/web/components/new/memory-graph/hooks/use-graph-interactions.ts @@ -0,0 +1,609 @@ +"use client" + +import { useCallback, useRef, useState } from "react" +import { GRAPH_SETTINGS } from "../constants" +import type { GraphNode } from "../types" + +export function useGraphInteractions( + variant: "console" | "consumer" = "console", +) { + const settings = GRAPH_SETTINGS[variant] + + const [panX, setPanX] = useState(settings.initialPanX) + const [panY, setPanY] = useState(settings.initialPanY) + const [zoom, setZoom] = useState(settings.initialZoom) + const [isPanning, setIsPanning] = useState(false) + const [panStart, setPanStart] = useState({ x: 0, y: 0 }) + const [hoveredNode, setHoveredNode] = useState(null) + const [selectedNode, setSelectedNode] = useState(null) + const [draggingNodeId, setDraggingNodeId] = useState(null) + const [dragStart, setDragStart] = useState({ + x: 0, + y: 0, + nodeX: 0, + nodeY: 0, + }) + const [nodePositions, setNodePositions] = useState< + Map< + string, + { + x: number + y: number + parentDocId?: string + offsetX?: number + offsetY?: number + } + > + >(new Map()) + + // Touch gesture state + const [touchState, setTouchState] = useState<{ + touches: { id: number; x: number; y: number }[] + lastDistance: number + lastCenter: { x: number; y: number } + isGesturing: boolean + }>({ + touches: [], + lastDistance: 0, + lastCenter: { x: 0, y: 0 }, + isGesturing: false, + }) + + // Animation state for smooth transitions + const animationRef = useRef(null) + const [isAnimating, setIsAnimating] = useState(false) + + // Smooth animation helper + const animateToViewState = useCallback( + ( + targetPanX: number, + targetPanY: number, + targetZoom: number, + duration = 300, + ) => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + } + + const startPanX = panX + const startPanY = panY + const startZoom = zoom + const startTime = Date.now() + + setIsAnimating(true) + + const animate = () => { + const elapsed = Date.now() - startTime + const progress = Math.min(elapsed / duration, 1) + + // Ease out cubic function for smooth transitions + const easeOut = 1 - (1 - progress) ** 3 + + const currentPanX = startPanX + (targetPanX - startPanX) * easeOut + const currentPanY = startPanY + (targetPanY - startPanY) * easeOut + const currentZoom = startZoom + (targetZoom - startZoom) * easeOut + + setPanX(currentPanX) + setPanY(currentPanY) + setZoom(currentZoom) + + if (progress < 1) { + animationRef.current = requestAnimationFrame(animate) + } else { + setIsAnimating(false) + animationRef.current = null + } + } + + animate() + }, + [panX, panY, zoom], + ) + + // Node drag handlers + const handleNodeDragStart = useCallback( + (nodeId: string, e: React.MouseEvent, nodes?: GraphNode[]) => { + const node = nodes?.find((n) => n.id === nodeId) + if (!node) return + + setDraggingNodeId(nodeId) + setDragStart({ + x: e.clientX, + y: e.clientY, + nodeX: node.x, + nodeY: node.y, + }) + }, + [], + ) + + const handleNodeDragMove = useCallback( + (e: React.MouseEvent, nodes?: GraphNode[]) => { + if (!draggingNodeId) return + + const deltaX = (e.clientX - dragStart.x) / zoom + const deltaY = (e.clientY - dragStart.y) / zoom + + const newX = dragStart.nodeX + deltaX + const newY = dragStart.nodeY + deltaY + + // Find the node being dragged to determine if it's a memory + const draggedNode = nodes?.find((n) => n.id === draggingNodeId) + + if (draggedNode?.type === "memory") { + // For memory nodes, find the parent document and store relative offset + const memoryData = draggedNode.data as any // MemoryEntry type + const parentDoc = nodes?.find( + (n) => + n.type === "document" && + (n.data as any).memoryEntries?.some( + (m: any) => m.id === memoryData.id, + ), + ) + + if (parentDoc) { + // Store the offset from the parent document + const offsetX = newX - parentDoc.x + const offsetY = newY - parentDoc.y + + setNodePositions((prev) => + new Map(prev).set(draggingNodeId, { + x: newX, + y: newY, + parentDocId: parentDoc.id, + offsetX, + offsetY, + }), + ) + return + } + } + + // For document nodes or if parent not found, just store absolute position + setNodePositions((prev) => + new Map(prev).set(draggingNodeId, { x: newX, y: newY }), + ) + }, + [draggingNodeId, dragStart, zoom], + ) + + const handleNodeDragEnd = useCallback(() => { + setDraggingNodeId(null) + }, []) + + // Pan handlers + const handlePanStart = useCallback( + (e: React.MouseEvent) => { + setIsPanning(true) + setPanStart({ x: e.clientX - panX, y: e.clientY - panY }) + }, + [panX, panY], + ) + + const handlePanMove = useCallback( + (e: React.MouseEvent) => { + if (!isPanning || draggingNodeId) return + + const newPanX = e.clientX - panStart.x + const newPanY = e.clientY - panStart.y + setPanX(newPanX) + setPanY(newPanY) + }, + [isPanning, panStart, draggingNodeId], + ) + + const handlePanEnd = useCallback(() => { + setIsPanning(false) + }, []) + + // Zoom handlers + const handleWheel = useCallback( + (e: React.WheelEvent) => { + // Always prevent default to stop browser navigation + e.preventDefault() + e.stopPropagation() + + // Handle horizontal scrolling (trackpad swipe) by converting to pan + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + // Horizontal scroll - pan the graph instead of zooming + const panDelta = e.deltaX * 0.5 + setPanX((prev) => prev - panDelta) + return + } + + // Vertical scroll - zoom behavior + const delta = e.deltaY > 0 ? 0.97 : 1.03 + const newZoom = Math.max(0.05, Math.min(3, zoom * delta)) + + // Get mouse position relative to the viewport + let mouseX = e.clientX + let mouseY = e.clientY + + // Try to get the container bounds to make coordinates relative to the graph container + const target = e.currentTarget + if (target && "getBoundingClientRect" in target) { + const rect = target.getBoundingClientRect() + mouseX = e.clientX - rect.left + mouseY = e.clientY - rect.top + } + + // Calculate the world position of the mouse cursor + const worldX = (mouseX - panX) / zoom + const worldY = (mouseY - panY) / zoom + + // Calculate new pan to keep the mouse position stationary + const newPanX = mouseX - worldX * newZoom + const newPanY = mouseY - worldY * newZoom + + setZoom(newZoom) + setPanX(newPanX) + setPanY(newPanY) + }, + [zoom, panX, panY], + ) + + const zoomIn = useCallback( + (centerX?: number, centerY?: number, animate = true) => { + const zoomFactor = 1.2 + const newZoom = Math.min(3, zoom * zoomFactor) // Increased max zoom to 3x + + if (centerX !== undefined && centerY !== undefined) { + // Zoom towards the specified center point + 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 { + 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.05 + + if (centerX !== undefined && centerY !== undefined) { + // Zoom towards the specified center point + 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 { + setZoom(newZoom) + } + }, + [zoom, panX, panY, isAnimating, animateToViewState], + ) + + const resetView = useCallback( + (animate = true) => { + if (animate && !isAnimating) { + animateToViewState( + settings.initialPanX, + settings.initialPanY, + settings.initialZoom, + 300, + ) + } else { + setZoom(settings.initialZoom) + setPanX(settings.initialPanX) + setPanY(settings.initialPanY) + } + }, + [settings, isAnimating, animateToViewState], + ) + + // Auto-fit graph to viewport + const autoFitToViewport = useCallback( + ( + nodes: GraphNode[], + viewportWidth: number, + viewportHeight: number, + options?: { occludedRightPx?: number; animate?: boolean }, + ) => { + if (nodes.length === 0) return + + // Calculate bounding box 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) + maxX = Math.max(maxX, node.x + node.size) + minY = Math.min(minY, node.y - node.size) + maxY = Math.max(maxY, node.y + node.size) + }) + + // Add padding (10% on each side) + const paddingX = (maxX - minX) * 0.1 + const paddingY = (maxY - minY) * 0.1 + minX -= paddingX + maxX += paddingX + minY -= paddingY + maxY += paddingY + + const contentWidth = maxX - minX + const contentHeight = maxY - minY + const contentCenterX = minX + contentWidth / 2 + const contentCenterY = minY + contentHeight / 2 + + // Account for occluded area (e.g., chat panel on the right) + const occludedRightPx = options?.occludedRightPx ?? 0 + const availableWidth = viewportWidth - occludedRightPx + + // Calculate zoom to fit content + const zoomX = availableWidth / contentWidth + const zoomY = viewportHeight / contentHeight + const newZoom = Math.min(zoomX, zoomY, 1) // Cap at 1x to avoid over-zooming + + // Calculate pan to center the content within available area + const availableCenterX = availableWidth / 2 + const newPanX = availableCenterX - contentCenterX * newZoom + const newPanY = viewportHeight / 2 - contentCenterY * newZoom + + // Apply the new view (optional animation) + if (options?.animate) { + const steps = 8 + const durationMs = 160 // snappy + const intervalMs = Math.max(1, Math.floor(durationMs / steps)) + const startZoom = zoom + const startPanX = panX + const startPanY = panY + let i = 0 + const ease = (t: number) => 1 - (1 - t) ** 2 // ease-out quad + const timer = setInterval(() => { + i++ + const t = ease(i / steps) + setZoom(startZoom + (newZoom - startZoom) * t) + setPanX(startPanX + (newPanX - startPanX) * t) + setPanY(startPanY + (newPanY - startPanY) * t) + if (i >= steps) clearInterval(timer) + }, intervalMs) + } else { + setZoom(newZoom) + setPanX(newPanX) + setPanY(newPanY) + } + }, + [zoom, panX, panY], + ) + + // Touch gesture handlers for mobile pinch-to-zoom + const handleTouchStart = useCallback((e: React.TouchEvent) => { + const touches = Array.from(e.touches).map((touch) => ({ + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + })) + + if (touches.length >= 2) { + // Start gesture with two or more fingers + const touch1 = touches[0]! + const touch2 = touches[1]! + + const distance = Math.sqrt( + (touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2, + ) + + const center = { + x: (touch1.x + touch2.x) / 2, + y: (touch1.y + touch2.y) / 2, + } + + setTouchState({ + touches, + lastDistance: distance, + lastCenter: center, + isGesturing: true, + }) + } else { + setTouchState((prev) => ({ ...prev, touches, isGesturing: false })) + } + }, []) + + const handleTouchMove = useCallback( + (e: React.TouchEvent) => { + e.preventDefault() + + const touches = Array.from(e.touches).map((touch) => ({ + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + })) + + if (touches.length >= 2 && touchState.isGesturing) { + const touch1 = touches[0]! + const touch2 = touches[1]! + + const distance = Math.sqrt( + (touch2.x - touch1.x) ** 2 + (touch2.y - touch1.y) ** 2, + ) + + const center = { + x: (touch1.x + touch2.x) / 2, + y: (touch1.y + touch2.y) / 2, + } + + // Calculate zoom change based on pinch distance change + const distanceChange = distance / touchState.lastDistance + const newZoom = Math.max(0.05, Math.min(3, zoom * distanceChange)) + + // Get canvas bounds for center calculation + const canvas = e.currentTarget as HTMLElement + const rect = canvas.getBoundingClientRect() + const centerX = center.x - rect.left + const centerY = center.y - rect.top + + // Calculate the world position of the pinch center + const worldX = (centerX - panX) / zoom + const worldY = (centerY - panY) / zoom + + // Calculate new pan to keep the pinch center stationary + const newPanX = centerX - worldX * newZoom + const newPanY = centerY - worldY * newZoom + + // Calculate pan change based on center movement + const centerDx = center.x - touchState.lastCenter.x + const centerDy = center.y - touchState.lastCenter.y + + setZoom(newZoom) + setPanX(newPanX + centerDx) + setPanY(newPanY + centerDy) + + setTouchState({ + touches, + lastDistance: distance, + lastCenter: center, + isGesturing: true, + }) + } else if (touches.length === 1 && !touchState.isGesturing && isPanning) { + // Single finger pan (only if not in gesture mode) + const touch = touches[0]! + const newPanX = touch.x - panStart.x + const newPanY = touch.y - panStart.y + setPanX(newPanX) + setPanY(newPanY) + } + }, + [touchState, zoom, panX, panY, isPanning, panStart], + ) + + const handleTouchEnd = useCallback((e: React.TouchEvent) => { + const touches = Array.from(e.touches).map((touch) => ({ + id: touch.identifier, + x: touch.clientX, + y: touch.clientY, + })) + + if (touches.length < 2) { + setTouchState((prev) => ({ ...prev, touches, isGesturing: false })) + } else { + setTouchState((prev) => ({ ...prev, touches })) + } + + if (touches.length === 0) { + setIsPanning(false) + } + }, []) + + // Center viewport on a specific world position (with animation) + const centerViewportOn = useCallback( + ( + worldX: number, + worldY: number, + viewportWidth: number, + viewportHeight: number, + animate = true, + ) => { + const newPanX = viewportWidth / 2 - worldX * zoom + const newPanY = viewportHeight / 2 - worldY * zoom + + if (animate && !isAnimating) { + animateToViewState(newPanX, newPanY, zoom, 400) + } else { + setPanX(newPanX) + setPanY(newPanY) + } + }, + [zoom, isAnimating, animateToViewState], + ) + + // Node interaction handlers + const handleNodeHover = useCallback((nodeId: string | null) => { + setHoveredNode(nodeId) + }, []) + + const handleNodeClick = useCallback( + (nodeId: string) => { + setSelectedNode(selectedNode === nodeId ? null : nodeId) + }, + [selectedNode], + ) + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + // Calculate new zoom (zoom in by 1.5x) + const zoomFactor = 1.5 + const newZoom = Math.min(3, zoom * zoomFactor) + + // Get mouse position relative to the container + let mouseX = e.clientX + let mouseY = e.clientY + + // Try to get the container bounds to make coordinates relative to the graph container + const target = e.currentTarget + if (target && "getBoundingClientRect" in target) { + const rect = target.getBoundingClientRect() + mouseX = e.clientX - rect.left + mouseY = e.clientY - rect.top + } + + // Calculate the world position of the clicked point + const worldX = (mouseX - panX) / zoom + const worldY = (mouseY - panY) / zoom + + // Calculate new pan to keep the clicked point in the same screen position + const newPanX = mouseX - worldX * newZoom + const newPanY = mouseY - worldY * newZoom + + setZoom(newZoom) + setPanX(newPanX) + setPanY(newPanY) + }, + [zoom, panX, panY], + ) + + return { + // State + panX, + panY, + zoom, + hoveredNode, + selectedNode, + draggingNodeId, + nodePositions, + // Handlers + handlePanStart, + handlePanMove, + handlePanEnd, + handleWheel, + handleNodeHover, + handleNodeClick, + handleNodeDragStart, + handleNodeDragMove, + handleNodeDragEnd, + handleDoubleClick, + // Touch handlers + handleTouchStart, + handleTouchMove, + handleTouchEnd, + // Controls + zoomIn, + zoomOut, + resetView, + autoFitToViewport, + centerViewportOn, + setSelectedNode, + } +} diff --git a/apps/web/components/new/memory-graph/index.ts b/apps/web/components/new/memory-graph/index.ts new file mode 100644 index 00000000..9d14276e --- /dev/null +++ b/apps/web/components/new/memory-graph/index.ts @@ -0,0 +1,27 @@ +// Memory Graph components +export { MemoryGraph } from "./memory-graph" +export type { MemoryGraphProps } from "./memory-graph" +export { GraphCard } from "./graph-card" + +// Hooks +export { useGraphApi } from "./hooks/use-graph-api" +export { + useGraphData, + calculateBackendViewport, + screenToBackendCoords, +} from "./hooks/use-graph-data" +export { useGraphInteractions } from "./hooks/use-graph-interactions" +export { useForceSimulation } from "./hooks/use-force-simulation" + +// Types +export type { + GraphNode, + GraphEdge, + GraphApiNode, + GraphApiEdge, + GraphViewportResponse, + GraphBoundsResponse, + GraphStatsResponse, + DocumentWithMemories, + MemoryEntry, +} from "./types" diff --git a/apps/web/components/new/memory-graph/legend.tsx b/apps/web/components/new/memory-graph/legend.tsx new file mode 100644 index 00000000..3ce58c59 --- /dev/null +++ b/apps/web/components/new/memory-graph/legend.tsx @@ -0,0 +1,600 @@ +"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 ( + + ) +}) + +// Hexagon SVG for memory nodes +const HexagonIcon = memo(function HexagonIcon({ + fill = "#0D2034", + stroke = "#3B73B8", + opacity = 1, + size = 12, +}: { + fill?: string + stroke?: string + opacity?: number + size?: number +}) { + return ( + + ) +}) + +// Document icon (rounded square) +const DocumentIcon = memo(function DocumentIcon() { + return ( +
+
+
+ ) +}) + +// Connection icon (graph) +const ConnectionIcon = memo(function ConnectionIcon() { + return ( + + ) +}) + +// Line icon for connections +const LineIcon = memo(function LineIcon({ + color, + dashed = false, +}: { + color: string + dashed?: boolean +}) { + return ( +
+
+
+ ) +}) + +// Similarity circle icon +const SimilarityCircle = memo(function SimilarityCircle({ + variant, +}: { + variant: "strong" | "weak" +}) { + return ( +
+ ) +}) + +// Accordion row with count +const StatRow = memo(function StatRow({ + icon, + label, + count, + expandable = false, + expanded = false, + onToggle, + children, +}: { + icon: React.ReactNode + label: string + count: number + expandable?: boolean + expanded?: boolean + onToggle?: () => void + children?: React.ReactNode +}) { + return ( +
+ + {expandable && expanded && children && ( +
{children}
+ )} +
+ ) +}) + +// Toggle row for relations/similarity +const ToggleRow = memo(function ToggleRow({ + icon, + label, + checked, + onChange, +}: { + icon: React.ReactNode + label: string + checked: boolean + onChange: (checked: boolean) => void +}) { + return ( +
+
+ {icon} + {label} +
+ +
+ ) +}) + +export const Legend = memo(function Legend({ + variant: _variant = "console", + id, + nodes = [], + edges = [], + isLoading: _isLoading = false, +}: ExtendedLegendProps) { + const isMobile = useIsMobile() + const [isExpanded, setIsExpanded] = useState(false) + const [isInitialized, setIsInitialized] = useState(false) + + // Toggle states for relations + const [showUpdates, setShowUpdates] = useState(true) + const [showExtends, setShowExtends] = useState(true) + const [showInferences, setShowInferences] = useState(false) + + // Toggle states for similarity + const [showStrong, setShowStrong] = useState(true) + const [showWeak, setShowWeak] = useState(true) + + // Expanded accordion states + const [memoriesExpanded, setMemoriesExpanded] = useState(false) + const [documentsExpanded, setDocumentsExpanded] = useState(false) + const [connectionsExpanded, setConnectionsExpanded] = useState(true) + + // Load saved preference on client side + useEffect(() => { + if (!isInitialized) { + const savedState = getCookie("legendCollapsed") + if (savedState === "true") { + setIsExpanded(false) + } else if (savedState === "false") { + setIsExpanded(true) + } else { + // Default: collapsed on mobile, collapsed on desktop too (per Figma) + setIsExpanded(false) + } + setIsInitialized(true) + } + }, [isInitialized]) + + // Save to cookie when state changes + const handleToggleExpanded = (expanded: boolean) => { + setIsExpanded(expanded) + setCookie("legendCollapsed", expanded ? "false" : "true") + } + + // Calculate stats + const memoryCount = nodes.filter((n) => n.type === "memory").length + const documentCount = nodes.filter((n) => n.type === "document").length + const connectionCount = edges.length + + // Hide on mobile + if (isMobile) return null + + return ( +
+ + {/* Glass background */} +
+ +
+ {/* Header - always visible */} + + {isExpanded ? ( + + ) : ( + + )} + + Legend + + + + +
+ {/* Main content column */} +
+ {/* STATISTICS Section */} +
+ + STATISTICS + +
+ {/* Memories */} + + } + label="Memories" + count={memoryCount} + expandable + expanded={memoriesExpanded} + onToggle={() => setMemoriesExpanded(!memoriesExpanded)} + > +
+
+
+ + + Memory (latest) + +
+ 76 +
+
+
+ + + Memory (oldest) + +
+ 182 +
+
+
+
+ +
+ + Score + +
+ 23 +
+
+
+ + + New memory + +
+ 17 +
+
+
+ + + Expiring soon + +
+ 11 +
+
+
+
+ +
+ + Forgotten + +
+ 6 +
+
+
+ + {/* Documents */} + } + label="Documents" + count={documentCount} + expandable + expanded={documentsExpanded} + onToggle={() => setDocumentsExpanded(!documentsExpanded)} + /> + + {/* Connections */} + } + label="Connections" + count={connectionCount} + expandable + expanded={connectionsExpanded} + onToggle={() => + setConnectionsExpanded(!connectionsExpanded) + } + > +
+
+
+ + + Doc > Memory + +
+
+ } + label="Doc similarity" + checked={showStrong} + onChange={setShowStrong} + /> +
+
+
+
+ + {/* RELATIONS Section */} +
+ + RELATIONS + +
+ } + label="Updates" + checked={showUpdates} + onChange={setShowUpdates} + /> + } + label="Extends" + checked={showExtends} + onChange={setShowExtends} + /> + } + label="Inferences" + checked={showInferences} + onChange={setShowInferences} + /> +
+
+ + {/* SIMILARITY Section */} +
+ + SIMILARITY + +
+ } + label="Strong" + checked={showStrong} + onChange={setShowStrong} + /> + } + label="Weak" + checked={showWeak} + onChange={setShowWeak} + /> +
+
+
+ + {/* Scrollbar indicator */} +
+
+ +
+ +
+ ) +}) + +Legend.displayName = "Legend" diff --git a/apps/web/components/new/memory-graph/lib/similarity.ts b/apps/web/components/new/memory-graph/lib/similarity.ts new file mode 100644 index 00000000..09d3a2cc --- /dev/null +++ b/apps/web/components/new/memory-graph/lib/similarity.ts @@ -0,0 +1,115 @@ +// Utility functions for calculating semantic similarity between documents and memories + +/** + * Calculate cosine similarity between two normalized vectors (unit vectors) + * Since all embeddings in this system are normalized using normalizeEmbeddingFast, + * cosine similarity equals dot product for unit vectors. + */ +export const cosineSimilarity = ( + vectorA: number[], + vectorB: number[], +): number => { + if (vectorA.length !== vectorB.length) { + throw new Error("Vectors must have the same length") + } + + let dotProduct = 0 + + for (let i = 0; i < vectorA.length; i++) { + const vectorAi = vectorA[i] + const vectorBi = vectorB[i] + if ( + typeof vectorAi !== "number" || + typeof vectorBi !== "number" || + isNaN(vectorAi) || + isNaN(vectorBi) + ) { + throw new Error("Vectors must contain only numbers") + } + dotProduct += vectorAi * vectorBi + } + + return dotProduct +} + +/** + * Calculate semantic similarity between two documents + * Returns a value between 0 and 1, where 1 is most similar + */ +export const calculateSemanticSimilarity = ( + document1Embedding: number[] | null, + document2Embedding: number[] | null, +): number => { + // If we have both embeddings, use cosine similarity + if ( + document1Embedding && + document2Embedding && + document1Embedding.length > 0 && + document2Embedding.length > 0 + ) { + const similarity = cosineSimilarity(document1Embedding, document2Embedding) + // Convert from [-1, 1] to [0, 1] range + return similarity >= 0 ? similarity : 0 + } + + return 0 +} + +/** + * Calculate semantic similarity between a document and memory entry + * Returns a value between 0 and 1, where 1 is most similar + */ +export const calculateDocumentMemorySimilarity = ( + documentEmbedding: number[] | null, + memoryEmbedding: number[] | null, + relevanceScore?: number | null, +): number => { + // If we have both embeddings, use cosine similarity + if ( + documentEmbedding && + memoryEmbedding && + documentEmbedding.length > 0 && + memoryEmbedding.length > 0 + ) { + const similarity = cosineSimilarity(documentEmbedding, memoryEmbedding) + // Convert from [-1, 1] to [0, 1] range + return similarity >= 0 ? similarity : 0 + } + + // Fall back to relevance score from database (0-100 scale) + if (relevanceScore !== null && relevanceScore !== undefined) { + return Math.max(0, Math.min(1, relevanceScore / 100)) + } + + // Default similarity for connections without embeddings or relevance scores + return 0.5 +} + +/** + * Get visual properties for connection based on similarity + */ +export const getConnectionVisualProps = (similarity: number) => { + // Ensure similarity is between 0 and 1 + const normalizedSimilarity = Math.max(0, Math.min(1, similarity)) + + return { + opacity: Math.max(0, normalizedSimilarity), // 0 to 1 range + thickness: Math.max(1, normalizedSimilarity * 4), // 1 to 4 pixels + glow: normalizedSimilarity * 0.6, // Glow intensity + pulseDuration: 2000 + (1 - normalizedSimilarity) * 3000, // Faster pulse for higher similarity + } +} + +/** + * Generate magical color based on similarity and connection type + */ +export const getMagicalConnectionColor = ( + similarity: number, + hue = 220, +): string => { + const normalizedSimilarity = Math.max(0, Math.min(1, similarity)) + const saturation = 60 + normalizedSimilarity * 40 // 60% to 100% + const lightness = 40 + normalizedSimilarity * 30 // 40% to 70% + + return `hsl(${hue}, ${saturation}%, ${lightness}%)` +} diff --git a/apps/web/components/new/memory-graph/loading-indicator.tsx b/apps/web/components/new/memory-graph/loading-indicator.tsx new file mode 100644 index 00000000..41026304 --- /dev/null +++ b/apps/web/components/new/memory-graph/loading-indicator.tsx @@ -0,0 +1,32 @@ +"use client" + +import { GlassMenuEffect } from "@repo/ui/other/glass-effect" +import { Sparkles } from "lucide-react" +import { memo } from "react" +import type { LoadingIndicatorProps } from "./types" + +export const LoadingIndicator = memo( + ({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => { + if (!isLoading && !isLoadingMore) return null + + return ( +
+ {/* Glass effect background */} + + +
+
+ + + {isLoading + ? "Loading memory graph..." + : `Loading more documents... (${totalLoaded})`} + +
+
+
+ ) + }, +) + +LoadingIndicator.displayName = "LoadingIndicator" diff --git a/apps/web/components/new/memory-graph/memory-graph.tsx b/apps/web/components/new/memory-graph/memory-graph.tsx new file mode 100644 index 00000000..84701722 --- /dev/null +++ b/apps/web/components/new/memory-graph/memory-graph.tsx @@ -0,0 +1,644 @@ +"use client" + +import { GlassMenuEffect } from "@repo/ui/other/glass-effect" +import { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react" +import { GraphCanvas } from "./graph-canvas" +import { useGraphApi } from "./hooks/use-graph-api" +import { useGraphData, calculateBackendViewport } from "./hooks/use-graph-data" +import { useGraphInteractions } from "./hooks/use-graph-interactions" +import { useForceSimulation } from "./hooks/use-force-simulation" +import { Legend } from "./legend" +import { LoadingIndicator } from "./loading-indicator" +import { NavigationControls } from "./navigation-controls" +import { NodeHoverPopover } from "./node-hover-popover" +import { NodePopover } from "./node-popover" +import { SpacesDropdown } from "./spaces-dropdown" +import { colors } from "./constants" + +export interface MemoryGraphProps { + children?: React.ReactNode + isLoading?: boolean + error?: Error | null + variant?: "console" | "consumer" + legendId?: string + highlightDocumentIds?: string[] + highlightsVisible?: boolean + occludedRightPx?: number + // External space control + selectedSpace?: string + onSpaceChange?: (spaceId: string) => void + // Container/project filtering + containerTags?: string[] + // Include memories in the graph + includeMemories?: boolean + // Maximum nodes to fetch + maxNodes?: number + // Slideshow control + isSlideshowActive?: boolean + onSlideshowNodeChange?: (nodeId: string | null) => void + onSlideshowStop?: () => void +} + +/** + * Memory Graph component powered by the Graph API + * Uses backend-computed spatial positions and pre-computed edges + */ +export const MemoryGraph = ({ + children, + isLoading: externalIsLoading = false, + error: externalError = null, + variant = "console", + legendId, + highlightDocumentIds = [], + highlightsVisible = true, + occludedRightPx: _occludedRightPx = 0, + selectedSpace: externalSelectedSpace, + onSpaceChange: externalOnSpaceChange, + containerTags, + includeMemories = true, + maxNodes = 200, + isSlideshowActive = false, + onSlideshowNodeChange, + onSlideshowStop, +}: MemoryGraphProps) => { + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) + const containerRef = useRef(null) + + // Internal state for space selection + const [internalSelectedSpace, setInternalSelectedSpace] = + useState("all") + const selectedSpace = externalSelectedSpace ?? internalSelectedSpace + + const handleSpaceChange = useCallback( + (spaceId: string) => { + if (externalOnSpaceChange) { + externalOnSpaceChange(spaceId) + } else { + setInternalSelectedSpace(spaceId) + } + }, + [externalOnSpaceChange], + ) + + // Graph API hook + const { + data: apiData, + isLoading: apiIsLoading, + error: apiError, + updateViewport, + } = useGraphApi({ + containerTags, + spaceId: selectedSpace !== "all" ? selectedSpace : undefined, + includeMemories, + limit: maxNodes, + enabled: containerSize.width > 0 && containerSize.height > 0, + }) + + // Graph interactions + const { + panX, + panY, + zoom, + hoveredNode, + selectedNode, + draggingNodeId, + nodePositions: _nodePositions, + handlePanStart, + handlePanMove, + handlePanEnd, + handleWheel, + handleNodeHover, + handleNodeClick, + handleNodeDragStart, + handleNodeDragMove, + handleNodeDragEnd, + handleDoubleClick, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + setSelectedNode, + autoFitToViewport, + centerViewportOn, + zoomIn, + zoomOut, + } = useGraphInteractions(variant) + + // Transform API data to graph nodes/edges + const { nodes, edges } = useGraphData( + apiData.nodes, + apiData.edges, + draggingNodeId, + containerSize.width, + containerSize.height, + ) + + // State to trigger re-renders when simulation ticks + const [, forceRender] = useReducer((x: number) => x + 1, 0) + + // Track drag state for physics integration + const dragStateRef = useRef<{ + nodeId: string | null + startX: number + startY: number + nodeStartX: number + nodeStartY: number + }>({ nodeId: null, startX: 0, startY: 0, nodeStartX: 0, nodeStartY: 0 }) + + // Force simulation for interactive physics + const forceSimulation = useForceSimulation( + nodes, + edges, + () => forceRender(), + true, + ) + + // Update backend viewport when pan/zoom changes + const lastViewportRef = useRef("") + useEffect(() => { + if (containerSize.width === 0 || containerSize.height === 0) return + + const backendViewport = calculateBackendViewport( + panX, + panY, + zoom, + containerSize.width, + containerSize.height, + ) + + // Debounce by checking if viewport significantly changed + const viewportKey = `${Math.round(backendViewport.minX)}-${Math.round(backendViewport.maxX)}-${Math.round(backendViewport.minY)}-${Math.round(backendViewport.maxY)}` + if (viewportKey !== lastViewportRef.current) { + lastViewportRef.current = viewportKey + updateViewport(backendViewport) + } + }, [ + panX, + panY, + zoom, + containerSize.width, + containerSize.height, + updateViewport, + ]) + + // Auto-fit graph when data first loads + const hasAutoFittedRef = useRef(false) + useEffect(() => { + if ( + !hasAutoFittedRef.current && + nodes.length > 0 && + containerSize.width > 0 && + containerSize.height > 0 + ) { + const timer = setTimeout(() => { + autoFitToViewport(nodes, containerSize.width, containerSize.height) + hasAutoFittedRef.current = true + }, 100) + return () => clearTimeout(timer) + } + }, [nodes, containerSize.width, containerSize.height, autoFitToViewport]) + + // Reset auto-fit flag when nodes become empty + useEffect(() => { + if (nodes.length === 0) { + hasAutoFittedRef.current = false + } + }, [nodes.length]) + + // Extract unique spaces from nodes + const { availableSpaces, spaceMemoryCounts } = useMemo(() => { + const spaceSet = new Set() + const counts: Record = {} + + for (const node of apiData.nodes) { + if (node.type === "memory" && node.spaceId) { + spaceSet.add(node.spaceId) + counts[node.spaceId] = (counts[node.spaceId] || 0) + 1 + } + } + + return { + availableSpaces: Array.from(spaceSet).sort(), + spaceMemoryCounts: counts, + } + }, [apiData.nodes]) + + // Handle container resize + useEffect(() => { + const updateSize = () => { + if (containerRef.current) { + const newWidth = containerRef.current.clientWidth + const newHeight = containerRef.current.clientHeight + if ( + newWidth !== containerSize.width || + newHeight !== containerSize.height + ) { + setContainerSize({ width: newWidth, height: newHeight }) + } + } + } + + updateSize() + + const resizeObserver = new ResizeObserver(() => updateSize()) + if (containerRef.current) { + resizeObserver.observe(containerRef.current) + } + + return () => resizeObserver.disconnect() + }, [containerSize.width, containerSize.height]) + + // Handle node drag with physics + const handleNodeDragStartWithNodes = useCallback( + (nodeId: string, e: React.MouseEvent) => { + const node = nodes.find((n) => n.id === nodeId) + if (node) { + dragStateRef.current = { + nodeId, + startX: e.clientX, + startY: e.clientY, + nodeStartX: node.x, + nodeStartY: node.y, + } + node.fx = node.x + node.fy = node.y + forceSimulation.reheat() + } + handleNodeDragStart(nodeId, e, nodes) + }, + [nodes, handleNodeDragStart, forceSimulation], + ) + + const handleNodeDragMoveWithNodes = useCallback( + (e: React.MouseEvent) => { + const { nodeId, startX, startY, nodeStartX, nodeStartY } = + dragStateRef.current + if (!nodeId) return + + const node = nodes.find((n) => n.id === nodeId) + if (node) { + const dx = (e.clientX - startX) / zoom + const dy = (e.clientY - startY) / zoom + node.fx = nodeStartX + dx + node.fy = nodeStartY + dy + node.x = node.fx + node.y = node.fy + } + + handleNodeDragMove(e, nodes) + }, + [nodes, handleNodeDragMove, zoom], + ) + + const handleNodeDragEndWithPhysics = useCallback(() => { + const { nodeId } = dragStateRef.current + if (nodeId) { + const node = nodes.find((n) => n.id === nodeId) + if (node) { + node.fx = null + node.fy = null + } + } + + dragStateRef.current = { + nodeId: null, + startX: 0, + startY: 0, + nodeStartX: 0, + nodeStartY: 0, + } + + forceSimulation.coolDown() + handleNodeDragEnd() + }, [nodes, handleNodeDragEnd, forceSimulation]) + + const handleNodeClickWithPhysics = useCallback( + (nodeId: string) => { + handleNodeClick(nodeId) + forceSimulation.reheat() + setTimeout(() => forceSimulation.coolDown(), 500) + }, + [handleNodeClick, forceSimulation], + ) + + // Calculate popover position + const selectedNodeData = useMemo(() => { + if (!selectedNode) return null + return nodes.find((n) => n.id === selectedNode) || null + }, [selectedNode, nodes]) + + const popoverPosition = useMemo(() => { + if (!selectedNodeData || !containerRef.current) return null + + const containerRect = containerRef.current.getBoundingClientRect() + const screenX = selectedNodeData.x * zoom + panX + containerRect.left + const screenY = selectedNodeData.y * zoom + panY + containerRect.top + + const nodeSize = selectedNodeData.size * zoom + const popoverWidth = 320 + const popoverHeight = 400 + + let x = screenX + nodeSize / 2 + 16 + let y = screenY - popoverHeight / 4 + + if (x + popoverWidth > window.innerWidth - 20) { + x = screenX - nodeSize / 2 - popoverWidth - 16 + } + if (y + popoverHeight > window.innerHeight - 20) { + y = window.innerHeight - popoverHeight - 20 + } + if (y < 20) { + y = 20 + } + + return { x, y } + }, [selectedNodeData, zoom, panX, panY]) + + // Calculate hover popover position + const hoveredNodeData = useMemo(() => { + if (!hoveredNode || selectedNode) return null // Don't show hover when selected popover is open + return nodes.find((n) => n.id === hoveredNode) || null + }, [hoveredNode, selectedNode, nodes]) + + const hoverPopoverPosition = useMemo(() => { + if (!hoveredNodeData || !containerRef.current) return null + + // Calculate screen position relative to the container + const screenX = hoveredNodeData.x * zoom + panX + const screenY = hoveredNodeData.y * zoom + panY + const nodeRadius = (hoveredNodeData.size * zoom) / 2 + + return { screenX, screenY, nodeRadius } + }, [hoveredNodeData, zoom, panX, panY]) + + // Navigation controls + const handleCenter = useCallback(() => { + if (nodes.length === 0) return + + let sumX = 0 + let sumY = 0 + nodes.forEach((node) => { + sumX += node.x + sumY += node.y + }) + const centerX = sumX / nodes.length + const centerY = sumY / nodes.length + + centerViewportOn( + centerX, + centerY, + containerSize.width, + containerSize.height, + ) + }, [nodes, centerViewportOn, containerSize.width, containerSize.height]) + + const handleAutoFit = useCallback(() => { + if (nodes.length === 0) return + autoFitToViewport(nodes, containerSize.width, containerSize.height) + }, [nodes, autoFitToViewport, containerSize.width, containerSize.height]) + + // Slideshow logic + const slideshowIntervalRef = useRef(null) + const physicsTimeoutRef = useRef(null) + const lastSelectedIndexRef = useRef(-1) + const isSlideshowActiveRef = useRef(isSlideshowActive) + + useEffect(() => { + isSlideshowActiveRef.current = isSlideshowActive + }, [isSlideshowActive]) + + const nodesRef = useRef(nodes) + const handleNodeClickRef = useRef(handleNodeClick) + const centerViewportOnRef = useRef(centerViewportOn) + const containerSizeRef = useRef(containerSize) + const onSlideshowNodeChangeRef = useRef(onSlideshowNodeChange) + const forceSimulationRef = useRef(forceSimulation) + + useEffect(() => { + nodesRef.current = nodes + handleNodeClickRef.current = handleNodeClick + centerViewportOnRef.current = centerViewportOn + containerSizeRef.current = containerSize + onSlideshowNodeChangeRef.current = onSlideshowNodeChange + forceSimulationRef.current = forceSimulation + }, [ + nodes, + handleNodeClick, + centerViewportOn, + containerSize, + onSlideshowNodeChange, + forceSimulation, + ]) + + useEffect(() => { + if (slideshowIntervalRef.current) { + clearInterval(slideshowIntervalRef.current) + slideshowIntervalRef.current = null + } + if (physicsTimeoutRef.current) { + clearTimeout(physicsTimeoutRef.current) + physicsTimeoutRef.current = null + } + + if (!isSlideshowActive) { + setSelectedNode(null) + forceSimulation.coolDown() + return + } + + const selectRandomNode = () => { + if (!isSlideshowActiveRef.current) return + + const currentNodes = nodesRef.current + if (currentNodes.length === 0) return + + let randomIndex: number + if (currentNodes.length > 1) { + do { + randomIndex = Math.floor(Math.random() * currentNodes.length) + } while (randomIndex === lastSelectedIndexRef.current) + } else { + randomIndex = 0 + } + + lastSelectedIndexRef.current = randomIndex + const randomNode = currentNodes[randomIndex] + + if (randomNode) { + centerViewportOnRef.current( + randomNode.x, + randomNode.y, + containerSizeRef.current.width, + containerSizeRef.current.height, + ) + handleNodeClickRef.current(randomNode.id) + forceSimulationRef.current.reheat() + + if (physicsTimeoutRef.current) { + clearTimeout(physicsTimeoutRef.current) + } + physicsTimeoutRef.current = setTimeout(() => { + forceSimulationRef.current.coolDown() + physicsTimeoutRef.current = null + }, 1000) + + onSlideshowNodeChangeRef.current?.(randomNode.id) + } + } + + selectRandomNode() + slideshowIntervalRef.current = setInterval(() => selectRandomNode(), 3500) + + return () => { + if (slideshowIntervalRef.current) { + clearInterval(slideshowIntervalRef.current) + slideshowIntervalRef.current = null + } + if (physicsTimeoutRef.current) { + clearTimeout(physicsTimeoutRef.current) + physicsTimeoutRef.current = null + } + } + }, [isSlideshowActive, setSelectedNode, forceSimulation]) + + // Combined loading and error states + const isLoading = externalIsLoading || apiIsLoading + const error = externalError || apiError + + if (error) { + return ( +
+
+ +
+ Error loading graph: {error.message} +
+
+
+ ) + } + + return ( +
+ {/* Spaces selector */} + {variant === "console" && availableSpaces.length > 0 && ( +
+ +
+ )} + + {/* Loading indicator */} + + + {/* Legend */} + + + {/* Node popover */} + {selectedNodeData && popoverPosition && ( + setSelectedNode(null)} + containerBounds={containerRef.current?.getBoundingClientRect()} + onBackdropClick={isSlideshowActive ? onSlideshowStop : undefined} + /> + )} + + {/* Empty state */} + {!isLoading && + nodes.filter((n) => n.type === "document").length === 0 && + children} + + {/* Graph container */} +
+ {containerSize.width > 0 && containerSize.height > 0 && ( + + )} + + {/* Hover popover */} + {hoveredNodeData && hoverPopoverPosition && ( + + )} + + {/* Navigation controls */} + {containerSize.width > 0 && ( + + zoomIn(containerSize.width / 2, containerSize.height / 2) + } + onZoomOut={() => + zoomOut(containerSize.width / 2, containerSize.height / 2) + } + onAutoFit={handleAutoFit} + nodes={nodes} + className="absolute bottom-4 left-4 z-[15]" + /> + )} +
+
+ ) +} diff --git a/apps/web/components/new/memory-graph/navigation-controls.tsx b/apps/web/components/new/memory-graph/navigation-controls.tsx new file mode 100644 index 00000000..16d51c4a --- /dev/null +++ b/apps/web/components/new/memory-graph/navigation-controls.tsx @@ -0,0 +1,248 @@ +"use client" + +import { memo, useState } from "react" +import type { GraphNode } from "./types" +import { cn } from "@lib/utils" +import { Play, Settings } from "lucide-react" + +interface NavigationControlsProps { + onCenter: () => void + onZoomIn: () => void + onZoomOut: () => void + onAutoFit: () => void + nodes: GraphNode[] + className?: string + onPlaySlideshow?: () => void +} + +// Keyboard shortcut badge component +const KeyboardShortcut = memo(function KeyboardShortcut({ + keys, +}: { + keys: string +}) { + return ( +
+ + {keys} + +
+ ) +}) + +// Timeline bar component +const TimelineBar = memo(function TimelineBar({ + width, + opacity = 1, +}: { + width: number + opacity?: number +}) { + return ( +
+ ) +}) + +// Visualizer component (left side) +const Visualizer = memo(function Visualizer({ + onPlaySlideshow, +}: { + onPlaySlideshow?: () => void +}) { + return ( +
+ {/* Play button row */} +
+ +
+ + {/* Keyboard shortcut */} +
+ +
+ + {/* Timeline section */} +
+ {/* Today row */} +
+ + Today +
+ + {/* Timeline bars */} +
+ {Array.from({ length: 10 }).map((_, i) => ( + + ))} +
+
+
+ ) +}) + +// Navigation buttons component +const NavigationButtons = memo(function NavigationButtons({ + onAutoFit, + onCenter, + onZoomIn, + onZoomOut, +}: { + onAutoFit: () => void + onCenter: () => void + onZoomIn: () => void + onZoomOut: () => void +}) { + const [zoomLevel, setZoomLevel] = useState(100) + + const handleZoomIn = () => { + setZoomLevel((prev) => Math.min(prev + 25, 200)) + onZoomIn() + } + + const handleZoomOut = () => { + setZoomLevel((prev) => Math.max(prev - 25, 25)) + onZoomOut() + } + + return ( +
+ {/* Fit button */} + + + {/* Center button */} + + + {/* Zoom controls */} +
+ {zoomLevel}% +
+ + +
+
+
+ ) +}) + +// Settings button +const SettingsButton = memo(function SettingsButton() { + return ( + + ) +}) + +export const NavigationControls = memo( + ({ + onCenter, + onZoomIn, + onZoomOut, + onAutoFit, + nodes, + className = "", + onPlaySlideshow, + }) => { + if (nodes.length === 0) { + return null + } + + return ( +
+ {/* Top row: Visualizer */} + + + {/* Bottom row: Navigation + Settings */} +
+ + +
+
+ ) + }, +) + +NavigationControls.displayName = "NavigationControls" diff --git a/apps/web/components/new/memory-graph/node-detail-panel.tsx b/apps/web/components/new/memory-graph/node-detail-panel.tsx new file mode 100644 index 00000000..fbe364ad --- /dev/null +++ b/apps/web/components/new/memory-graph/node-detail-panel.tsx @@ -0,0 +1,257 @@ +"use client" + +import { Badge } from "@ui/components/badge" +import { Button } from "@ui/components/button" +import { GlassMenuEffect } from "@repo/ui/other/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 type { DocumentWithMemories, MemoryEntry } from "./types" +import type { NodeDetailPanelProps } from "./types" +import { cn } from "@lib/utils" + +const formatDocumentType = (type: string) => { + // Special case for PDF + if (type.toLowerCase() === "pdf") return "PDF" + + // Replace underscores with spaces and capitalize each word + return type + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" ") +} + +const getDocumentIcon = (type: string) => { + const iconProps = { className: "w-5 h-5 text-slate-300" } + + switch (type) { + case "google_doc": + return + case "google_sheet": + return + case "google_slide": + return + case "google_drive": + return + case "notion": + case "notion_doc": + return + case "word": + case "microsoft_word": + return + case "excel": + case "microsoft_excel": + return + case "powerpoint": + case "microsoft_powerpoint": + return + case "onenote": + case "microsoft_onenote": + return + case "onedrive": + return + case "pdf": + return + default: + return + } +} + +export const NodeDetailPanel = memo(function NodeDetailPanel({ + node, + onClose, + variant = "console", +}: NodeDetailPanelProps) { + if (!node) return null + + const isDocument = node.type === "document" + const data = node.data + + return ( + + {/* Glass effect background */} + + + +
+
+ {isDocument ? ( + getDocumentIcon((data as DocumentWithMemories).type ?? "") + ) : ( + + )} +

+ {isDocument ? "Document" : "Memory"} +

+
+ + + +
+ +
+ {isDocument ? ( + <> +
+ + Title + +

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

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

+ {(data as DocumentWithMemories).summary} +

+
+ )} + +
+ + Type + +

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

+
+ +
+ + Memory Count + +

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

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

+ {(data as MemoryEntry).memory} +

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

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

+ )} +
+ +
+ + Space + +

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

+
+ + )} + +
+
+ + + {new Date(data.createdAt).toLocaleDateString()} + + + + {node.id} + +
+
+
+
+
+ ) +}) + +NodeDetailPanel.displayName = "NodeDetailPanel" diff --git a/apps/web/components/new/memory-graph/node-hover-popover.tsx b/apps/web/components/new/memory-graph/node-hover-popover.tsx new file mode 100644 index 00000000..f81a1079 --- /dev/null +++ b/apps/web/components/new/memory-graph/node-hover-popover.tsx @@ -0,0 +1,278 @@ +"use client" + +import { memo, useMemo } from "react" +import type { GraphNode } from "./types" + +export interface NodeHoverPopoverProps { + node: GraphNode + screenX: number + screenY: number + nodeRadius: number + containerBounds?: DOMRect +} + +// Small hexagon icon for the "Latest" badge +function HexagonIcon({ className }: { className?: string }) { + return ( + + ) +} + +// Globe icon for document type +function GlobeIcon() { + return ( + + ) +} + +// Keyboard shortcut badge +function KeyBadge({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +export const NodeHoverPopover = memo( + function NodeHoverPopover({ + node, + screenX, + screenY, + nodeRadius, + containerBounds, + }) { + // Calculate position - place popover to the right of the node with connector + const { popoverX, popoverY, iconX, iconY, connectorPath } = useMemo(() => { + const gap = 20 // Gap between node and icon + const iconSize = 40 + const popoverWidth = 320 // Approximate total width + const popoverHeight = 135 // Approximate total height + + // Default position: to the right of the node + let pX = screenX + nodeRadius + gap + iconSize + 12 + let pY = screenY - popoverHeight / 2 + + // Icon position + let iX = screenX + nodeRadius + gap + let iY = screenY - iconSize / 2 + + // Adjust if too close to edges + if (containerBounds) { + const rightEdge = containerBounds.width - 20 + const bottomEdge = containerBounds.height - 20 + + // If popover goes off right edge, flip to left side + if (pX + popoverWidth - iconSize - 12 > rightEdge) { + pX = screenX - nodeRadius - gap - popoverWidth + iX = screenX - nodeRadius - gap - iconSize + } + + // Keep within vertical bounds + if (pY < 20) pY = 20 + if (pY + popoverHeight > bottomEdge) pY = bottomEdge - popoverHeight + + // Keep icon within vertical bounds + if (iY < 20) iY = 20 + if (iY + iconSize > bottomEdge) iY = bottomEdge - iconSize + } + + // Connector SVG path from node edge to icon center + const nodeEdgeX = screenX + nodeRadius + const iconCenterX = iX + iconSize / 2 + const iconCenterY = iY + iconSize / 2 + + const path = `M ${nodeEdgeX} ${screenY} L ${iconCenterX} ${iconCenterY}` + + return { + popoverX: pX, + popoverY: pY, + iconX: iX, + iconY: iY, + connectorPath: path, + } + }, [screenX, screenY, nodeRadius, containerBounds]) + + const content = useMemo(() => { + if (node.type === "memory") { + return (node.data as any).memory || (node.data as any).content || "" + } + return (node.data as any).summary || (node.data as any).title || "" + }, [node]) + + const truncatedContent = useMemo(() => { + if (!content) return "No content" + if (content.length > 120) { + return `${content.substring(0, 120)}...` + } + return content + }, [content]) + + return ( +
+ {/* Connector line from node to icon */} + + + {/* Document type icon */} +
+
+ +
+
+ + {/* Main popover card */} +
+ {/* Memory card */} +
+ {/* Content area */} +
+

+ {truncatedContent} +

+
+ + {/* Bottom bar */} +
+ {/* Version with gradient text */} + + v1 + + + {/* Latest badge */} +
+ + + Latest + +
+
+
+ + {/* Keyboard shortcuts panel */} +
+
+ + + Go to document + +
+
+ + + Next memory + +
+
+ + + Previous memory + +
+
+
+
+ ) + }, +) diff --git a/apps/web/components/new/memory-graph/node-popover.tsx b/apps/web/components/new/memory-graph/node-popover.tsx new file mode 100644 index 00000000..7a2a9c70 --- /dev/null +++ b/apps/web/components/new/memory-graph/node-popover.tsx @@ -0,0 +1,362 @@ +"use client" + +import { memo, useEffect } from "react" +import type { GraphNode } from "./types" +import { cn } from "@lib/utils" + +export interface NodePopoverProps { + node: GraphNode + x: number // Screen X position + y: number // Screen Y position + onClose: () => void + containerBounds?: DOMRect // Optional container bounds to limit backdrop + onBackdropClick?: () => void // Optional callback when backdrop is clicked +} + +export const NodePopover = memo(function NodePopover({ + node, + x, + y, + onClose, + containerBounds, + onBackdropClick, +}) { + // Handle Escape key to close popover + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [onClose]) + + // Calculate backdrop bounds - use container bounds if provided, otherwise full viewport + const backdropStyle = containerBounds + ? { + left: `${containerBounds.left}px`, + top: `${containerBounds.top}px`, + width: `${containerBounds.width}px`, + height: `${containerBounds.height}px`, + } + : undefined + + const handleBackdropClick = () => { + onBackdropClick?.() + onClose() + } + + return ( + <> + {/* Invisible backdrop to catch clicks outside */} +
+ + {/* Popover content */} +
e.stopPropagation()} // Prevent closing when clicking inside + className="fixed backdrop-blur-[12px] bg-white/5 border border-white/25 rounded-xl p-4 w-80 z-[1000] pointer-events-auto shadow-[0_20px_25px_-5px_rgb(0_0_0/0.3),0_8px_10px_-6px_rgb(0_0_0/0.3)]" + style={{ + left: `${x}px`, + top: `${y}px`, + }} + > + {node.type === "document" ? ( + // Document popover +
+ {/* Header */} +
+
+ + + + + + + +

Document

+
+ +
+ + {/* Sections */} +
+ {/* Title */} +
+
+ Title +
+

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

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

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

+
+ )} + + {/* Type */} +
+
+ Type +
+

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

+
+ + {/* Memory Count */} +
+
+ Memory Count +
+

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

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

Memory

+
+ +
+ + {/* Sections */} +
+ {/* Memory content */} +
+
+ Memory +
+

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

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

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

+ )} +
+ + {/* Space */} +
+
+ Space +
+

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

+
+ + {/* Footer with metadata */} +
+
+ + + + + + + + {new Date( + (node.data as any).createdAt, + ).toLocaleDateString()} + +
+
+ + + + + + + + {node.id} + +
+
+
+
+ )} +
+ + ) +}) diff --git a/apps/web/components/new/memory-graph/spaces-dropdown.tsx b/apps/web/components/new/memory-graph/spaces-dropdown.tsx new file mode 100644 index 00000000..46e25a5e --- /dev/null +++ b/apps/web/components/new/memory-graph/spaces-dropdown.tsx @@ -0,0 +1,256 @@ +"use client" + +import { Badge } from "@ui/components/badge" +import { ChevronDown, Eye, Search, X } from "lucide-react" +import { memo, useEffect, useRef, useState } from "react" +import type { SpacesDropdownProps } from "./types" +import { cn } from "@lib/utils" + +export const SpacesDropdown = memo( + ({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => { + const [isOpen, setIsOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + const [highlightedIndex, setHighlightedIndex] = useState(-1) + const dropdownRef = useRef(null) + const searchInputRef = useRef(null) + const itemRefs = useRef>(new Map()) + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false) + } + } + + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + // Focus search input when dropdown opens + useEffect(() => { + if (isOpen && searchInputRef.current) { + searchInputRef.current.focus() + } + }, [isOpen]) + + // Clear search query and reset highlighted index when dropdown closes + useEffect(() => { + if (!isOpen) { + setSearchQuery("") + setHighlightedIndex(-1) + } + }, [isOpen]) + + // Filter spaces based on search query (client-side) + const filteredSpaces = searchQuery + ? availableSpaces.filter((space) => + space.toLowerCase().includes(searchQuery.toLowerCase()), + ) + : availableSpaces + + const totalMemories = Object.values(spaceMemoryCounts).reduce( + (sum, count) => sum + count, + 0, + ) + + // Total items including "Latest" option + const totalItems = filteredSpaces.length + 1 + + // Scroll highlighted item into view + useEffect(() => { + if (highlightedIndex >= 0 && highlightedIndex < totalItems) { + const element = itemRefs.current.get(highlightedIndex) + if (element) { + element.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }) + } + } + }, [highlightedIndex, totalItems]) + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) return + + switch (e.key) { + case "ArrowDown": + e.preventDefault() + setHighlightedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0)) + break + case "ArrowUp": + e.preventDefault() + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1)) + break + case "Enter": + e.preventDefault() + if (highlightedIndex === 0) { + onSpaceChange("all") + setIsOpen(false) + } else if ( + highlightedIndex > 0 && + highlightedIndex <= filteredSpaces.length + ) { + const selected = filteredSpaces[highlightedIndex - 1] + if (selected) { + onSpaceChange(selected) + setIsOpen(false) + } + } + break + case "Escape": + e.preventDefault() + setIsOpen(false) + break + } + } + + return ( +
+ + + {isOpen && ( +
+
+ {/* Search Input - Always show for filtering */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search spaces..." + ref={searchInputRef} + type="text" + value={searchQuery} + /> + {searchQuery && ( + + )} +
+
+ + {/* Spaces List */} +
+ {/* Always show "Latest" option */} + + + {/* Show all spaces, filtered by search query */} + {filteredSpaces.length > 0 + ? filteredSpaces.map((space, index) => { + const itemIndex = index + 1 + return ( + + ) + }) + : searchQuery && ( +
+ No spaces found matching "{searchQuery}" +
+ )} +
+
+
+ )} +
+ ) + }, +) + +SpacesDropdown.displayName = "SpacesDropdown" diff --git a/apps/web/components/new/memory-graph/types.ts b/apps/web/components/new/memory-graph/types.ts new file mode 100644 index 00000000..53762d72 --- /dev/null +++ b/apps/web/components/new/memory-graph/types.ts @@ -0,0 +1,219 @@ +import type { + DocumentsResponse, + DocumentWithMemories, + MemoryEntry, +} from "./api-types" + +// Re-export for convenience +export type { DocumentsResponse, DocumentWithMemories, MemoryEntry } + +// New Graph API types +export interface GraphApiNode { + id: string + type: "document" | "memory" + title: string | null + content: string | null + createdAt: string + updatedAt: string + x: number // 0-1000 + y: number // 0-1000 + // document-specific + documentType?: string + // memory-specific + isStatic?: boolean + spaceId?: string +} + +export interface GraphApiEdge { + source: string + target: string + similarity: number // 0-1 +} + +export interface GraphViewportResponse { + nodes: GraphApiNode[] + 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 + totalMemories: number + memoriesWithSpatial: number + totalMemoryEdges: number +} + +export interface GraphNode { + id: string + type: "document" | "memory" + x: number + y: number + data: DocumentWithMemories | MemoryEntry + size: number + color: 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) +} + +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 +} + +export interface SpacesDropdownProps { + selectedSpace: string + availableSpaces: string[] + spaceMemoryCounts: Record + onSpaceChange: (space: string) => void +} + +export interface NodeDetailPanelProps { + node: GraphNode | null + onClose: () => void + variant?: "console" | "consumer" +} + +export interface 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 + highlightDocumentIds?: string[] + // Physics simulation state + isSimulationActive?: boolean + // Selected node ID - dims all other nodes and edges + selectedNodeId?: string | null +} + +export interface MemoryGraphProps { + /** The documents to display in the graph */ + documents: DocumentWithMemories[] + /** Whether the initial 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) */ + isLoadingMore?: boolean + /** Total number of documents loaded */ + totalLoaded?: number + /** Whether there are more documents to load */ + hasMore?: boolean + /** Callback to load more documents (for pagination) */ + loadMoreDocuments?: () => Promise + /** Show/hide the spaces filter dropdown */ + showSpacesSelector?: boolean + /** Visual variant - "console" for full view, "consumer" for embedded */ + variant?: "console" | "consumer" + /** Optional ID for the legend component */ + legendId?: string + /** Document IDs to highlight in the graph */ + highlightDocumentIds?: string[] + /** Whether highlights are currently 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 + + // 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) */ + maxNodes?: number + + // Feature flags + /** Enable experimental features */ + isExperimental?: boolean + + // Slideshow control + /** Whether slideshow mode is currently active */ + 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 +} + +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" +} + +export interface ControlsProps { + onZoomIn: () => void + onZoomOut: () => void + onResetView: () => void + variant?: "console" | "consumer" +} diff --git a/apps/web/components/new/memory-graph/utils/document-icons.ts b/apps/web/components/new/memory-graph/utils/document-icons.ts new file mode 100644 index 00000000..2e93c22a --- /dev/null +++ b/apps/web/components/new/memory-graph/utils/document-icons.ts @@ -0,0 +1,237 @@ +/** + * Canvas-based document type icon rendering utilities + * Simplified to match supported file types: PDF, TXT, MD, DOCX, DOC, RTF, CSV, JSON + */ + +export type DocumentIconType = + | "text" + | "pdf" + | "md" + | "markdown" + | "docx" + | "doc" + | "rtf" + | "csv" + | "json" + +/** + * Draws a document type icon on canvas + * @param ctx - Canvas 2D rendering context + * @param x - X position (center of icon) + * @param y - Y position (center of icon) + * @param size - Icon size (width/height) + * @param type - Document type + * @param color - Icon color (default: white) + */ +export function drawDocumentIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + type: string, + color = "rgba(255, 255, 255, 0.9)", +): void { + ctx.save() + ctx.fillStyle = color + ctx.strokeStyle = color + ctx.lineWidth = Math.max(1, size / 12) + ctx.lineCap = "round" + ctx.lineJoin = "round" + + switch (type) { + case "pdf": + drawPdfIcon(ctx, x, y, size) + break + case "md": + case "markdown": + drawMarkdownIcon(ctx, x, y, size) + break + case "doc": + case "docx": + drawWordIcon(ctx, x, y, size) + break + case "rtf": + drawRtfIcon(ctx, x, y, size) + break + case "csv": + drawCsvIcon(ctx, x, y, size) + break + case "json": + drawJsonIcon(ctx, x, y, size) + break + case "txt": + case "text": + default: + drawTextIcon(ctx, x, y, size) + break + } + + ctx.restore() +} + +// Individual icon drawing functions + +function drawTextIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + // Simple document outline with lines + const w = size * 0.7 + const h = size * 0.85 + const cornerFold = size * 0.2 + + ctx.beginPath() + ctx.moveTo(x - w / 2, y - h / 2) + ctx.lineTo(x + w / 2 - cornerFold, y - h / 2) + ctx.lineTo(x + w / 2, y - h / 2 + cornerFold) + ctx.lineTo(x + w / 2, y + h / 2) + ctx.lineTo(x - w / 2, y + h / 2) + ctx.closePath() + ctx.stroke() + + // Text lines + const lineSpacing = size * 0.15 + const lineWidth = size * 0.4 + ctx.beginPath() + ctx.moveTo(x - lineWidth / 2, y - lineSpacing) + ctx.lineTo(x + lineWidth / 2, y - lineSpacing) + ctx.moveTo(x - lineWidth / 2, y) + ctx.lineTo(x + lineWidth / 2, y) + ctx.moveTo(x - lineWidth / 2, y + lineSpacing) + ctx.lineTo(x + lineWidth / 2, y + lineSpacing) + ctx.stroke() +} + +function drawPdfIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + // Document with "PDF" text + const w = size * 0.7 + const h = size * 0.85 + + ctx.beginPath() + ctx.rect(x - w / 2, y - h / 2, w, h) + ctx.stroke() + + // "PDF" letters (simplified) + ctx.font = `bold ${size * 0.35}px sans-serif` + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.fillText("PDF", x, y) +} + +function drawMarkdownIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + // Document with "MD" text + const w = size * 0.7 + const h = size * 0.85 + + ctx.beginPath() + ctx.rect(x - w / 2, y - h / 2, w, h) + ctx.stroke() + + // "MD" letters + ctx.font = `bold ${size * 0.3}px sans-serif` + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.fillText("MD", x, y) +} + +function drawWordIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + // Document with "DOC" text + const w = size * 0.7 + const h = size * 0.85 + + ctx.beginPath() + ctx.rect(x - w / 2, y - h / 2, w, h) + ctx.stroke() + + // "DOC" letters + ctx.font = `bold ${size * 0.28}px sans-serif` + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.fillText("DOC", x, y) +} + +function drawRtfIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + // Document with "RTF" text + const w = size * 0.7 + const h = size * 0.85 + + ctx.beginPath() + ctx.rect(x - w / 2, y - h / 2, w, h) + ctx.stroke() + + // "RTF" letters + ctx.font = `bold ${size * 0.3}px sans-serif` + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.fillText("RTF", x, y) +} + +function drawCsvIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + // Grid table for CSV + const w = size * 0.7 + const h = size * 0.85 + + ctx.strokeRect(x - w / 2, y - h / 2, w, h) + + // Grid lines (2x2) + ctx.beginPath() + // Vertical line + ctx.moveTo(x, y - h / 2) + ctx.lineTo(x, y + h / 2) + // Horizontal line + ctx.moveTo(x - w / 2, y) + ctx.lineTo(x + w / 2, y) + ctx.stroke() +} + +function drawJsonIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + // Curly braces for JSON + const w = size * 0.6 + const h = size * 0.8 + + // Left brace + ctx.beginPath() + ctx.moveTo(x - w / 4, y - h / 2) + ctx.quadraticCurveTo(x - w / 2, y - h / 3, x - w / 2, y) + ctx.quadraticCurveTo(x - w / 2, y + h / 3, x - w / 4, y + h / 2) + ctx.stroke() + + // Right brace + ctx.beginPath() + ctx.moveTo(x + w / 4, y - h / 2) + ctx.quadraticCurveTo(x + w / 2, y - h / 3, x + w / 2, y) + ctx.quadraticCurveTo(x + w / 2, y + h / 3, x + w / 4, y + h / 2) + ctx.stroke() +} diff --git a/apps/web/components/new/onboarding/setup/chat-sidebar.tsx b/apps/web/components/new/onboarding/setup/chat-sidebar.tsx index 47af432d..0122b03e 100644 --- a/apps/web/components/new/onboarding/setup/chat-sidebar.tsx +++ b/apps/web/components/new/onboarding/setup/chat-sidebar.tsx @@ -67,9 +67,9 @@ export function ChatSidebar({ formData }: ChatSidebarProps) { "correct" | "incorrect" | null >(null) const [isConfirmed, setIsConfirmed] = useState(false) - const [processingByUrl, setProcessingByUrl] = useState>( - {}, - ) + const [processingByUrl, setProcessingByUrl] = useState< + Record + >({}) const displayedMemoriesRef = useRef>(new Set()) const contextInjectedRef = useRef(false) const draftsBuiltRef = useRef(false) diff --git a/apps/web/components/onboarding/new-onboarding-modal.tsx b/apps/web/components/onboarding/new-onboarding-modal.tsx index 5dc545c1..8313f150 100644 --- a/apps/web/components/onboarding/new-onboarding-modal.tsx +++ b/apps/web/components/onboarding/new-onboarding-modal.tsx @@ -36,11 +36,14 @@ export function NewOnboardingModal() { } return ( - { - if (!isOpen) { - setOpen(false) - } - }}> + { + if (!isOpen) { + setOpen(false) + } + }} + > e.preventDefault()}> Experience the new onboarding diff --git a/apps/web/package.json b/apps/web/package.json index 178b2e2b..fda9128a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -61,11 +61,13 @@ "@tiptap/react": "^3.15.3", "@tiptap/starter-kit": "^3.15.3", "@tiptap/suggestion": "^3.15.3", + "@types/d3-force": "^3.0.10", "@types/dompurify": "^3.2.0", "ai": "^6.0.35", "autumn-js": "0.0.116", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "d3-force": "^3.0.0", "date-fns": "^4.1.0", "dompurify": "^3.2.7", "dotenv": "^16.6.0", diff --git a/packages/lib/auth-context.tsx b/packages/lib/auth-context.tsx index 67e57d49..3c32d613 100644 --- a/packages/lib/auth-context.tsx +++ b/packages/lib/auth-context.tsx @@ -18,9 +18,7 @@ interface AuthContextType { user: SessionData["user"] | null org: Organization | null setActiveOrg: (orgSlug: string) => Promise - updateOrgMetadata: ( - partial: Record, - ) => void + updateOrgMetadata: (partial: Record) => void } const AuthContext = createContext(undefined) @@ -39,21 +37,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { setOrg(activeOrg) } - const updateOrgMetadata = useCallback( - (partial: Record) => { - setOrg((prev) => { - if (!prev) return prev - return { - ...prev, - metadata: { - ...prev.metadata, - ...partial, - }, - } - }) - }, - [], - ) + const updateOrgMetadata = useCallback((partial: Record) => { + setOrg((prev) => { + if (!prev) return prev + return { + ...prev, + metadata: { + ...prev.metadata, + ...partial, + }, + } + }) + }, []) // biome-ignore lint/correctness/useExhaustiveDependencies: ignoring the setActiveOrg dependency useEffect(() => {