From 5dd358a939498d37ecea4dd6ddaf03e2fa637a97 Mon Sep 17 00:00:00 2001 From: nexxeln <95541290+nexxeln@users.noreply.github.com> Date: Mon, 9 Feb 2026 07:02:20 +0000 Subject: [PATCH] graph frontend (#713) --- apps/memory-graph-playground/next-env.d.ts | 2 +- apps/web/app/new/page.tsx | 124 +++- apps/web/components/graph-dialog.tsx | 168 +---- apps/web/components/new/chat/index.tsx | 7 +- .../new/document-cards/notion-preview.tsx | 217 ++++++ .../document-modal/graph-list-memories.tsx | 175 +++-- .../components/new/document-modal/index.tsx | 1 + apps/web/components/new/graph-layout-view.tsx | 77 ++ apps/web/components/new/header.tsx | 7 +- apps/web/components/new/highlights-card.tsx | 5 - apps/web/components/new/memories-grid.tsx | 96 +-- .../components/new/memory-graph/api-types.ts | 79 ++ .../new/memory-graph/canvas/hit-test.ts | 70 ++ .../new/memory-graph/canvas/input-handler.ts | 324 +++++++++ .../new/memory-graph/canvas/renderer.ts | 681 ++++++++++++++++++ .../new/memory-graph/canvas/simulation.ts | 79 ++ .../new/memory-graph/canvas/version-chain.ts | 63 ++ .../new/memory-graph/canvas/viewport.ts | 172 +++++ .../components/new/memory-graph/constants.ts | 62 ++ .../new/memory-graph/graph-canvas.tsx | 248 +++++++ .../new/memory-graph/graph-card.tsx | 202 ++++++ .../new/memory-graph/hooks/use-graph-api.ts | 273 +++++++ .../new/memory-graph/hooks/use-graph-data.ts | 289 ++++++++ apps/web/components/new/memory-graph/index.ts | 33 + .../components/new/memory-graph/legend.tsx | 599 +++++++++++++++ .../new/memory-graph/loading-indicator.tsx | 32 + .../new/memory-graph/memory-graph.tsx | 515 +++++++++++++ .../new/memory-graph/navigation-controls.tsx | 164 +++++ .../new/memory-graph/node-hover-popover.tsx | 467 ++++++++++++ .../new/memory-graph/node-popover.tsx | 362 ++++++++++ apps/web/components/new/memory-graph/types.ts | 163 +++++ apps/web/components/new/share-modal.tsx | 539 ++++++++++++++ apps/web/components/text-morph.tsx | 1 - apps/web/package.json | 3 + apps/web/public/icons/share-graph.svg | 12 + .../src/components/node-detail-panel.tsx | 2 - packages/ui/components/badge.tsx | 12 +- packages/ui/components/breadcrumb.tsx | 12 +- packages/ui/components/button.tsx | 12 +- packages/ui/components/sidebar.tsx | 147 ++-- 40 files changed, 6100 insertions(+), 396 deletions(-) create mode 100644 apps/web/components/new/document-cards/notion-preview.tsx create mode 100644 apps/web/components/new/graph-layout-view.tsx create mode 100644 apps/web/components/new/memory-graph/api-types.ts create mode 100644 apps/web/components/new/memory-graph/canvas/hit-test.ts create mode 100644 apps/web/components/new/memory-graph/canvas/input-handler.ts create mode 100644 apps/web/components/new/memory-graph/canvas/renderer.ts create mode 100644 apps/web/components/new/memory-graph/canvas/simulation.ts create mode 100644 apps/web/components/new/memory-graph/canvas/version-chain.ts create mode 100644 apps/web/components/new/memory-graph/canvas/viewport.ts create mode 100644 apps/web/components/new/memory-graph/constants.ts create mode 100644 apps/web/components/new/memory-graph/graph-canvas.tsx create mode 100644 apps/web/components/new/memory-graph/graph-card.tsx create mode 100644 apps/web/components/new/memory-graph/hooks/use-graph-api.ts create mode 100644 apps/web/components/new/memory-graph/hooks/use-graph-data.ts create mode 100644 apps/web/components/new/memory-graph/index.ts create mode 100644 apps/web/components/new/memory-graph/legend.tsx create mode 100644 apps/web/components/new/memory-graph/loading-indicator.tsx create mode 100644 apps/web/components/new/memory-graph/memory-graph.tsx create mode 100644 apps/web/components/new/memory-graph/navigation-controls.tsx create mode 100644 apps/web/components/new/memory-graph/node-hover-popover.tsx create mode 100644 apps/web/components/new/memory-graph/node-popover.tsx create mode 100644 apps/web/components/new/memory-graph/types.ts create mode 100644 apps/web/components/new/share-modal.tsx create mode 100644 apps/web/public/icons/share-graph.svg diff --git a/apps/memory-graph-playground/next-env.d.ts b/apps/memory-graph-playground/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/apps/memory-graph-playground/next-env.d.ts +++ b/apps/memory-graph-playground/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/app/new/page.tsx b/apps/web/app/new/page.tsx index 0eb8613c..7115dff6 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,8 @@ 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" +import { cn } from "@lib/utils" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] @@ -32,6 +35,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) @@ -54,16 +58,33 @@ export default function NewPage() { }, }) - // Fetch space highlights (highlights + suggested questions) type SpaceHighlightsResponse = { highlights: HighlightItem[] questions: string[] generatedAt: string } + + const HIGHLIGHTS_CACHE_NAME = "space-highlights-v1" + const HIGHLIGHTS_MAX_AGE = 4 * 60 * 60 * 1000 // 4 hours + const { data: highlightsData, isLoading: isLoadingHighlights } = useQuery({ queryKey: ["space-highlights", selectedProject], queryFn: async (): Promise => { + const spaceId = selectedProject || "sm_project_default" + const cacheKey = `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights?spaceId=${spaceId}` + + // Check Cache API for a fresh response + const cache = await caches.open(HIGHLIGHTS_CACHE_NAME) + const cached = await cache.match(cacheKey) + if (cached) { + const age = + Date.now() - Number(cached.headers.get("x-cached-at") || 0) + if (age < HIGHLIGHTS_MAX_AGE) { + return cached.json() + } + } + const response = await fetch( `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights`, { @@ -71,7 +92,7 @@ export default function NewPage() { headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ - spaceId: selectedProject || "sm_project_default", + spaceId, highlightsCount: 3, questionsCount: 4, includeHighlights: true, @@ -84,9 +105,20 @@ export default function NewPage() { throw new Error("Failed to fetch space highlights") } - return response.json() + const data = await response.json() + + // Store in Cache API with timestamp + const cacheResponse = new Response(JSON.stringify(data), { + headers: { + "Content-Type": "application/json", + "x-cached-at": String(Date.now()), + }, + }) + await cache.put(cacheKey, cacheResponse) + + return data }, - staleTime: 4 * 60 * 60 * 1000, // 4 hours (matches backend cache) + staleTime: HIGHLIGHTS_MAX_AGE, refetchOnWindowFocus: false, }) @@ -172,13 +204,26 @@ export default function NewPage() { setIsSearchOpen(true) }, []) + const isGraphMode = viewMode === "graph" && !isMobile + return ( -
+
+ {isGraphMode && ( +
+ )}
{ analytics.addDocumentModalOpened() @@ -195,36 +240,47 @@ export default function NewPage() { }} />
-
- -
-
- - setQueuedChatSeed(null)} - emptyStateSuggestions={highlightsData?.questions} - /> - +
+ {viewMode === "graph" && !isMobile ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ + setQueuedChatSeed(null)} + emptyStateSuggestions={highlightsData?.questions} + /> + +
diff --git a/apps/web/components/graph-dialog.tsx b/apps/web/components/graph-dialog.tsx index ef291d69..3f75cdee 100644 --- a/apps/web/components/graph-dialog.tsx +++ b/apps/web/components/graph-dialog.tsx @@ -1,12 +1,8 @@ "use client" import { useAuth } from "@lib/auth-context" -import { $fetch } from "@repo/lib/api" -import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" -import { useInfiniteQuery } from "@tanstack/react-query" -import { useCallback, useEffect, useMemo, useState } from "react" -import type { z } from "zod" -import { MemoryGraph } from "@repo/ui/memory-graph" +import { useState } from "react" +import { MemoryGraph } from "@/components/new/memory-graph/memory-graph" import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog" import { ConnectAIModal } from "@/components/connect-ai-modal" import { AddMemoryView } from "@/components/views/add-memory" @@ -14,179 +10,39 @@ import { useChatOpen, useProject, useGraphModal } from "@/stores" import { useGraphHighlights } from "@/stores/highlights" import { useIsMobile } from "@hooks/use-mobile" -type DocumentsResponse = z.infer -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/chat/index.tsx b/apps/web/components/new/chat/index.tsx index 51bbe455..86f51595 100644 --- a/apps/web/components/new/chat/index.tsx +++ b/apps/web/components/new/chat/index.tsx @@ -42,6 +42,7 @@ import { ChainOfThought } from "./input/chain-of-thought" import { useIsMobile } from "@hooks/use-mobile" import { analytics } from "@/lib/analytics" import { generateId } from "@lib/generate-id" +import { useViewMode } from "@/lib/view-mode-context" const DEFAULT_SUGGESTIONS = [ "Show me all content related to Supermemory.", @@ -135,15 +136,17 @@ export function ChatSidebar({ const pendingFollowUpGenerations = useRef>(new Set()) const messagesContainerRef = useRef(null) const { selectedProject } = useProject() + const { viewMode } = useViewMode() const [currentChatId, setCurrentChatId] = useState(() => generateId()) const [pendingThreadLoad, setPendingThreadLoad] = useState<{ id: string messages: UIMessage[] } | null>(null) - // Adjust chat height based on scroll position (desktop only) + // Adjust chat height based on scroll position (desktop only, grid mode only) useEffect(() => { if (isMobile) return + if (viewMode === "graph") return const handleWindowScroll = () => { const scrollThreshold = 80 @@ -157,7 +160,7 @@ export function ChatSidebar({ handleWindowScroll() return () => window.removeEventListener("scroll", handleWindowScroll) - }, [isMobile]) + }, [isMobile, viewMode]) const { messages, sendMessage, status, setMessages, stop } = useChat({ id: currentChatId ?? undefined, diff --git a/apps/web/components/new/document-cards/notion-preview.tsx b/apps/web/components/new/document-cards/notion-preview.tsx new file mode 100644 index 00000000..d3f4425b --- /dev/null +++ b/apps/web/components/new/document-cards/notion-preview.tsx @@ -0,0 +1,217 @@ +"use client" + +import { useMemo } from "react" +import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" +import type { z } from "zod" +import { dmSansClassName } from "@/lib/fonts" +import { cn } from "@lib/utils" +import { NotionDoc, SyncLogoIcon } from "@ui/assets/icons" + +type DocumentsResponse = z.infer +type DocumentWithMemories = DocumentsResponse["documents"][0] + +// Extract structured blocks from markdown content for a visual preview +function parseContentBlocks(content: string) { + const lines = content.split("\n").filter((l) => l.trim()) + const blocks: { + type: "heading" | "text" | "bullet" | "todo" | "divider" + text: string + checked?: boolean + }[] = [] + + for (const line of lines) { + if (blocks.length >= 6) break + const trimmed = line.trim() + + if (trimmed.startsWith("# ")) { + blocks.push({ type: "heading", text: trimmed.replace(/^#+\s*/, "") }) + } else if (trimmed.startsWith("- [x]") || trimmed.startsWith("- [ ]")) { + blocks.push({ + type: "todo", + text: trimmed.replace(/^- \[.\]\s*/, ""), + checked: trimmed.includes("[x]"), + }) + } else if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) { + blocks.push({ type: "bullet", text: trimmed.replace(/^[-*]\s*/, "") }) + } else if (trimmed === "---") { + blocks.push({ type: "divider", text: "" }) + } else if (trimmed.length > 0) { + blocks.push({ + type: "text", + text: trimmed.replace(/\*\*/g, "").replace(/\*/g, "").replace(/`/g, ""), + }) + } + } + + return blocks +} + +export function NotionPreview({ + document, +}: { + document: DocumentWithMemories +}) { + const blocks = useMemo( + () => parseContentBlocks(document.content || document.summary || ""), + [document.content, document.summary], + ) + + return ( +
+ {/* Header with Notion branding */} +
+
+ + + Notion + +
+ {/* Decorative dots mimicking Notion's block handles */} +
+
+
+
+
+
+ + {/* Content area — structured block preview */} +
+ {document.title && ( +

+ {document.title} +

+ )} + + {blocks.length > 0 ? ( +
+ {blocks.map((block, i) => { + if (block.type === "divider") { + return
+ } + + if (block.type === "heading") { + return ( +

+ {block.text} +

+ ) + } + + if (block.type === "todo") { + return ( +
+
+ {block.checked && ( + + + + )} +
+

+ {block.text} +

+
+ ) + } + + if (block.type === "bullet") { + return ( +
+
+

+ {block.text} +

+
+ ) + } + + return ( +

+ {block.text} +

+ ) + })} +
+ ) : document.summary ? ( +

+ {document.summary} +

+ ) : null} +
+ + {/* Footer */} + {(document.memoryEntries.length > 0 || document.createdAt) && ( +
+ {document.memoryEntries.length > 0 && ( +

+ + {document.memoryEntries.length} +

+ )} +

+ {new Date(document.createdAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} +

+
+ )} +
+ ) +} diff --git a/apps/web/components/new/document-modal/graph-list-memories.tsx b/apps/web/components/new/document-modal/graph-list-memories.tsx index 0c2e418f..c61ba0d7 100644 --- a/apps/web/components/new/document-modal/graph-list-memories.tsx +++ b/apps/web/components/new/document-modal/graph-list-memories.tsx @@ -1,6 +1,7 @@ import { useState } from "react" import { cn } from "@lib/utils" -import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs" +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@ui/components/tabs" +import { MemoryGraph } from "../memory-graph" export interface MemoryEntry { id: string @@ -166,8 +167,10 @@ function VersionStatus({ export function GraphListMemories({ memoryEntries, + documentId, }: { memoryEntries: MemoryEntry[] + documentId?: string }) { const [expandedMemories, setExpandedMemories] = useState>( new Set(), @@ -195,7 +198,7 @@ export function GraphListMemories({ backgroundSize: "369px 371px", }} > - + - -
- {memoryEntries.map((memory, idx) => { - const isClickable = - memory.url && - (memory.url.startsWith("http://") || - memory.url.startsWith("https://")) + +
+ +
+
+ +
+ {memoryEntries.map((memory, idx) => { + const isClickable = + memory.url && + (memory.url.startsWith("http://") || + memory.url.startsWith("https://")) - const status = memory.isForgotten - ? "forgotten" - : memory.forgetAfter - ? "expiring" - : memory.isStatic - ? "static" - : "latest" + const status = memory.isForgotten + ? "forgotten" + : memory.forgetAfter + ? "expiring" + : memory.isStatic + ? "static" + : "latest" - const content = ( -
-
- {memory.title && ( -
- {memory.title} -
- )} - {memory.memory && ( - + )} + {memory.url && ( +
+ {memory.url} +
)} - onClick={() => toggleMemory(memory.id)} - > - {memory.memory} - - )} - {memory.url && ( -
- {memory.url}
- )} -
-
-
- v{memory.version} +
+
+ v{memory.version} +
+ +
- -
-
- ) + ) - if (isClickable) { - return ( - - {content} - - ) - } + if (isClickable) { + return ( + + {content} + + ) + } - return ( -
- {content} -
- ) - })} -
+ return ( +
+ {content} +
+ ) + })} +
+ +
) } diff --git a/apps/web/components/new/document-modal/index.tsx b/apps/web/components/new/document-modal/index.tsx index 145a7a13..6a25e194 100644 --- a/apps/web/components/new/document-modal/index.tsx +++ b/apps/web/components/new/document-modal/index.tsx @@ -291,6 +291,7 @@ export function DocumentModal({ {_document?.memoryEntries && _document.memoryEntries.length > 0 && ( )}
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..54638c3e --- /dev/null +++ b/apps/web/components/new/graph-layout-view.tsx @@ -0,0 +1,77 @@ +"use client" + +import { memo, useState, useCallback, useRef } from "react" +import Image from "next/image" +import { MemoryGraph } from "./memory-graph/memory-graph" +import { useProject } from "@/stores" +import { useGraphHighlights } from "@/stores/highlights" +import { Button } from "@ui/components/button" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/lib/fonts" +import { ShareModal } from "./share-modal" + +interface GraphLayoutViewProps { + isChatOpen: boolean +} + +export const GraphLayoutView = memo(({ isChatOpen }) => { + const { selectedProject } = useProject() + const { documentIds: allHighlightDocumentIds } = useGraphHighlights() + const [isShareModalOpen, setIsShareModalOpen] = useState(false) + const canvasRef = useRef(null) + + const containerTags = selectedProject ? [selectedProject] : undefined + + const handleShare = useCallback(() => { + setIsShareModalOpen(true) + }, []) + + const handleCloseShareModal = useCallback(() => { + setIsShareModalOpen(false) + }, []) + + return ( +
+ {/* Full-width graph */} +
+ +
+ + {/* Share graph button - top left */} +
+ +
+ + {/* Share modal */} + +
+ ) +}) + +GraphLayoutView.displayName = "GraphLayoutView" diff --git a/apps/web/components/new/header.tsx b/apps/web/components/new/header.tsx index a52a07fa..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")} + > void onShowRelated: (query: string) => void isLoading?: boolean - width?: number } function renderContent(content: string, format: HighlightFormat) { @@ -68,7 +67,6 @@ export function HighlightsCard({ onChat, onShowRelated, isLoading = false, - width = 216, }: HighlightsCardProps) { const [activeIndex, setActiveIndex] = useState(0) @@ -108,7 +106,6 @@ export function HighlightsCard({ "bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3 min-h-[180px] items-center justify-center", dmSansClassName(), )} - style={{ width }} > @@ -125,7 +122,6 @@ export function HighlightsCard({ "bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3 min-h-[180px]", dmSansClassName(), )} - style={{ width }} >
@@ -155,7 +151,6 @@ export function HighlightsCard({ "bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3", dmSansClassName(), )} - style={{ width }} >
diff --git a/apps/web/components/new/memories-grid.tsx b/apps/web/components/new/memories-grid.tsx index e9dac883..b93aba6d 100644 --- a/apps/web/components/new/memories-grid.tsx +++ b/apps/web/components/new/memories-grid.tsx @@ -22,9 +22,11 @@ import { YoutubePreview } from "./document-cards/youtube-preview" import { getAbsoluteUrl, isYouTubeUrl, useYouTubeChannelName } from "./utils" import { SyncLogoIcon } from "@ui/assets/icons" import { McpPreview } from "./document-cards/mcp-preview" +import { NotionPreview } from "./document-cards/notion-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 @@ -62,11 +64,7 @@ const PAGE_SIZE = IS_DEV ? 100 : 100 const MAX_TOTAL = 1000 // Discriminated union for masonry items -type MasonryItem = - | { type: "quick-note"; id: string } - | { type: "highlights-card"; id: string } - | { type: "highlights-card-spacer"; id: string } - | { type: "document"; id: string; data: DocumentWithMemories } +type MasonryItem = { type: "document"; id: string; data: DocumentWithMemories } interface QuickNoteProps { onSave: (content: string) => void @@ -193,32 +191,18 @@ export function MemoriesGrid({ const masonryItems: MasonryItem[] = useMemo(() => { const items: MasonryItem[] = [] - if (!isMobile) { - if (hasQuickNote) { - items.push({ type: "quick-note", id: "quick-note" }) - } - if (hasHighlights) { - items.push({ type: "highlights-card", id: "highlights-card" }) - // Add spacer to occupy the second column space for the 2-column highlights card - items.push({ - type: "highlights-card-spacer", - id: "highlights-card-spacer", - }) - } - } - for (const doc of documents) { items.push({ type: "document", id: doc.id, data: doc }) } return items - }, [documents, isMobile, hasQuickNote, hasHighlights]) + }, [documents]) // Stable key for Masonry based on document IDs, not item values const masonryKey = useMemo(() => { const docIds = documents.map((d) => d.id).join(",") - return `masonry-${documents.length}-${docIds}-${isChatOpen}-${hasQuickNote}-${hasHighlights}` - }, [documents, isChatOpen, hasQuickNote, hasHighlights]) + return `masonry-${documents.length}-${docIds}-${isChatOpen}` + }, [documents, isChatOpen]) const isLoadingMore = isFetchingNextPage @@ -260,37 +244,6 @@ export function MemoriesGrid({ data: MasonryItem width: number }) => { - if (data.type === "quick-note" && quickNoteProps) { - return ( -
- -
- ) - } - - if (data.type === "highlights-card" && highlightsProps) { - const doubleWidth = width * 2 - const cardWidth = doubleWidth - 16 - return ( -
- -
- ) - } - - if (data.type === "highlights-card-spacer") { - return ( -
- ) - } - if (data.type === "document") { return ( ) : (
+ {!isMobile && (hasQuickNote || hasHighlights) && ( +
+ {hasQuickNote && quickNoteProps && ( +
+ +
+ )} + {hasHighlights && highlightsProps && ( +
+ +
+ )} +
+ +
+
+ )} {!( document.type === "image" || + document.type === "notion_doc" || document.metadata?.mimeType?.toString().startsWith("image/") ) && (
@@ -568,7 +545,7 @@ const DocumentCard = memo(

- {document.memoryEntries.length}{" "} - {document.memoryEntries.length === 1 - ? "memory" - : "memories"} + {document.memoryEntries.length}

)}

} + if (document.type === "notion_doc") { + return + } + if ( document.type === "pdf" || document.type === "image" || 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/canvas/hit-test.ts b/apps/web/components/new/memory-graph/canvas/hit-test.ts new file mode 100644 index 00000000..88a40678 --- /dev/null +++ b/apps/web/components/new/memory-graph/canvas/hit-test.ts @@ -0,0 +1,70 @@ +import type { GraphNode } from "../types" + +export class SpatialIndex { + private grid = new Map() + private cellSize = 200 + private lastHash = 0 + + rebuild(nodes: GraphNode[]): boolean { + const hash = this.computeHash(nodes) + if (hash === this.lastHash) return false + this.lastHash = hash + this.grid.clear() + + for (const node of nodes) { + const key = `${Math.floor(node.x / this.cellSize)},${Math.floor(node.y / this.cellSize)}` + let cell = this.grid.get(key) + if (!cell) { + cell = [] + this.grid.set(key, cell) + } + cell.push(node) + } + return true + } + + queryPoint(worldX: number, worldY: number): GraphNode | null { + const cx = Math.floor(worldX / this.cellSize) + const cy = Math.floor(worldY / this.cellSize) + + // Check current cell + 8 neighbors + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + const cell = this.grid.get(`${cx + dx},${cy + dy}`) + if (!cell) continue + + for (let i = cell.length - 1; i >= 0; i--) { + const node = cell[i]! + if (this.hitTest(node, worldX, worldY)) return node + } + } + } + return null + } + + private hitTest(node: GraphNode, wx: number, wy: number): boolean { + const halfSize = node.size * 0.5 + + if (node.type === "document") { + // AABB rectangle hit test (50x50 node) + return ( + Math.abs(wx - node.x) <= halfSize && Math.abs(wy - node.y) <= halfSize + ) + } + + // Circular hit test for hexagon memory nodes + const dx = wx - node.x + const dy = wy - node.y + return dx * dx + dy * dy <= halfSize * halfSize + } + + private computeHash(nodes: GraphNode[]): number { + let hash = nodes.length + for (const n of nodes) { + // Round to nearest integer to avoid false rebuilds from tiny physics jitter + hash = (hash * 31 + (Math.round(n.x) | 0)) | 0 + hash = (hash * 31 + (Math.round(n.y) | 0)) | 0 + } + return hash + } +} diff --git a/apps/web/components/new/memory-graph/canvas/input-handler.ts b/apps/web/components/new/memory-graph/canvas/input-handler.ts new file mode 100644 index 00000000..a1a6fbf7 --- /dev/null +++ b/apps/web/components/new/memory-graph/canvas/input-handler.ts @@ -0,0 +1,324 @@ +import type { ViewportState } from "./viewport" +import type { SpatialIndex } from "./hit-test" +import type { GraphNode } from "../types" + +interface InputCallbacks { + onNodeHover: (id: string | null) => void + onNodeClick: (id: string | null) => void + onNodeDragStart: (id: string, node: GraphNode) => void + onNodeDragEnd: () => void + onRequestRender: () => void +} + +export class InputHandler { + private canvas: HTMLCanvasElement + private viewport: ViewportState + private spatialIndex: SpatialIndex + private callbacks: InputCallbacks + + private isPanning = false + private lastMouseX = 0 + private lastMouseY = 0 + + // Ring buffer for velocity tracking + private posHistory: Array<{ x: number; y: number; t: number }> = [] + + private draggingNode: GraphNode | null = null + private dragStartX = 0 + private dragStartY = 0 + private didDrag = false + + private currentHoveredId: string | null = null + + // Touch state + private lastTouchDistance = 0 + private lastTouchCenter = { x: 0, y: 0 } + private isTouchGesture = false + + // Bound handlers for cleanup + private boundMouseDown: (e: MouseEvent) => void + private boundMouseMove: (e: MouseEvent) => void + private boundMouseUp: (e: MouseEvent) => void + private boundWheel: (e: WheelEvent) => void + private boundClick: (e: MouseEvent) => void + private boundDblClick: (e: MouseEvent) => void + private boundTouchStart: (e: TouchEvent) => void + private boundTouchMove: (e: TouchEvent) => void + private boundTouchEnd: (e: TouchEvent) => void + private boundGesture: (e: Event) => void + + constructor( + canvas: HTMLCanvasElement, + viewport: ViewportState, + spatialIndex: SpatialIndex, + callbacks: InputCallbacks, + ) { + this.canvas = canvas + this.viewport = viewport + this.spatialIndex = spatialIndex + this.callbacks = callbacks + + this.boundMouseDown = this.onMouseDown.bind(this) + this.boundMouseMove = this.onMouseMove.bind(this) + this.boundMouseUp = this.onMouseUp.bind(this) + this.boundWheel = this.onWheel.bind(this) + this.boundClick = this.onClick.bind(this) + this.boundDblClick = this.onDblClick.bind(this) + this.boundTouchStart = this.onTouchStart.bind(this) + this.boundTouchMove = this.onTouchMove.bind(this) + this.boundTouchEnd = this.onTouchEnd.bind(this) + this.boundGesture = (e: Event) => e.preventDefault() + + canvas.addEventListener("mousedown", this.boundMouseDown) + canvas.addEventListener("mousemove", this.boundMouseMove) + canvas.addEventListener("mouseup", this.boundMouseUp) + canvas.addEventListener("click", this.boundClick) + canvas.addEventListener("dblclick", this.boundDblClick) + canvas.addEventListener("wheel", this.boundWheel, { passive: false }) + canvas.addEventListener("touchstart", this.boundTouchStart, { + passive: false, + }) + canvas.addEventListener("touchmove", this.boundTouchMove, { + passive: false, + }) + canvas.addEventListener("touchend", this.boundTouchEnd) + canvas.addEventListener("gesturestart", this.boundGesture, { + passive: false, + }) + canvas.addEventListener("gesturechange", this.boundGesture, { + passive: false, + }) + canvas.addEventListener("gestureend", this.boundGesture, { passive: false }) + } + + destroy(): void { + const c = this.canvas + c.removeEventListener("mousedown", this.boundMouseDown) + c.removeEventListener("mousemove", this.boundMouseMove) + c.removeEventListener("mouseup", this.boundMouseUp) + c.removeEventListener("click", this.boundClick) + c.removeEventListener("dblclick", this.boundDblClick) + c.removeEventListener("wheel", this.boundWheel) + c.removeEventListener("touchstart", this.boundTouchStart) + c.removeEventListener("touchmove", this.boundTouchMove) + c.removeEventListener("touchend", this.boundTouchEnd) + c.removeEventListener("gesturestart", this.boundGesture) + c.removeEventListener("gesturechange", this.boundGesture) + c.removeEventListener("gestureend", this.boundGesture) + } + + getDraggingNode(): GraphNode | null { + return this.draggingNode + } + + private canvasXY(e: MouseEvent): { x: number; y: number } { + const rect = this.canvas.getBoundingClientRect() + return { x: e.clientX - rect.left, y: e.clientY - rect.top } + } + + private onMouseDown(e: MouseEvent): void { + const { x, y } = this.canvasXY(e) + const world = this.viewport.screenToWorld(x, y) + const node = this.spatialIndex.queryPoint(world.x, world.y) + + this.lastMouseX = x + this.lastMouseY = y + this.posHistory = [{ x, y, t: performance.now() }] + this.didDrag = false + + if (node) { + this.draggingNode = node + this.dragStartX = x + this.dragStartY = y + node.fx = node.x + node.fy = node.y + this.callbacks.onNodeDragStart(node.id, node) + this.canvas.style.cursor = "grabbing" + } else { + this.isPanning = true + this.canvas.style.cursor = "grabbing" + } + } + + private onMouseMove(e: MouseEvent): void { + const { x, y } = this.canvasXY(e) + + if (this.draggingNode) { + const world = this.viewport.screenToWorld(x, y) + this.draggingNode.fx = world.x + this.draggingNode.fy = world.y + this.draggingNode.x = world.x + this.draggingNode.y = world.y + this.didDrag = true + this.callbacks.onRequestRender() + return + } + + if (this.isPanning) { + const dx = x - this.lastMouseX + const dy = y - this.lastMouseY + this.viewport.pan(dx, dy) + this.lastMouseX = x + this.lastMouseY = y + this.didDrag = true + + // Track positions for velocity (keep last 4) + const now = performance.now() + this.posHistory.push({ x, y, t: now }) + if (this.posHistory.length > 4) this.posHistory.shift() + + this.callbacks.onRequestRender() + return + } + + // Hover detection + const world = this.viewport.screenToWorld(x, y) + const node = this.spatialIndex.queryPoint(world.x, world.y) + const id = node?.id ?? null + if (id !== this.currentHoveredId) { + this.currentHoveredId = id + this.callbacks.onNodeHover(id) + this.canvas.style.cursor = id ? "grab" : "default" + this.callbacks.onRequestRender() + } + } + + private onMouseUp(_e: MouseEvent): void { + if (this.draggingNode) { + this.draggingNode.fx = null + this.draggingNode.fy = null + this.draggingNode = null + this.callbacks.onNodeDragEnd() + this.canvas.style.cursor = this.currentHoveredId ? "grab" : "default" + return + } + + if (this.isPanning) { + this.isPanning = false + + // Calculate release velocity from position history + if (this.posHistory.length >= 2) { + const newest = this.posHistory[this.posHistory.length - 1]! + const oldest = this.posHistory[0]! + const dt = newest.t - oldest.t + if (dt > 0 && dt < 200) { + const vx = ((newest.x - oldest.x) / dt) * 16 // scale to ~60fps frame + const vy = ((newest.y - oldest.y) / dt) * 16 + this.viewport.releaseWithVelocity(vx, vy) + } + } + + this.canvas.style.cursor = "default" + this.callbacks.onRequestRender() + } + } + + private onClick(e: MouseEvent): void { + if (this.didDrag) return + + const { x, y } = this.canvasXY(e) + const world = this.viewport.screenToWorld(x, y) + const node = this.spatialIndex.queryPoint(world.x, world.y) + this.callbacks.onNodeClick(node?.id ?? null) + } + + private onDblClick(e: MouseEvent): void { + const { x, y } = this.canvasXY(e) + this.viewport.zoomTo(this.viewport.zoom * 1.5, x, y) + this.callbacks.onRequestRender() + } + + private onWheel(e: WheelEvent): void { + e.preventDefault() + + const { x, y } = this.canvasXY(e) + + // Horizontal scroll -> pan + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + this.viewport.pan(-e.deltaX, 0) + this.callbacks.onRequestRender() + return + } + + // Vertical scroll -> zoom + const factor = e.deltaY > 0 ? 0.97 : 1.03 + this.viewport.zoomImmediate(factor, x, y) + this.callbacks.onRequestRender() + } + + // Touch handling + private onTouchStart(e: TouchEvent): void { + e.preventDefault() + const touches = e.touches + + if (touches.length >= 2) { + this.isTouchGesture = true + const t0 = touches[0]! + const t1 = touches[1]! + this.lastTouchDistance = Math.hypot( + t1.clientX - t0.clientX, + t1.clientY - t0.clientY, + ) + this.lastTouchCenter = { + x: (t0.clientX + t1.clientX) / 2, + y: (t0.clientY + t1.clientY) / 2, + } + } else if (touches.length === 1) { + this.isTouchGesture = false + const t = touches[0]! + const rect = this.canvas.getBoundingClientRect() + this.lastMouseX = t.clientX - rect.left + this.lastMouseY = t.clientY - rect.top + this.isPanning = true + } + } + + private onTouchMove(e: TouchEvent): void { + e.preventDefault() + const touches = e.touches + + if (touches.length >= 2 && this.isTouchGesture) { + const t0 = touches[0]! + const t1 = touches[1]! + const dist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY) + const center = { + x: (t0.clientX + t1.clientX) / 2, + y: (t0.clientY + t1.clientY) / 2, + } + const rect = this.canvas.getBoundingClientRect() + const cx = center.x - rect.left + const cy = center.y - rect.top + + // Pinch zoom + const scale = dist / this.lastTouchDistance + this.viewport.zoomImmediate(scale, cx, cy) + + // Pan from center movement + const dx = center.x - this.lastTouchCenter.x + const dy = center.y - this.lastTouchCenter.y + this.viewport.pan(dx, dy) + + this.lastTouchDistance = dist + this.lastTouchCenter = center + this.callbacks.onRequestRender() + } else if (touches.length === 1 && this.isPanning && !this.isTouchGesture) { + const t = touches[0]! + const rect = this.canvas.getBoundingClientRect() + const x = t.clientX - rect.left + const y = t.clientY - rect.top + this.viewport.pan(x - this.lastMouseX, y - this.lastMouseY) + this.lastMouseX = x + this.lastMouseY = y + this.callbacks.onRequestRender() + } + } + + private onTouchEnd(e: TouchEvent): void { + if (e.touches.length < 2) { + this.isTouchGesture = false + } + if (e.touches.length === 0) { + this.isPanning = false + } + } +} diff --git a/apps/web/components/new/memory-graph/canvas/renderer.ts b/apps/web/components/new/memory-graph/canvas/renderer.ts new file mode 100644 index 00000000..7d9cc3c3 --- /dev/null +++ b/apps/web/components/new/memory-graph/canvas/renderer.ts @@ -0,0 +1,681 @@ +import type { ViewportState } from "./viewport" +import type { GraphNode, GraphEdge, DocumentNodeData } from "../types" + +export interface RenderState { + selectedNodeId: string | null + hoveredNodeId: string | null + highlightIds: Set + dimProgress: number +} + +export function renderFrame( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + edges: GraphEdge[], + viewport: ViewportState, + width: number, + height: number, + state: RenderState, + nodeMap: Map, +): void { + ctx.clearRect(0, 0, width, height) + drawDocDocLines(ctx, nodes, viewport, width, height) + drawEdges(ctx, edges, viewport, width, height, state, nodeMap) + drawNodes(ctx, nodes, viewport, width, height, state) +} + +// Connect each visible doc to its 2 nearest neighbors +function drawDocDocLines( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + viewport: ViewportState, + width: number, + height: number, +): void { + const docs: { x: number; y: number }[] = [] + for (const n of nodes) { + if (n.type !== "document") continue + const s = viewport.worldToScreen(n.x, n.y) + if (s.x > -100 && s.x < width + 100 && s.y > -100 && s.y < height + 100) { + docs.push(s) + } + } + if (docs.length < 2) return + + ctx.strokeStyle = "#8DA3F4" + ctx.lineWidth = 1 + ctx.globalAlpha = 0.3 + ctx.setLineDash([4, 6]) + ctx.beginPath() + + // Deduplicate: only draw line when i < neighbor index + for (let i = 0; i < docs.length; i++) { + const d = docs[i]! + let best1 = -1 + let best2 = -1 + let dist1 = Number.POSITIVE_INFINITY + let dist2 = Number.POSITIVE_INFINITY + + for (let j = 0; j < docs.length; j++) { + if (j === i) continue + const dx = docs[j]!.x - d.x + const dy = docs[j]!.y - d.y + const dist = dx * dx + dy * dy + if (dist < dist1) { + best2 = best1 + dist2 = dist1 + best1 = j + dist1 = dist + } else if (dist < dist2) { + best2 = j + dist2 = dist + } + } + + if (best1 >= 0 && i < best1) { + ctx.moveTo(d.x, d.y) + ctx.lineTo(docs[best1]!.x, docs[best1]!.y) + } + if (best2 >= 0 && i < best2) { + ctx.moveTo(d.x, d.y) + ctx.lineTo(docs[best2]!.x, docs[best2]!.y) + } + } + + ctx.stroke() + ctx.setLineDash([]) + ctx.globalAlpha = 1 +} + +// --- Edges --- + +const EDGE_STYLE: Record = { + "doc-memory": { color: "#4A5568", width: 1.5 }, + version: { color: "#8B5CF6", width: 2 }, +} + +const SIM_STRONG = { color: "#00D4B8", width: 2 } as const +const SIM_MEDIUM = { color: "#6B8FBF", width: 1.5 } as const +const SIM_WEAK = { color: "#4A6A8A", width: 1 } as const + +function edgeStyle(edge: GraphEdge): { color: string; width: number } { + const preset = EDGE_STYLE[edge.edgeType] + if (preset) return preset + if (edge.similarity >= 0.9) return SIM_STRONG + if (edge.similarity >= 0.8) return SIM_MEDIUM + return SIM_WEAK +} + +// Unique key for batching: "color|width" +function batchKey(style: { color: string; width: number }): string { + return `${style.color}|${style.width}` +} + +interface PreparedEdge { + startX: number + startY: number + endX: number + endY: number + connected: boolean + style: { color: string; width: number } + isVersion: boolean + arrowSize: number +} + +function drawEdges( + ctx: CanvasRenderingContext2D, + edges: GraphEdge[], + viewport: ViewportState, + width: number, + height: number, + state: RenderState, + nodeMap: Map, +): void { + const margin = 100 + const hasDim = state.selectedNodeId !== null && state.dimProgress > 0 + + // Prepare all visible edges + const prepared: PreparedEdge[] = [] + + for (const edge of edges) { + const src = + typeof edge.source === "string" ? nodeMap.get(edge.source) : edge.source + const tgt = + typeof edge.target === "string" ? nodeMap.get(edge.target) : edge.target + if (!src || !tgt) continue + + // Skip doc-memory edges when memory dots are too small to see connections + if (edge.edgeType === "doc-memory") { + const mem = src.type === "memory" ? src : tgt + if (mem.size * viewport.zoom < 3) continue + } + + const s = viewport.worldToScreen(src.x, src.y) + const t = viewport.worldToScreen(tgt.x, tgt.y) + + if ( + (s.x < -margin && t.x < -margin) || + (s.x > width + margin && t.x > width + margin) || + (s.y < -margin && t.y < -margin) || + (s.y > height + margin && t.y > height + margin) + ) + continue + + const dx = t.x - s.x + const dy = t.y - s.y + const dist = Math.sqrt(dx * dx + dy * dy) + if (dist < 1) continue + + const ux = dx / dist + const uy = dy / dist + const sr = src.size * viewport.zoom * 0.5 + const tr = tgt.size * viewport.zoom * 0.5 + + let connected = true + if (hasDim) { + const srcId = + typeof edge.source === "string" ? edge.source : edge.source.id + const tgtId = + typeof edge.target === "string" ? edge.target : edge.target.id + connected = + srcId === state.selectedNodeId || tgtId === state.selectedNodeId + } + + prepared.push({ + startX: s.x + ux * sr, + startY: s.y + uy * sr, + endX: t.x - ux * tr, + endY: t.y - uy * tr, + connected, + style: edgeStyle(edge), + isVersion: edge.edgeType === "version", + arrowSize: + edge.edgeType === "version" ? Math.max(6, 8 * viewport.zoom) : 0, + }) + } + + // Batch by style + dim state: group into "key|connected" and "key|dimmed" + const batches = new Map() + for (const e of prepared) { + const dimKey = hasDim ? (e.connected ? "|c" : "|d") : "" + const key = batchKey(e.style) + dimKey + let batch = batches.get(key) + if (!batch) { + batch = [] + batches.set(key, batch) + } + batch.push(e) + } + + // Draw each batch in a single beginPath/stroke + ctx.setLineDash([]) + for (const [key, batch] of batches) { + const first = batch[0]! + const isDimmed = key.endsWith("|d") + + ctx.globalAlpha = isDimmed ? 1 - state.dimProgress * 0.8 : 1 + ctx.strokeStyle = first.style.color + ctx.lineWidth = first.style.width + + ctx.beginPath() + for (const e of batch) { + ctx.moveTo(e.startX, e.startY) + ctx.lineTo(e.endX, e.endY) + } + ctx.stroke() + + // Arrow heads for version edges (fill calls — unavoidable per-arrow) + const versionEdges = batch.filter((e) => e.isVersion) + if (versionEdges.length > 0) { + ctx.fillStyle = first.style.color + for (const e of versionEdges) { + drawArrowHead(ctx, e.startX, e.startY, e.endX, e.endY, e.arrowSize) + } + } + } + + ctx.globalAlpha = 1 +} + +function drawArrowHead( + ctx: CanvasRenderingContext2D, + fromX: number, + fromY: number, + toX: number, + toY: number, + size: number, +): void { + const angle = Math.atan2(toY - fromY, toX - fromX) + ctx.beginPath() + ctx.moveTo(toX, toY) + ctx.lineTo( + toX - size * Math.cos(angle - Math.PI / 6), + toY - size * Math.sin(angle - Math.PI / 6), + ) + ctx.lineTo( + toX - size * Math.cos(angle + Math.PI / 6), + toY - size * Math.sin(angle + Math.PI / 6), + ) + ctx.closePath() + ctx.fill() +} + +// --- Nodes --- + +function drawNodes( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + viewport: ViewportState, + width: number, + height: number, + state: RenderState, +): void { + const margin = 60 + const memDots: { x: number; y: number; r: number; color: string }[] = [] + const docDots: { x: number; y: number; s: number }[] = [] + + for (const node of nodes) { + const screen = viewport.worldToScreen(node.x, node.y) + const screenSize = node.size * viewport.zoom + + // Frustum cull (use at least 2px so tiny nodes aren't culled) + const cullSize = Math.max(screenSize, 2) + if ( + screen.x + cullSize < -margin || + screen.x - cullSize > width + margin || + screen.y + cullSize < -margin || + screen.y - cullSize > height + margin + ) + continue + + const isSelected = node.id === state.selectedNodeId + const isHovered = node.id === state.hoveredNodeId + const isHighlighted = state.highlightIds.has(node.id) + + // LOD: tiny nodes → batched dots (but selected/highlighted always get full detail) + if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) { + if (node.type === "document") { + docDots.push({ x: screen.x, y: screen.y, s: Math.max(3, screenSize) }) + } else { + memDots.push({ + x: screen.x, + y: screen.y, + r: Math.max(2, screenSize * 0.45), + color: node.borderColor || "#3B73B8", + }) + } + continue + } + + let alpha = 1 + if (state.selectedNodeId && state.dimProgress > 0 && !isSelected) { + alpha = 1 - state.dimProgress * 0.7 + } + ctx.globalAlpha = alpha + + if (node.type === "document") { + drawDocumentNode( + ctx, + screen.x, + screen.y, + screenSize, + node, + isSelected, + isHovered, + isHighlighted, + ) + } else { + drawMemoryNode( + ctx, + screen.x, + screen.y, + screenSize, + node, + isSelected, + isHovered, + isHighlighted, + ) + } + + if (isSelected || isHighlighted) { + drawGlow(ctx, screen.x, screen.y, screenSize, node.type) + } + } + + const dimAlpha = + state.selectedNodeId && state.dimProgress > 0 + ? 1 - state.dimProgress * 0.7 + : 1 + + // Batch: document dots as filled squares + if (docDots.length > 0) { + ctx.fillStyle = "#1B1F24" + ctx.strokeStyle = "#2A2F36" + ctx.lineWidth = 1 + ctx.globalAlpha = dimAlpha + for (const d of docDots) { + const h = d.s * 0.5 + ctx.fillRect(d.x - h, d.y - h, d.s, d.s) + ctx.strokeRect(d.x - h, d.y - h, d.s, d.s) + } + } + + // Batch: memory dots — dark fill, then colored border strokes + if (memDots.length > 0) { + ctx.globalAlpha = dimAlpha + + // Pass 1: all dark fills in one batch + ctx.fillStyle = "#0D2034" + ctx.beginPath() + for (const d of memDots) { + ctx.moveTo(d.x + d.r, d.y) + ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) + } + ctx.fill() + + // Pass 2: colored strokes grouped by border color + ctx.lineWidth = 1.5 + const byColor = new Map() + for (const d of memDots) { + let batch = byColor.get(d.color) + if (!batch) { + batch = [] + byColor.set(d.color, batch) + } + batch.push(d) + } + for (const [color, batch] of byColor) { + ctx.strokeStyle = color + ctx.beginPath() + for (const d of batch) { + ctx.moveTo(d.x + d.r, d.y) + ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2) + } + ctx.stroke() + } + } + + ctx.globalAlpha = 1 +} + +function drawDocumentNode( + ctx: CanvasRenderingContext2D, + sx: number, + sy: number, + size: number, + node: GraphNode, + isSelected: boolean, + isHovered: boolean, + isHighlighted: boolean, +): void { + const half = size * 0.5 + const cornerR = 8 * (size / 50) + + // Outer rect + ctx.fillStyle = "#1B1F24" + ctx.strokeStyle = + isSelected || isHighlighted ? "#3B73B8" : isHovered ? "#3B73B8" : "#2A2F36" + ctx.lineWidth = isSelected || isHighlighted ? 2 : 1 + roundRect(ctx, sx - half, sy - half, size, size, cornerR) + ctx.fill() + ctx.stroke() + + // Inner rect + const innerSize = size * 0.72 + const innerHalf = innerSize * 0.5 + const innerR = 6 * (size / 50) + ctx.fillStyle = "#13161A" + roundRect(ctx, sx - innerHalf, sy - innerHalf, innerSize, innerSize, innerR) + ctx.fill() + + // Icon + const iconSize = size * 0.35 + const docType = + node.type === "document" ? (node.data as DocumentNodeData).type : "text" + drawDocIcon(ctx, sx, sy, iconSize, docType || "text") +} + +function drawMemoryNode( + ctx: CanvasRenderingContext2D, + sx: number, + sy: number, + size: number, + node: GraphNode, + isSelected: boolean, + isHovered: boolean, + _isHighlighted: boolean, +): void { + const radius = size * 0.5 + + // Fill + ctx.fillStyle = isHovered ? "#112840" : "#0D2034" + drawHexagon(ctx, sx, sy, radius) + ctx.fill() + + // Stroke with time-based border color + const borderColor = node.borderColor || "#3B73B8" + ctx.strokeStyle = isSelected ? "#3B73B8" : borderColor + ctx.lineWidth = isHovered ? 2 : 1.5 + ctx.stroke() +} + +function drawGlow( + ctx: CanvasRenderingContext2D, + sx: number, + sy: number, + size: number, + nodeType: "document" | "memory", +): void { + ctx.strokeStyle = "#3B73B8" + ctx.lineWidth = 2 + ctx.setLineDash([3, 3]) + ctx.globalAlpha = 0.8 + + if (nodeType === "document") { + const glowSize = size * 1.15 + const half = glowSize * 0.5 + const r = 8 * (glowSize / 50) + roundRect(ctx, sx - half, sy - half, glowSize, glowSize, r) + } else { + drawHexagon(ctx, sx, sy, size * 0.5 * 1.15) + } + + ctx.stroke() + ctx.setLineDash([]) + ctx.globalAlpha = 1 +} + +// --- Shapes --- + +function drawHexagon( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + radius: number, +): void { + ctx.beginPath() + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 6 + const x = cx + radius * Math.cos(angle) + const y = cy + radius * Math.sin(angle) + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y) + } + ctx.closePath() +} + +function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number, +): void { + ctx.beginPath() + ctx.moveTo(x + r, y) + ctx.lineTo(x + w - r, y) + ctx.arcTo(x + w, y, x + w, y + r, r) + ctx.lineTo(x + w, y + h - r) + ctx.arcTo(x + w, y + h, x + w - r, y + h, r) + ctx.lineTo(x + r, y + h) + ctx.arcTo(x, y + h, x, y + h - r, r) + ctx.lineTo(x, y + r) + ctx.arcTo(x, y, x + r, y, r) + ctx.closePath() +} + +// --- Document icons --- + +const ICON_COLOR = "#3B73B8" + +function drawDocIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + type: string, +): void { + ctx.save() + ctx.fillStyle = ICON_COLOR + ctx.strokeStyle = ICON_COLOR + ctx.lineWidth = Math.max(1, size / 12) + ctx.lineCap = "round" + ctx.lineJoin = "round" + + switch (type) { + case "webpage": + case "url": + drawGlobeIcon(ctx, x, y, size) + break + case "pdf": + drawTextLabel(ctx, x, y, size, "PDF", 0.35) + break + case "md": + case "markdown": + drawTextLabel(ctx, x, y, size, "MD", 0.3) + break + case "doc": + case "docx": + drawTextLabel(ctx, x, y, size, "DOC", 0.28) + break + case "csv": + drawGridIcon(ctx, x, y, size) + break + case "json": + drawBracesIcon(ctx, x, y, size) + break + case "notion": + case "notion_doc": + drawTextLabel(ctx, x, y, size, "N", 0.4) + break + case "google_doc": + case "google_sheet": + case "google_slide": + drawTextLabel(ctx, x, y, size, "G", 0.4) + break + default: + drawDocOutline(ctx, x, y, size) + break + } + + ctx.restore() +} + +function drawTextLabel( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + text: string, + fontRatio: number, +): void { + ctx.font = `bold ${size * fontRatio}px sans-serif` + ctx.textAlign = "center" + ctx.textBaseline = "middle" + ctx.fillText(text, x, y) +} + +function drawGlobeIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const r = size * 0.4 + ctx.beginPath() + ctx.arc(x, y, r, 0, Math.PI * 2) + ctx.stroke() + ctx.beginPath() + ctx.ellipse(x, y, r * 0.4, r, 0, 0, Math.PI * 2) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(x - r, y) + ctx.lineTo(x + r, y) + ctx.stroke() +} + +function drawGridIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.7 + const h = size * 0.7 + ctx.strokeRect(x - w / 2, y - h / 2, w, h) + ctx.beginPath() + ctx.moveTo(x, y - h / 2) + ctx.lineTo(x, y + h / 2) + ctx.moveTo(x - w / 2, y) + ctx.lineTo(x + w / 2, y) + ctx.stroke() +} + +function drawBracesIcon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.6 + const h = size * 0.8 + ctx.beginPath() + ctx.moveTo(x - w / 4, y - h / 2) + ctx.quadraticCurveTo(x - w / 2, y - h / 3, x - w / 2, y) + ctx.quadraticCurveTo(x - w / 2, y + h / 3, x - w / 4, y + h / 2) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(x + w / 4, y - h / 2) + ctx.quadraticCurveTo(x + w / 2, y - h / 3, x + w / 2, y) + ctx.quadraticCurveTo(x + w / 2, y + h / 3, x + w / 4, y + h / 2) + ctx.stroke() +} + +function drawDocOutline( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, +): void { + const w = size * 0.7 + const h = size * 0.85 + const fold = size * 0.2 + ctx.beginPath() + ctx.moveTo(x - w / 2, y - h / 2) + ctx.lineTo(x + w / 2 - fold, y - h / 2) + ctx.lineTo(x + w / 2, y - h / 2 + fold) + ctx.lineTo(x + w / 2, y + h / 2) + ctx.lineTo(x - w / 2, y + h / 2) + ctx.closePath() + ctx.stroke() + const sp = size * 0.15 + const lw = size * 0.4 + ctx.beginPath() + ctx.moveTo(x - lw / 2, y - sp) + ctx.lineTo(x + lw / 2, y - sp) + ctx.moveTo(x - lw / 2, y) + ctx.lineTo(x + lw / 2, y) + ctx.moveTo(x - lw / 2, y + sp) + ctx.lineTo(x + lw / 2, y + sp) + ctx.stroke() +} diff --git a/apps/web/components/new/memory-graph/canvas/simulation.ts b/apps/web/components/new/memory-graph/canvas/simulation.ts new file mode 100644 index 00000000..f0a4088b --- /dev/null +++ b/apps/web/components/new/memory-graph/canvas/simulation.ts @@ -0,0 +1,79 @@ +import * as d3 from "d3-force" +import type { GraphNode, GraphEdge } from "../types" + +export class ForceSimulation { + private sim: d3.Simulation | null = null + + init(nodes: GraphNode[], edges: GraphEdge[]): void { + this.destroy() + + try { + this.sim = d3 + .forceSimulation(nodes) + .alphaDecay(0.03) + .alphaMin(0.001) + .velocityDecay(0.6) + + this.sim.force( + "link", + d3 + .forceLink(edges) + .id((d) => d.id) + .distance((link) => (link.edgeType === "doc-memory" ? 150 : 300)) + .strength((link) => { + if (link.edgeType === "doc-memory") return 0.8 + if (link.edgeType === "version") return 1.0 + return link.similarity * 0.3 + }), + ) + + this.sim.force("charge", d3.forceManyBody().strength(-1000)) + + this.sim.force( + "collide", + d3 + .forceCollide() + .radius((d) => (d.type === "document" ? 80 : 40)) + .strength(0.7), + ) + + this.sim.force("x", d3.forceX().strength(0.05)) + this.sim.force("y", d3.forceY().strength(0.05)) + + // Pre-settle synchronously, then start the live simulation + this.sim.stop() + this.sim.alpha(1) + for (let i = 0; i < 50; i++) this.sim.tick() + this.sim.alphaTarget(0).restart() + } catch (e) { + console.error("ForceSimulation.init failed:", e) + this.destroy() + } + } + + update(nodes: GraphNode[], edges: GraphEdge[]): void { + if (!this.sim) return + this.sim.nodes(nodes) + const linkForce = this.sim.force>("link") + if (linkForce) linkForce.links(edges) + } + + reheat(): void { + this.sim?.alphaTarget(0.3).restart() + } + + coolDown(): void { + this.sim?.alphaTarget(0) + } + + isActive(): boolean { + return (this.sim?.alpha() ?? 0) > 0.001 + } + + destroy(): void { + if (this.sim) { + this.sim.stop() + this.sim = null + } + } +} diff --git a/apps/web/components/new/memory-graph/canvas/version-chain.ts b/apps/web/components/new/memory-graph/canvas/version-chain.ts new file mode 100644 index 00000000..ec151701 --- /dev/null +++ b/apps/web/components/new/memory-graph/canvas/version-chain.ts @@ -0,0 +1,63 @@ +import type { GraphApiDocument, GraphApiMemory } from "../types" + +export interface ChainEntry { + id: string + version: number + memory: string + isForgotten: boolean + isLatest: boolean +} + +export class VersionChainIndex { + private memoryMap = new Map() + private cache = new Map() + private lastDocs: GraphApiDocument[] | null = null + + rebuild(documents: GraphApiDocument[]): void { + if (documents === this.lastDocs) return + this.lastDocs = documents + this.memoryMap.clear() + this.cache.clear() + + for (const doc of documents) { + for (const m of doc.memories) { + this.memoryMap.set(m.id, m) + } + } + } + + getChain(memoryId: string): ChainEntry[] | null { + const cached = this.cache.get(memoryId) + if (cached) return cached + + const mem = this.memoryMap.get(memoryId) + if (!mem || mem.version <= 1) return null + + // Walk parentMemoryId up to the root + const chain: ChainEntry[] = [] + const visited = new Set() + let current: GraphApiMemory | undefined = mem + while (current && !visited.has(current.id)) { + visited.add(current.id) + chain.push({ + id: current.id, + version: current.version, + memory: current.memory, + isForgotten: current.isForgotten, + isLatest: current.isLatest, + }) + current = current.parentMemoryId + ? this.memoryMap.get(current.parentMemoryId) + : undefined + } + + chain.reverse() + + // Cache for every member in the chain + for (const entry of chain) { + this.cache.set(entry.id, chain) + } + + return chain + } +} diff --git a/apps/web/components/new/memory-graph/canvas/viewport.ts b/apps/web/components/new/memory-graph/canvas/viewport.ts new file mode 100644 index 00000000..ec2715a6 --- /dev/null +++ b/apps/web/components/new/memory-graph/canvas/viewport.ts @@ -0,0 +1,172 @@ +export class ViewportState { + panX: number + panY: number + zoom: number + + private velocityX = 0 + private velocityY = 0 + private readonly friction = 0.92 + + private targetZoom: number + private readonly zoomSpring = 0.15 + private zoomAnchorX = 0 + private zoomAnchorY = 0 + + private targetPanX: number | null = null + private targetPanY: number | null = null + private readonly panLerp = 0.12 + + private static readonly MIN_ZOOM = 0.1 + private static readonly MAX_ZOOM = 5.0 + + constructor(initialPanX = 0, initialPanY = 0, initialZoom = 0.5) { + this.panX = initialPanX + this.panY = initialPanY + this.zoom = initialZoom + this.targetZoom = initialZoom + } + + worldToScreen(wx: number, wy: number): { x: number; y: number } { + return { + x: wx * this.zoom + this.panX, + y: wy * this.zoom + this.panY, + } + } + + screenToWorld(sx: number, sy: number): { x: number; y: number } { + return { + x: (sx - this.panX) / this.zoom, + y: (sy - this.panY) / this.zoom, + } + } + + pan(dx: number, dy: number): void { + this.panX += dx + this.panY += dy + // Cancel any target pan animation when user drags + this.targetPanX = null + this.targetPanY = null + } + + releaseWithVelocity(vx: number, vy: number): void { + this.velocityX = vx + this.velocityY = vy + } + + zoomImmediate(delta: number, anchorX: number, anchorY: number): void { + const world = this.screenToWorld(anchorX, anchorY) + this.zoom = clamp( + this.zoom * delta, + ViewportState.MIN_ZOOM, + ViewportState.MAX_ZOOM, + ) + this.targetZoom = this.zoom + this.panX = anchorX - world.x * this.zoom + this.panY = anchorY - world.y * this.zoom + } + + zoomTo(target: number, anchorX: number, anchorY: number): void { + this.targetZoom = clamp( + target, + ViewportState.MIN_ZOOM, + ViewportState.MAX_ZOOM, + ) + this.zoomAnchorX = anchorX + this.zoomAnchorY = anchorY + } + + fitToNodes( + nodes: Array<{ x: number; y: number; size: number }>, + width: number, + height: number, + ): void { + if (nodes.length === 0) return + + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const n of nodes) { + minX = Math.min(minX, n.x - n.size) + maxX = Math.max(maxX, n.x + n.size) + minY = Math.min(minY, n.y - n.size) + maxY = Math.max(maxY, n.y + n.size) + } + + const pad = 0.1 + const cw = (maxX - minX) * (1 + pad * 2) + const ch = (maxY - minY) * (1 + pad * 2) + const cx = (minX + maxX) / 2 + const cy = (minY + maxY) / 2 + + const fitZoom = Math.min(width / cw, height / ch, 1) + this.targetZoom = clamp( + fitZoom, + ViewportState.MIN_ZOOM, + ViewportState.MAX_ZOOM, + ) + this.zoomAnchorX = width / 2 + this.zoomAnchorY = height / 2 + this.targetPanX = width / 2 - cx * this.targetZoom + this.targetPanY = height / 2 - cy * this.targetZoom + } + + centerOn( + worldX: number, + worldY: number, + width: number, + height: number, + ): void { + this.targetPanX = width / 2 - worldX * this.zoom + this.targetPanY = height / 2 - worldY * this.zoom + } + + tick(): boolean { + let moving = false + + // Momentum panning + if (Math.abs(this.velocityX) > 0.5 || Math.abs(this.velocityY) > 0.5) { + this.panX += this.velocityX + this.panY += this.velocityY + this.velocityX *= this.friction + this.velocityY *= this.friction + moving = true + } else { + this.velocityX = 0 + this.velocityY = 0 + } + + // Spring zoom + const zoomDiff = this.targetZoom - this.zoom + if (Math.abs(zoomDiff) > 0.001) { + const world = this.screenToWorld(this.zoomAnchorX, this.zoomAnchorY) + this.zoom += zoomDiff * this.zoomSpring + this.panX = this.zoomAnchorX - world.x * this.zoom + this.panY = this.zoomAnchorY - world.y * this.zoom + moving = true + } + + // Lerp pan animation + if (this.targetPanX !== null && this.targetPanY !== null) { + const dx = this.targetPanX - this.panX + const dy = this.targetPanY - this.panY + if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) { + this.panX += dx * this.panLerp + this.panY += dy * this.panLerp + moving = true + } else { + this.panX = this.targetPanX + this.panY = this.targetPanY + this.targetPanX = null + this.targetPanY = null + } + } + + return moving + } +} + +function clamp(v: number, min: number, max: number): number { + return v < min ? min : v > max ? max : v +} 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..b879bf1d --- /dev/null +++ b/apps/web/components/new/memory-graph/constants.ts @@ -0,0 +1,62 @@ +export const colors = { + background: { + primary: "#0f1419", + secondary: "#1a1f29", + accent: "#252a35", + }, + hexagon: { + active: { fill: "#0D2034", stroke: "#3B73B8", strokeWidth: 1.68 }, + inactive: { fill: "#0B1826", stroke: "#3D4857", strokeWidth: 1.4 }, + hovered: { fill: "#112840", stroke: "#4A8AD0", strokeWidth: 2 }, + }, + document: { + outer: { fill: "#1B1F24", stroke: "#2A2F36", radius: 8 }, + inner: { fill: "#13161A", radius: 6 }, + iconColor: "#3B73B8", + }, + text: { + primary: "#ffffff", + secondary: "#e2e8f0", + muted: "#94a3b8", + }, +} + +export const MEMORY_BORDER = { + forgotten: "#EF4444", + expiring: "#F59E0B", + recent: "#10B981", + default: "#3B73B8", +} as const + +export const EDGE_COLORS = { + docMemory: "#4A5568", + similarityStrong: "#00D4B8", + similarityMedium: "#6B8FBF", + similarityWeak: "#4A6A8A", + version: "#8B5CF6", +} as const + +export const FORCE_CONFIG = { + linkStrength: { + docMemory: 0.8, + version: 1.0, + docDocBase: 0.3, + }, + linkDistance: 300, + docMemoryDistance: 150, + chargeStrength: -1000, + collisionRadius: { document: 80, memory: 40 }, + alphaDecay: 0.03, + alphaMin: 0.001, + velocityDecay: 0.6, + alphaTarget: 0.3, +} + +export const GRAPH_SETTINGS = { + console: { initialZoom: 0.8, initialPanX: 0, initialPanY: 0 }, + consumer: { initialZoom: 0.5, initialPanX: 400, initialPanY: 300 }, +} + +export const ANIMATION = { + dimDuration: 1500, +} diff --git a/apps/web/components/new/memory-graph/graph-canvas.tsx b/apps/web/components/new/memory-graph/graph-canvas.tsx new file mode 100644 index 00000000..06b7bc4c --- /dev/null +++ b/apps/web/components/new/memory-graph/graph-canvas.tsx @@ -0,0 +1,248 @@ +"use client" + +import { memo, useEffect, useLayoutEffect, useRef } from "react" +import type { GraphCanvasProps, GraphNode } from "./types" +import { ViewportState } from "./canvas/viewport" +import { SpatialIndex } from "./canvas/hit-test" +import { InputHandler } from "./canvas/input-handler" +import { renderFrame } from "./canvas/renderer" +import { GRAPH_SETTINGS } from "./constants" + +export const GraphCanvas = memo(function GraphCanvas({ + nodes, + edges, + width, + height, + highlightDocumentIds, + selectedNodeId = null, + onNodeHover, + onNodeClick, + onNodeDragStart, + onNodeDragEnd, + onViewportChange, + canvasRef: externalCanvasRef, + variant = "console", + simulation, + viewportRef: externalViewportRef, +}) { + const internalCanvasRef = useRef(null) + const canvasRef = externalCanvasRef || internalCanvasRef + + // Engine instances — mutable, never trigger re-renders + const viewportRef = useRef(null) + const spatialRef = useRef(new SpatialIndex()) + const inputRef = useRef(null) + const rafRef = useRef(0) + const renderNeeded = useRef(true) + const nodeMapRef = useRef(new Map()) + + // All mutable render state in a single ref — the rAF loop reads from here + const s = useRef({ + nodes, + edges, + width, + height, + selectedNodeId, + hoveredNodeId: null as string | null, + highlightIds: new Set(highlightDocumentIds ?? []), + dimProgress: 0, + dimTarget: selectedNodeId ? 1 : 0, + }) + + // Sync incoming props to mutable state (no re-renders) + s.current.nodes = nodes + s.current.edges = edges + s.current.width = width + s.current.height = height + + // Stable callback refs so InputHandler never needs recreation + const cb = useRef({ + onNodeHover, + onNodeClick, + onNodeDragStart, + onNodeDragEnd, + onViewportChange, + simulation, + }) + cb.current = { + onNodeHover, + onNodeClick, + onNodeDragStart, + onNodeDragEnd, + onViewportChange, + simulation, + } + + // Rebuild nodeMap + spatial index when nodes change + useEffect(() => { + const map = nodeMapRef.current + map.clear() + for (const n of nodes) map.set(n.id, n) + spatialRef.current.rebuild(nodes) + renderNeeded.current = true + }, [nodes]) + + useEffect(() => { + s.current.highlightIds = new Set(highlightDocumentIds ?? []) + renderNeeded.current = true + }, [highlightDocumentIds]) + + useEffect(() => { + s.current.selectedNodeId = selectedNodeId + s.current.dimTarget = selectedNodeId ? 1 : 0 + renderNeeded.current = true + }, [selectedNodeId]) + + // Create viewport + input handler (once per variant) + useLayoutEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const cfg = GRAPH_SETTINGS[variant] + const vp = new ViewportState( + cfg.initialPanX, + cfg.initialPanY, + cfg.initialZoom, + ) + viewportRef.current = vp + if (externalViewportRef) { + ;( + externalViewportRef as React.MutableRefObject + ).current = vp + } + + const handler = new InputHandler(canvas, vp, spatialRef.current, { + onNodeHover: (id) => { + s.current.hoveredNodeId = id + cb.current.onNodeHover(id) + renderNeeded.current = true + }, + onNodeClick: (id) => cb.current.onNodeClick(id), + onNodeDragStart: (id, node) => { + cb.current.onNodeDragStart(id) + cb.current.simulation?.reheat() + }, + onNodeDragEnd: () => { + cb.current.onNodeDragEnd() + cb.current.simulation?.coolDown() + }, + onRequestRender: () => { + renderNeeded.current = true + }, + }) + inputRef.current = handler + + return () => handler.destroy() + }, [variant]) + + // High-DPI canvas sizing + const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1 + + useLayoutEffect(() => { + const canvas = canvasRef.current + if (!canvas || width === 0 || height === 0) return + + const MAX = 16384 + const d = Math.min(MAX / width, MAX / height, dpr) + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + canvas.width = Math.min(width * d, MAX) + canvas.height = Math.min(height * d, MAX) + + const ctx = canvas.getContext("2d") + if (ctx) { + ctx.scale(d, d) + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = "high" + } + renderNeeded.current = true + }, [width, height, dpr]) + + // Single render loop — runs for component lifetime, reads everything from refs + useEffect(() => { + let lastReportedZoom = 0 + + const tick = () => { + rafRef.current = requestAnimationFrame(tick) + + const vp = viewportRef.current + const canvas = canvasRef.current + if (!vp || !canvas) return + + const ctx = canvas.getContext("2d") + if (!ctx) return + + const cur = s.current + + // 1. Viewport momentum / spring zoom / lerp pan + const vpMoving = vp.tick() + + // 2. Dim animation (ease toward target) + const dd = cur.dimTarget - cur.dimProgress + let dimming = false + if (Math.abs(dd) > 0.01) { + cur.dimProgress += dd * 0.1 + dimming = true + } else { + cur.dimProgress = cur.dimTarget + } + + // 3. Simulation physics + const simActive = cb.current.simulation?.isActive() ?? false + + // 4. Spatial index rebuild (only when positions actually move) + const spatialChanged = + simActive || inputRef.current?.getDraggingNode() + ? spatialRef.current.rebuild(cur.nodes) + : false + + // Skip frame if nothing changed + if ( + !vpMoving && + !simActive && + !dimming && + !spatialChanged && + !renderNeeded.current + ) + return + renderNeeded.current = false + + // Throttled zoom reporting for NavigationControls + if ( + vpMoving && + cb.current.onViewportChange && + Math.abs(vp.zoom - lastReportedZoom) > 0.005 + ) { + lastReportedZoom = vp.zoom + cb.current.onViewportChange(vp.zoom) + } + + renderFrame( + ctx, + cur.nodes, + cur.edges, + vp, + cur.width, + cur.height, + { + selectedNodeId: cur.selectedNodeId, + hoveredNodeId: cur.hoveredNodeId, + highlightIds: cur.highlightIds, + dimProgress: cur.dimProgress, + }, + nodeMapRef.current, + ) + } + + rafRef.current = requestAnimationFrame(tick) + return () => cancelAnimationFrame(rafRef.current) + }, []) + + return ( + + ) +}) diff --git a/apps/web/components/new/memory-graph/graph-card.tsx b/apps/web/components/new/memory-graph/graph-card.tsx new file mode 100644 index 00000000..841039ef --- /dev/null +++ b/apps/web/components/new/memory-graph/graph-card.tsx @@ -0,0 +1,202 @@ +"use client" + +import { memo, useMemo } from "react" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/lib/fonts" +import { Expand } from "lucide-react" +import { useGraphApi } from "./hooks/use-graph-api" +import { useViewMode } from "@/lib/view-mode-context" + +export interface GraphCardProps { + containerTags?: string[] + width?: number + height?: number + className?: string +} + +// Simple seeded random for deterministic node positions +function seededRandom(seed: number) { + let s = seed + return () => { + s = (s * 16807 + 0) % 2147483647 + return s / 2147483647 + } +} + +function StaticGraphPreview({ + documentCount, + memoryCount, + width, + height, +}: { + documentCount: number + memoryCount: number + width: number + height: number +}) { + const nodes = useMemo(() => { + const rand = seededRandom(42) + const count = Math.min(documentCount + memoryCount, 30) + const docCount = Math.min(documentCount, 12) + const result: { + x: number + y: number + r: number + color: string + opacity: number + }[] = [] + + const pad = 20 + for (let i = 0; i < count; i++) { + const isDoc = i < docCount + result.push({ + x: pad + rand() * (width - pad * 2), + y: pad + rand() * (height - pad * 2), + r: isDoc ? 4 + rand() * 3 : 2 + rand() * 2, + color: isDoc ? "#4BA0FA" : "#36FDFD", + opacity: 0.4 + rand() * 0.4, + }) + } + return result + }, [documentCount, memoryCount, width, height]) + + const edges = useMemo(() => { + if (nodes.length < 2) return [] + const rand = seededRandom(123) + const result: { x1: number; y1: number; x2: number; y2: number }[] = [] + const edgeCount = Math.min(nodes.length - 1, 20) + for (let i = 0; i < edgeCount; i++) { + const a = Math.floor(rand() * nodes.length) + let b = Math.floor(rand() * nodes.length) + if (b === a) b = (a + 1) % nodes.length + result.push({ + x1: nodes[a]!.x, + y1: nodes[a]!.y, + x2: nodes[b]!.x, + y2: nodes[b]!.y, + }) + } + return result + }, [nodes]) + + return ( + + {edges.map((e, i) => ( + + ))} + {nodes.map((n, i) => ( + + ))} + + ) +} + +export const GraphCard = memo( + ({ containerTags, width = 216, height = 220, className }) => { + const { setViewMode } = useViewMode() + + const { data, isLoading, error } = useGraphApi({ + containerTags, + limit: 20, + enabled: true, + }) + + if (error) { + return ( +

+

+ Failed to load graph +

+
+ ) + } + + const documentCount = data.stats?.documentsWithSpatial ?? 0 + const memoryCount = data.documents.reduce( + (sum, d) => sum + d.memories.length, + 0, + ) + + return ( + + ) + }, +) + +GraphCard.displayName = "GraphCard" 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..8f4babde --- /dev/null +++ b/apps/web/components/new/memory-graph/hooks/use-graph-api.ts @@ -0,0 +1,273 @@ +"use client" + +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useCallback, useMemo, useState, useRef, useEffect } from "react" +import { $fetch } from "@repo/lib/api" +import type { + GraphViewportResponse, + GraphBoundsResponse, + GraphStatsResponse, +} from "../types" + +interface ViewportParams { + minX: number + maxX: number + minY: number + maxY: number +} + +interface UseGraphApiOptions { + containerTags?: string[] + limit?: number + enabled?: boolean + documentIds?: string[] +} + +export function useGraphApi(options: UseGraphApiOptions = {}) { + const { containerTags, documentIds, limit = 200, enabled = true } = options + + const queryClient = useQueryClient() + + const [viewport, setViewport] = useState({ + minX: 0, + maxX: 1000, + minY: 0, + maxY: 1000, + }) + + // Debounce viewport changes + 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) + }, []) + + useEffect(() => { + return () => { + if (viewportTimeoutRef.current) { + clearTimeout(viewportTimeoutRef.current) + } + } + }, []) + + const boundsQuery = useQuery({ + queryKey: ["graph-bounds", containerTags?.join(",")], + queryFn: async (): Promise => { + const params = new URLSearchParams() + if (containerTags?.length) { + params.set("containerTags", JSON.stringify(containerTags)) + } + + const response = await $fetch("@get/graph/bounds", { + query: Object.fromEntries(params), + disableValidation: true, + }) + + if (response.error) { + throw new Error( + response.error?.message || "Failed to fetch graph bounds", + ) + } + + return response.data as GraphBoundsResponse + }, + staleTime: 5 * 60 * 1000, + enabled, + }) + + const statsQuery = useQuery({ + queryKey: ["graph-stats", containerTags?.join(",")], + queryFn: async (): Promise => { + const params = new URLSearchParams() + if (containerTags?.length) { + params.set("containerTags", JSON.stringify(containerTags)) + } + + const response = await $fetch("@get/graph/stats", { + query: Object.fromEntries(params), + disableValidation: true, + }) + + if (response.error) { + throw new Error( + response.error?.message || "Failed to fetch graph stats", + ) + } + + return response.data as GraphStatsResponse + }, + staleTime: 5 * 60 * 1000, + enabled, + }) + + const viewportQuery = useQuery({ + queryKey: [ + "graph-viewport", + viewport.minX, + viewport.maxX, + viewport.minY, + viewport.maxY, + containerTags?.join(","), + documentIds?.join(","), + limit, + ], + queryFn: async (): Promise => { + const response = await $fetch("@post/graph/viewport", { + body: { + viewport: { + minX: viewport.minX, + maxX: viewport.maxX, + minY: viewport.minY, + maxY: viewport.maxY, + }, + containerTags, + documentIds, + 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, + enabled, + }) + + // Prefetch adjacent viewports for smoother panning + const prefetchAdjacentViewports = useCallback( + (currentViewport: ViewportParams) => { + const viewportWidth = currentViewport.maxX - currentViewport.minX + const viewportHeight = currentViewport.maxY - currentViewport.minY + + const offsets = [ + { dx: viewportWidth * 0.5, dy: 0 }, + { dx: -viewportWidth * 0.5, dy: 0 }, + { dx: 0, dy: viewportHeight * 0.5 }, + { dx: 0, dy: -viewportHeight * 0.5 }, + ] + + offsets.forEach(({ dx, dy }) => { + const prefetchViewport = { + minX: Math.max(0, currentViewport.minX + dx), + maxX: Math.max(0, currentViewport.maxX + dx), + minY: Math.max(0, currentViewport.minY + dy), + maxY: Math.max(0, currentViewport.maxY + dy), + } + + queryClient.prefetchQuery({ + queryKey: [ + "graph-viewport", + prefetchViewport.minX, + prefetchViewport.maxX, + prefetchViewport.minY, + prefetchViewport.maxY, + containerTags?.join(","), + limit, + ], + queryFn: async () => { + const response = await $fetch("@post/graph/viewport", { + body: { + viewport: prefetchViewport, + containerTags, + limit, + }, + disableValidation: true, + }) + + if (response.error) { + throw new Error( + response.error?.message || "Failed to fetch graph viewport", + ) + } + + return response.data + }, + staleTime: 30 * 1000, + }) + }) + }, + [queryClient, containerTags, limit], + ) + + const data = useMemo(() => { + return { + documents: viewportQuery.data?.documents ?? [], + edges: viewportQuery.data?.edges ?? [], + totalCount: viewportQuery.data?.totalCount ?? 0, + bounds: boundsQuery.data?.bounds ?? null, + stats: statsQuery.data ?? null, + } + }, [viewportQuery.data, boundsQuery.data, statsQuery.data]) + + const isLoading = viewportQuery.isPending || boundsQuery.isPending + const isRefetching = viewportQuery.isRefetching + const error = + viewportQuery.error || boundsQuery.error || statsQuery.error || null + + return { + data, + isLoading, + isRefetching, + error, + viewport, + updateViewport, + prefetchAdjacentViewports, + refetch: viewportQuery.refetch, + } +} + +/** + * Scales backend coordinates (0-1000) to graph canvas coordinates + */ +export function scaleBackendToCanvas( + x: number, + y: number, + canvasWidth: number, + canvasHeight: number, +): { x: number; y: number } { + const scale = Math.min(canvasWidth, canvasHeight) / 1000 + const offsetX = (canvasWidth - 1000 * scale) / 2 + const offsetY = (canvasHeight - 1000 * scale) / 2 + + return { + x: x * scale + offsetX, + y: y * scale + offsetY, + } +} + +/** + * Scales canvas coordinates to backend coordinates (0-1000) + */ +export function scaleCanvasToBackend( + x: number, + y: number, + canvasWidth: number, + canvasHeight: number, +): { x: number; y: number } { + const scale = Math.min(canvasWidth, canvasHeight) / 1000 + const offsetX = (canvasWidth - 1000 * scale) / 2 + const offsetY = (canvasHeight - 1000 * scale) / 2 + + return { + x: (x - offsetX) / scale, + y: (y - offsetY) / scale, + } +} 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..1c5b0a10 --- /dev/null +++ b/apps/web/components/new/memory-graph/hooks/use-graph-data.ts @@ -0,0 +1,289 @@ +"use client" + +import { useMemo, useRef, useEffect } from "react" +import { MEMORY_BORDER, EDGE_COLORS } from "../constants" +import type { + GraphNode, + GraphEdge, + GraphApiDocument, + GraphApiMemory, + GraphApiEdge, + DocumentNodeData, + MemoryNodeData, +} from "../types" + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000 +const ONE_DAY_MS = 24 * 60 * 60 * 1000 +const MEMORY_CLUSTER_SPREAD = 150 + +function getMemoryBorderColor(mem: GraphApiMemory): string { + if (mem.isForgotten) return MEMORY_BORDER.forgotten + if (mem.forgetAfter) { + const msLeft = new Date(mem.forgetAfter).getTime() - Date.now() + if (msLeft < SEVEN_DAYS_MS) return MEMORY_BORDER.expiring + } + const age = Date.now() - new Date(mem.createdAt).getTime() + if (age < ONE_DAY_MS) return MEMORY_BORDER.recent + return MEMORY_BORDER.default +} + +function getEdgeVisualProps(similarity: number) { + return { + opacity: 0.3 + similarity * 0.5, + thickness: 1 + similarity * 1.5, + } +} + +function normalizeDocCoordinates( + documents: GraphApiDocument[], +): GraphApiDocument[] { + if (documents.length <= 1) return documents + + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const doc of documents) { + minX = Math.min(minX, doc.x) + maxX = Math.max(maxX, doc.x) + minY = Math.min(minY, doc.y) + maxY = Math.max(maxY, doc.y) + } + + const rangeX = maxX - minX || 1 + const rangeY = maxY - minY || 1 + const PAD = 100 + + return documents.map((doc) => ({ + ...doc, + x: PAD + ((doc.x - minX) / rangeX) * (1000 - 2 * PAD), + y: PAD + ((doc.y - minY) / rangeY) * (1000 - 2 * PAD), + })) +} + +export function useGraphData( + documents: GraphApiDocument[], + apiEdges: GraphApiEdge[], + draggingNodeId: string | null, + canvasWidth: number, + canvasHeight: number, +) { + const nodeCache = useRef>(new Map()) + + useEffect(() => { + if (!documents || documents.length === 0) return + + const currentIds = new Set() + for (const doc of documents) { + currentIds.add(doc.id) + for (const mem of doc.memories) currentIds.add(mem.id) + } + + for (const [id] of nodeCache.current.entries()) { + if (!currentIds.has(id)) nodeCache.current.delete(id) + } + }, [documents]) + + const { scale, offsetX, offsetY } = useMemo(() => { + if (canvasWidth === 0 || canvasHeight === 0) { + return { scale: 1, offsetX: 0, offsetY: 0 } + } + const paddingFactor = 0.8 + const s = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000 + const ox = (canvasWidth - 1000 * s) / 2 + const oy = (canvasHeight - 1000 * s) / 2 + return { scale: s, offsetX: ox, offsetY: oy } + }, [canvasWidth, canvasHeight]) + + const normalizedDocs = useMemo( + () => normalizeDocCoordinates(documents), + [documents], + ) + + const nodes = useMemo(() => { + if (!normalizedDocs || normalizedDocs.length === 0) return [] + + const result: GraphNode[] = [] + + for (const doc of normalizedDocs) { + const initialX = doc.x * scale + offsetX + const initialY = doc.y * scale + offsetY + + let docNode = nodeCache.current.get(doc.id) + const docData: DocumentNodeData = { + id: doc.id, + title: doc.title, + summary: doc.summary, + type: doc.documentType, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + memories: doc.memories, + } + + if (docNode) { + docNode.data = docData + docNode.isDragging = draggingNodeId === doc.id + } else { + docNode = { + id: doc.id, + type: "document", + x: initialX, + y: initialY, + data: docData, + size: 50, + borderColor: "#2A2F36", + isHovered: false, + isDragging: false, + } + nodeCache.current.set(doc.id, docNode) + } + result.push(docNode) + + const memCount = doc.memories.length + for (let i = 0; i < memCount; i++) { + const mem = doc.memories[i]! + let memNode = nodeCache.current.get(mem.id) + const memData: MemoryNodeData = { + ...mem, + documentId: doc.id, + content: mem.memory, + } + + if (memNode) { + memNode.data = memData + memNode.borderColor = getMemoryBorderColor(mem) + memNode.isDragging = draggingNodeId === mem.id + } else { + const angle = (i / memCount) * 2 * Math.PI + memNode = { + id: mem.id, + type: "memory", + x: docNode.x + Math.cos(angle) * MEMORY_CLUSTER_SPREAD, + y: docNode.y + Math.sin(angle) * MEMORY_CLUSTER_SPREAD, + data: memData, + size: 36, + borderColor: getMemoryBorderColor(mem), + isHovered: false, + isDragging: false, + } + nodeCache.current.set(mem.id, memNode) + } + result.push(memNode) + } + } + + return result + }, [normalizedDocs, scale, offsetX, offsetY, draggingNodeId]) + + const edges = useMemo(() => { + if (!normalizedDocs || normalizedDocs.length === 0) return [] + + const result: GraphEdge[] = [] + const allNodeIds = new Set(nodes.map((n) => n.id)) + + for (const doc of normalizedDocs) { + for (const mem of doc.memories) { + result.push({ + id: `dm-${doc.id}-${mem.id}`, + source: doc.id, + target: mem.id, + similarity: 1, + visualProps: { opacity: 0.3, thickness: 1.5 }, + edgeType: "doc-memory", + }) + } + } + + for (const doc of normalizedDocs) { + for (const mem of doc.memories) { + if (mem.parentMemoryId && allNodeIds.has(mem.parentMemoryId)) { + result.push({ + id: `ver-${mem.parentMemoryId}-${mem.id}`, + source: mem.parentMemoryId, + target: mem.id, + similarity: 1, + visualProps: { opacity: 0.6, thickness: 2 }, + edgeType: "version", + }) + } + } + } + + for (const apiEdge of apiEdges) { + if (!allNodeIds.has(apiEdge.source) || !allNodeIds.has(apiEdge.target)) { + continue + } + + result.push({ + id: `sim-${apiEdge.source}-${apiEdge.target}`, + source: apiEdge.source, + target: apiEdge.target, + similarity: apiEdge.similarity, + visualProps: getEdgeVisualProps(apiEdge.similarity), + edgeType: "similarity", + }) + } + + return result + }, [normalizedDocs, apiEdges, nodes]) + + return { nodes, edges, scale, offsetX, offsetY } +} + +export function screenToBackendCoords( + screenX: number, + screenY: number, + panX: number, + panY: number, + zoom: number, + canvasWidth: number, + canvasHeight: number, +): { x: number; y: number } { + const canvasX = (screenX - panX) / zoom + const canvasY = (screenY - panY) / zoom + + const paddingFactor = 0.8 + const s = (Math.min(canvasWidth, canvasHeight) * paddingFactor) / 1000 + const ox = (canvasWidth - 1000 * s) / 2 + const oy = (canvasHeight - 1000 * s) / 2 + + return { + x: (canvasX - ox) / s, + y: (canvasY - oy) / s, + } +} + +export function calculateBackendViewport( + panX: number, + panY: number, + zoom: number, + canvasWidth: number, + canvasHeight: number, +): { minX: number; maxX: number; minY: number; maxY: number } { + const topLeft = screenToBackendCoords( + 0, + 0, + panX, + panY, + zoom, + canvasWidth, + canvasHeight, + ) + const bottomRight = screenToBackendCoords( + canvasWidth, + canvasHeight, + panX, + panY, + zoom, + canvasWidth, + canvasHeight, + ) + + return { + minX: Math.max(0, Math.min(topLeft.x, bottomRight.x)), + maxX: Math.max(topLeft.x, bottomRight.x), + minY: Math.max(0, Math.min(topLeft.y, bottomRight.y)), + maxY: Math.max(topLeft.y, bottomRight.y), + } +} 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..cb3827a0 --- /dev/null +++ b/apps/web/components/new/memory-graph/index.ts @@ -0,0 +1,33 @@ +// Memory Graph components +export { MemoryGraph } from "./memory-graph" +export type { MemoryGraphProps } from "./memory-graph" +export { GraphCard } from "./graph-card" +export type { GraphCardProps } from "./graph-card" + +// Hooks +export { useGraphApi } from "./hooks/use-graph-api" +export { + useGraphData, + calculateBackendViewport, + screenToBackendCoords, +} from "./hooks/use-graph-data" + +// Canvas engine +export { ViewportState } from "./canvas/viewport" +export { ForceSimulation } from "./canvas/simulation" + +// Types +export type { + GraphNode, + GraphEdge, + GraphApiDocument, + GraphApiMemory, + GraphApiEdge, + GraphViewportResponse, + GraphBoundsResponse, + GraphStatsResponse, + DocumentNodeData, + MemoryNodeData, + 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..6ec393d2 --- /dev/null +++ b/apps/web/components/new/memory-graph/legend.tsx @@ -0,0 +1,599 @@ +"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/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..cb17104c --- /dev/null +++ b/apps/web/components/new/memory-graph/memory-graph.tsx @@ -0,0 +1,515 @@ +"use client" + +import { GlassMenuEffect } from "@repo/ui/other/glass-effect" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useHotkeys } from "react-hotkeys-hook" +import { GraphCanvas } from "./graph-canvas" +import { useGraphApi } from "./hooks/use-graph-api" +import { useGraphData } from "./hooks/use-graph-data" +import { ForceSimulation } from "./canvas/simulation" +import { VersionChainIndex } from "./canvas/version-chain" +import type { ViewportState } from "./canvas/viewport" +import { Legend } from "./legend" +import { LoadingIndicator } from "./loading-indicator" +import { NavigationControls } from "./navigation-controls" +import { NodeHoverPopover } from "./node-hover-popover" +import { colors } from "./constants" +import type { GraphNode } from "./types" + +export interface MemoryGraphProps { + children?: React.ReactNode + isLoading?: boolean + error?: Error | null + variant?: "console" | "consumer" + legendId?: string + highlightDocumentIds?: string[] + highlightsVisible?: boolean + containerTags?: string[] + documentIds?: string[] + maxNodes?: number + isSlideshowActive?: boolean + onSlideshowNodeChange?: (nodeId: string | null) => void + onSlideshowStop?: () => void + canvasRef?: React.RefObject +} + +export const MemoryGraph = ({ + children, + isLoading: externalIsLoading = false, + error: externalError = null, + variant = "console", + legendId, + highlightDocumentIds = [], + highlightsVisible = true, + containerTags, + documentIds, + maxNodes = 200, + isSlideshowActive = false, + onSlideshowNodeChange, + onSlideshowStop, + canvasRef, +}: MemoryGraphProps) => { + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }) + const [containerBounds, setContainerBounds] = useState(null) + const containerRef = useRef(null) + const viewportRef = useRef(null) + const simulationRef = useRef(null) + const chainIndex = useRef(new VersionChainIndex()) + + // React state only for things that affect DOM + const [hoveredNode, setHoveredNode] = useState(null) + const [selectedNode, setSelectedNode] = useState(null) + const [zoomDisplay, setZoomDisplay] = useState(50) + + const { + data: apiData, + isLoading: apiIsLoading, + error: apiError, + } = useGraphApi({ + containerTags, + documentIds, + limit: maxNodes, + enabled: containerSize.width > 0 && containerSize.height > 0, + }) + + const { nodes, edges } = useGraphData( + apiData.documents, + apiData.edges, + null, + containerSize.width, + containerSize.height, + ) + + // Rebuild version chain index when documents change + useEffect(() => { + chainIndex.current.rebuild(apiData.documents) + }, [apiData.documents]) + + // Force simulation (created once, updated when data changes) + useEffect(() => { + if (nodes.length === 0) return + + if (!simulationRef.current) { + simulationRef.current = new ForceSimulation() + } + simulationRef.current.init(nodes, edges) + + return () => { + simulationRef.current?.destroy() + simulationRef.current = null + } + }, [nodes, edges]) + + // Auto-fit when data first loads + const hasAutoFittedRef = useRef(false) + useEffect(() => { + if ( + !hasAutoFittedRef.current && + nodes.length > 0 && + viewportRef.current && + containerSize.width > 0 + ) { + const timer = setTimeout(() => { + viewportRef.current?.fitToNodes( + nodes, + containerSize.width, + containerSize.height, + ) + hasAutoFittedRef.current = true + }, 100) + return () => clearTimeout(timer) + } + }, [nodes, containerSize.width, containerSize.height]) + + useEffect(() => { + if (nodes.length === 0) hasAutoFittedRef.current = false + }, [nodes.length]) + + // Container resize observer + useEffect(() => { + const el = containerRef.current + if (!el) return + + const ro = new ResizeObserver(() => { + setContainerSize({ width: el.clientWidth, height: el.clientHeight }) + setContainerBounds(el.getBoundingClientRect()) + }) + ro.observe(el) + setContainerSize({ width: el.clientWidth, height: el.clientHeight }) + setContainerBounds(el.getBoundingClientRect()) + + return () => ro.disconnect() + }, []) + + // Callbacks for GraphCanvas + const handleNodeHover = useCallback( + (id: string | null) => setHoveredNode(id), + [], + ) + + const handleNodeClick = useCallback((id: string | null) => { + setSelectedNode((prev) => (id === null ? null : prev === id ? null : id)) + }, []) + + const handleNodeDragStart = useCallback((_id: string) => { + // Drag is handled imperatively by InputHandler + }, []) + + const handleNodeDragEnd = useCallback(() => { + // Drag end handled by InputHandler + }, []) + + const handleViewportChange = useCallback((zoom: number) => { + setZoomDisplay(Math.round(zoom * 100)) + }, []) + + // Navigation + const handleAutoFit = useCallback(() => { + if (nodes.length === 0 || !viewportRef.current) return + viewportRef.current.fitToNodes( + nodes, + containerSize.width, + containerSize.height, + ) + }, [nodes, containerSize.width, containerSize.height]) + + const handleCenter = useCallback(() => { + if (nodes.length === 0 || !viewportRef.current) return + let sx = 0 + let sy = 0 + for (const n of nodes) { + sx += n.x + sy += n.y + } + viewportRef.current.centerOn( + sx / nodes.length, + sy / nodes.length, + containerSize.width, + containerSize.height, + ) + }, [nodes, containerSize.width, containerSize.height]) + + const handleZoomIn = useCallback(() => { + const vp = viewportRef.current + if (!vp) return + vp.zoomTo(vp.zoom * 1.3, containerSize.width / 2, containerSize.height / 2) + }, [containerSize.width, containerSize.height]) + + const handleZoomOut = useCallback(() => { + const vp = viewportRef.current + if (!vp) return + vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, containerSize.height / 2) + }, [containerSize.width, containerSize.height]) + + // Keyboard shortcuts + useHotkeys("z", handleAutoFit, [handleAutoFit]) + useHotkeys("c", handleCenter, [handleCenter]) + useHotkeys("equal", handleZoomIn, [handleZoomIn]) + useHotkeys("minus", handleZoomOut, [handleZoomOut]) + useHotkeys("escape", () => setSelectedNode(null), []) + + // Arrow key navigation through nodes + const selectAndCenter = useCallback( + (nodeId: string) => { + setSelectedNode(nodeId) + const n = nodes.find((nd) => nd.id === nodeId) + if (n && viewportRef.current) + viewportRef.current.centerOn( + n.x, + n.y, + containerSize.width, + containerSize.height, + ) + }, + [nodes, containerSize.width, containerSize.height], + ) + + const navigateUp = useCallback(() => { + if (!selectedNode) return + const chain = chainIndex.current.getChain(selectedNode) + if (chain && chain.length > 1) { + const idx = chain.findIndex((e) => e.id === selectedNode) + if (idx > 0) { + selectAndCenter(chain[idx - 1]!.id) + return + } + } + // At top of chain or no chain — go to parent document + const node = nodes.find((n) => n.id === selectedNode) + if (node?.type === "memory" && "documentId" in node.data) { + selectAndCenter(node.data.documentId) + } + }, [selectedNode, nodes, selectAndCenter]) + + const navigateDown = useCallback(() => { + if (!selectedNode) return + // Version chain navigation + const chain = chainIndex.current.getChain(selectedNode) + if (chain && chain.length > 1) { + const idx = chain.findIndex((e) => e.id === selectedNode) + if (idx >= 0 && idx < chain.length - 1) { + selectAndCenter(chain[idx + 1]!.id) + return + } + } + // On a document — go to its first memory + const node = nodes.find((n) => n.id === selectedNode) + if (node?.type === "document") { + const child = nodes.find( + (n) => + n.type === "memory" && + "documentId" in n.data && + n.data.documentId === selectedNode, + ) + if (child) selectAndCenter(child.id) + } + }, [selectedNode, nodes, selectAndCenter]) + + const navigateNext = useCallback(() => { + if (!selectedNode) return + const node = nodes.find((n) => n.id === selectedNode) + if (!node) return + + if (node.type === "document") { + const docs = nodes.filter((n) => n.type === "document") + const idx = docs.findIndex((n) => n.id === selectedNode) + const next = docs[(idx + 1) % docs.length]! + setSelectedNode(next.id) + if (viewportRef.current) + viewportRef.current.centerOn( + next.x, + next.y, + containerSize.width, + containerSize.height, + ) + } else { + const docId = "documentId" in node.data ? node.data.documentId : null + const siblings = nodes.filter( + (n) => + n.type === "memory" && + "documentId" in n.data && + n.data.documentId === docId, + ) + if (siblings.length === 0) return + const idx = siblings.findIndex((n) => n.id === selectedNode) + const next = siblings[(idx + 1) % siblings.length]! + setSelectedNode(next.id) + if (viewportRef.current) + viewportRef.current.centerOn( + next.x, + next.y, + containerSize.width, + containerSize.height, + ) + } + }, [selectedNode, nodes, containerSize.width, containerSize.height]) + + const navigatePrev = useCallback(() => { + if (!selectedNode) return + const node = nodes.find((n) => n.id === selectedNode) + if (!node) return + + if (node.type === "document") { + const docs = nodes.filter((n) => n.type === "document") + const idx = docs.findIndex((n) => n.id === selectedNode) + const prev = docs[(idx - 1 + docs.length) % docs.length]! + setSelectedNode(prev.id) + if (viewportRef.current) + viewportRef.current.centerOn( + prev.x, + prev.y, + containerSize.width, + containerSize.height, + ) + } else { + const docId = "documentId" in node.data ? node.data.documentId : null + const siblings = nodes.filter( + (n) => + n.type === "memory" && + "documentId" in n.data && + n.data.documentId === docId, + ) + if (siblings.length === 0) return + const idx = siblings.findIndex((n) => n.id === selectedNode) + const prev = siblings[(idx - 1 + siblings.length) % siblings.length]! + setSelectedNode(prev.id) + if (viewportRef.current) + viewportRef.current.centerOn( + prev.x, + prev.y, + containerSize.width, + containerSize.height, + ) + } + }, [selectedNode, nodes, containerSize.width, containerSize.height]) + + useHotkeys("up", navigateUp, [navigateUp]) + useHotkeys("down", navigateDown, [navigateDown]) + useHotkeys("right", navigateNext, [navigateNext]) + useHotkeys("left", navigatePrev, [navigatePrev]) + + // Slideshow + useEffect(() => { + if (!isSlideshowActive || nodes.length === 0) { + if (!isSlideshowActive) { + setSelectedNode(null) + simulationRef.current?.coolDown() + } + return + } + + let lastIdx = -1 + const pick = () => { + if (nodes.length === 0) return + let idx: number + if (nodes.length > 1) { + do { + idx = Math.floor(Math.random() * nodes.length) + } while (idx === lastIdx) + } else { + idx = 0 + } + lastIdx = idx + const n = nodes[idx]! + setSelectedNode(n.id) + viewportRef.current?.centerOn( + n.x, + n.y, + containerSize.width, + containerSize.height, + ) + simulationRef.current?.reheat() + onSlideshowNodeChange?.(n.id) + setTimeout(() => simulationRef.current?.coolDown(), 1000) + } + + pick() + const interval = setInterval(pick, 3500) + return () => clearInterval(interval) + }, [ + isSlideshowActive, + nodes, + containerSize.width, + containerSize.height, + onSlideshowNodeChange, + ]) + + // Active node: selected takes priority, then hovered + const activeNodeId = selectedNode ?? hoveredNode + const activeNodeData = useMemo(() => { + if (!activeNodeId) return null + return nodes.find((n) => n.id === activeNodeId) ?? null + }, [activeNodeId, nodes]) + + const activePopoverPosition = useMemo(() => { + if (!activeNodeData || !viewportRef.current) return null + const vp = viewportRef.current + const screen = vp.worldToScreen(activeNodeData.x, activeNodeData.y) + return { + screenX: screen.x, + screenY: screen.y, + nodeRadius: (activeNodeData.size * vp.zoom) / 2, + } + }, [activeNodeData]) + + const activeVersionChain = useMemo(() => { + if (!activeNodeData || activeNodeData.type !== "memory") return null + return chainIndex.current.getChain(activeNodeData.id) + }, [activeNodeData]) + + const isLoading = externalIsLoading || apiIsLoading + const error = externalError || apiError + + if (error) { + return ( +
+
+ +
+ Error loading graph: {error.message} +
+
+
+ ) + } + + return ( +
+ + + {!isLoading && + nodes.filter((n) => n.type === "document").length === 0 && + children} + +
+ {containerSize.width > 0 && containerSize.height > 0 && ( + + )} + + {activeNodeData && activePopoverPosition && ( + + )} + +
+ {containerSize.width > 0 && ( + + )} + +
+
+
+ ) +} 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..27d2a7e0 --- /dev/null +++ b/apps/web/components/new/memory-graph/navigation-controls.tsx @@ -0,0 +1,164 @@ +"use client" + +import { memo } from "react" +import type { GraphNode } from "./types" +import { cn } from "@lib/utils" +import { Settings } from "lucide-react" + +interface NavigationControlsProps { + onCenter: () => void + onZoomIn: () => void + onZoomOut: () => void + onAutoFit: () => void + nodes: GraphNode[] + className?: string + zoomLevel: number +} + +// Keyboard shortcut badge component +const KeyboardShortcut = memo(function KeyboardShortcut({ + keys, +}: { + keys: string +}) { + return ( +
+ + {keys} + +
+ ) +}) + +// Navigation buttons component +const NavigationButtons = memo(function NavigationButtons({ + onAutoFit, + onCenter, + onZoomIn, + onZoomOut, + zoomLevel, +}: { + onAutoFit: () => void + onCenter: () => void + onZoomIn: () => void + onZoomOut: () => void + zoomLevel: number +}) { + return ( +
+ {/* Fit button */} + + + {/* Center button */} + + + {/* Zoom controls */} +
+ {zoomLevel}% +
+ + +
+
+
+ ) +}) + +const SettingsButton = memo(function SettingsButton() { + return ( + + ) +}) + +export const NavigationControls = memo( + ({ + onCenter, + onZoomIn, + onZoomOut, + onAutoFit, + nodes, + className = "", + zoomLevel, + }) => { + if (nodes.length === 0) { + return null + } + + return ( +
+
+ + {/* Commented out for now as we are not using this */} + {/**/} +
+
+ ) + }, +) + +NavigationControls.displayName = "NavigationControls" diff --git a/apps/web/components/new/memory-graph/node-hover-popover.tsx b/apps/web/components/new/memory-graph/node-hover-popover.tsx new file mode 100644 index 00000000..a1e1deeb --- /dev/null +++ b/apps/web/components/new/memory-graph/node-hover-popover.tsx @@ -0,0 +1,467 @@ +"use client" + +import { memo, useMemo, useCallback, useState } from "react" +import type { GraphNode, DocumentNodeData, MemoryNodeData } from "./types" +import type { ChainEntry } from "./canvas/version-chain" + +export interface NodeHoverPopoverProps { + node: GraphNode + screenX: number + screenY: number + nodeRadius: number + containerBounds?: DOMRect + versionChain?: ChainEntry[] | null + onNavigateNext?: () => void + onNavigatePrev?: () => void + onNavigateUp?: () => void + onNavigateDown?: () => void + onSelectNode?: (nodeId: string) => void +} + +function KeyBadge({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function NavButton({ + icon, + label, + onClick, +}: { + icon: string + label: string + onClick?: () => void +}) { + return ( + + ) +} + +function CopyableId({ label, value }: { label: string; value: string }) { + const [copied, setCopied] = useState(false) + const copy = useCallback(() => { + navigator.clipboard.writeText(value) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }, [value]) + + const short = + value.length > 12 ? `${value.slice(0, 6)}...${value.slice(-4)}` : value + + return ( + + ) +} + +type Quadrant = "right" | "left" | "above" | "below" + +function pickBestQuadrant( + screenX: number, + screenY: number, + nodeRadius: number, + containerWidth: number, + containerHeight: number, + popoverWidth: number, + popoverHeight: number, +): Quadrant { + const gap = 24 + const spaceRight = containerWidth - (screenX + nodeRadius + gap) + const spaceLeft = screenX - nodeRadius - gap + const spaceAbove = screenY - nodeRadius - gap + const spaceBelow = containerHeight - (screenY + nodeRadius + gap) + + const fits: [Quadrant, number][] = [ + ["right", spaceRight >= popoverWidth ? spaceRight : -1], + ["left", spaceLeft >= popoverWidth ? spaceLeft : -1], + ["above", spaceAbove >= popoverHeight ? spaceAbove : -1], + ["below", spaceBelow >= popoverHeight ? spaceBelow : -1], + ] + + const preferred: Quadrant[] = ["right", "left", "below", "above"] + for (const q of preferred) { + const entry = fits.find(([dir]) => dir === q) + if (entry && entry[1] > 0) return q + } + + return fits.sort((a, b) => b[1] - a[1])[0]![0] +} + +function truncate(s: string, max: number) { + return s.length > max ? `${s.substring(0, max)}...` : s +} + +function VersionTimeline({ + chain, + currentId, + onSelect, +}: { + chain: ChainEntry[] + currentId: string + onSelect?: (id: string) => void +}) { + return ( +
+ {chain.map((entry) => { + const isCurrent = entry.id === currentId + return ( + + ) + })} +
+ ) +} + +export const NodeHoverPopover = memo( + function NodeHoverPopover({ + node, + screenX, + screenY, + nodeRadius, + containerBounds, + versionChain, + onNavigateNext, + onNavigatePrev, + onNavigateUp, + onNavigateDown, + onSelectNode, + }) { + const CARD_W = 280 + const SHORTCUTS_W = 100 + const GAP = 24 + const TOTAL_W = CARD_W + 12 + SHORTCUTS_W + + const isMemory = node.type === "memory" + const data = node.data + + const memoryMeta = useMemo(() => { + if (!isMemory) return null + const md = data as MemoryNodeData + return { + version: md.version ?? 1, + isLatest: md.isLatest ?? false, + isForgotten: md.isForgotten ?? false, + forgetReason: md.forgetReason ?? null, + forgetAfter: md.forgetAfter ?? null, + } + }, [isMemory, data]) + + const hasChain = versionChain && versionChain.length > 1 + const hasForgetInfo = + memoryMeta && (memoryMeta.isForgotten || memoryMeta.forgetAfter) + + const CARD_H = hasChain ? 200 : hasForgetInfo ? 165 : 135 + const TOTAL_H = CARD_H + + const { popoverX, popoverY, connectorPath } = useMemo(() => { + const cw = containerBounds?.width ?? 800 + const ch = containerBounds?.height ?? 600 + + const quadrant = pickBestQuadrant( + screenX, + screenY, + nodeRadius, + cw, + ch, + TOTAL_W + GAP, + TOTAL_H, + ) + + let px: number + let py: number + let connStart: { x: number; y: number } + + switch (quadrant) { + case "right": + px = screenX + nodeRadius + GAP + py = screenY - TOTAL_H / 2 + connStart = { x: screenX + nodeRadius, y: screenY } + break + case "left": + px = screenX - nodeRadius - GAP - TOTAL_W + py = screenY - TOTAL_H / 2 + connStart = { x: screenX - nodeRadius, y: screenY } + break + case "below": + px = screenX - TOTAL_W / 2 + py = screenY + nodeRadius + GAP + connStart = { x: screenX, y: screenY + nodeRadius } + break + case "above": + px = screenX - TOTAL_W / 2 + py = screenY - nodeRadius - GAP - TOTAL_H + connStart = { x: screenX, y: screenY - nodeRadius } + break + } + + px = Math.max(8, Math.min(cw - TOTAL_W - 8, px)) + py = Math.max(8, Math.min(ch - TOTAL_H - 8, py)) + + const cardCenterX = px + CARD_W / 2 + const cardCenterY = py + TOTAL_H / 2 + const path = `M ${connStart.x} ${connStart.y} L ${cardCenterX} ${cardCenterY}` + + return { popoverX: px, popoverY: py, connectorPath: path } + }, [screenX, screenY, nodeRadius, containerBounds, TOTAL_W, TOTAL_H]) + + const content = useMemo(() => { + if (isMemory) { + const md = data as MemoryNodeData + return md.memory || md.content || "" + } + const dd = data as DocumentNodeData + return dd.summary || dd.title || "" + }, [isMemory, data]) + + const docData = !isMemory ? (data as DocumentNodeData) : null + + return ( +
+ + + + +
+ {/* Card */} +
+ {/* Content — show timeline if chain exists, otherwise plain text */} + {hasChain ? ( + + ) : ( +
+

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

+
+ )} + + {/* Forget info (memory-only) */} + {memoryMeta && hasForgetInfo && ( +
+ {memoryMeta.forgetAfter && ( + + Expires:{" "} + {new Date(memoryMeta.forgetAfter).toLocaleDateString()} + + )} + {memoryMeta.forgetReason && ( + + Reason: {memoryMeta.forgetReason} + + )} + {memoryMeta.isForgotten && !memoryMeta.forgetReason && ( + + Forgotten + + )} +
+ )} + + {/* Bottom bar */} +
+ {memoryMeta ? ( + <> + + v{memoryMeta.version}{" "} + {memoryMeta.isForgotten + ? "Forgotten" + : memoryMeta.isLatest + ? "Latest" + : "Superseded"} + + + ) : ( + <> + + {docData?.type || "document"} + + + {docData?.memories?.length ?? 0} memories + + + )} +
+ + {/* ID row */} +
+ {isMemory ? ( + + ) : ( + + )} +
+
+ + {/* Navigation */} +
+ {isMemory && ( + + )} + {(isMemory ? hasChain : true) && ( + + )} + + +
+
+
+ ) + }, +) diff --git a/apps/web/components/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/types.ts b/apps/web/components/new/memory-graph/types.ts new file mode 100644 index 00000000..f20243f6 --- /dev/null +++ b/apps/web/components/new/memory-graph/types.ts @@ -0,0 +1,163 @@ +import type { + DocumentsResponse, + DocumentWithMemories, + MemoryEntry, +} from "./api-types" + +export type { DocumentsResponse, DocumentWithMemories, MemoryEntry } + +// Graph API types matching backend response + +export interface GraphApiMemory { + id: string + memory: string + isStatic: boolean + spaceId: string + isLatest: boolean + isForgotten: boolean + forgetAfter: string | null + forgetReason: string | null + version: number + parentMemoryId: string | null + rootMemoryId: string | null + createdAt: string + updatedAt: string +} + +export interface GraphApiDocument { + id: string + title: string | null + summary: string | null + documentType: string + createdAt: string + updatedAt: string + x: number // backend coordinates (dynamic range) + y: number // backend coordinates (dynamic range) + memories: GraphApiMemory[] +} + +export interface GraphApiEdge { + source: string + target: string + similarity: number // 0-1 +} + +export interface GraphViewportResponse { + documents: GraphApiDocument[] + edges: GraphApiEdge[] + viewport: { + minX: number + maxX: number + minY: number + maxY: number + } + totalCount: number +} + +export interface GraphBoundsResponse { + bounds: { + minX: number + maxX: number + minY: number + maxY: number + } | null +} + +export interface GraphStatsResponse { + totalDocuments: number + documentsWithSpatial: number + totalDocumentEdges: number +} + +// Typed node data + +export interface DocumentNodeData { + id: string + title: string | null + summary: string | null + type: string + createdAt: string + updatedAt: string + memories: GraphApiMemory[] +} + +export interface MemoryNodeData { + id: string + memory: string + content: string + documentId: string + isStatic: boolean + isLatest: boolean + isForgotten: boolean + forgetAfter: string | null + forgetReason: string | null + version: number + parentMemoryId: string | null + spaceId: string + createdAt: string + updatedAt: string +} + +export interface GraphNode { + id: string + type: "document" | "memory" + x: number + y: number + data: DocumentNodeData | MemoryNodeData + size: number + borderColor: string + isHovered: boolean + isDragging: boolean + // D3-force simulation properties + vx?: number + vy?: number + fx?: number | null + fy?: number | null +} + +export interface GraphEdge { + id: string + source: string | GraphNode + target: string | GraphNode + similarity: number + visualProps: { + opacity: number + thickness: number + } + edgeType: "doc-memory" | "similarity" | "version" +} + +export interface GraphCanvasProps { + nodes: GraphNode[] + edges: GraphEdge[] + width: number + height: number + highlightDocumentIds?: string[] + selectedNodeId?: string | null + onNodeHover: (nodeId: string | null) => void + onNodeClick: (nodeId: string | null) => void + onNodeDragStart: (nodeId: string) => void + onNodeDragEnd: () => void + onViewportChange?: (zoom: number) => void + canvasRef?: React.RefObject + variant?: "console" | "consumer" + simulation?: import("./canvas/simulation").ForceSimulation + viewportRef?: React.RefObject< + import("./canvas/viewport").ViewportState | null + > +} + +export interface LegendProps { + variant?: "console" | "consumer" + nodes?: GraphNode[] + edges?: GraphEdge[] + isLoading?: boolean + hoveredNode?: string | null +} + +export interface LoadingIndicatorProps { + isLoading: boolean + isLoadingMore: boolean + totalLoaded: number + variant?: "console" | "consumer" +} diff --git a/apps/web/components/new/share-modal.tsx b/apps/web/components/new/share-modal.tsx new file mode 100644 index 00000000..4f09c7e5 --- /dev/null +++ b/apps/web/components/new/share-modal.tsx @@ -0,0 +1,539 @@ +"use client" + +import { useState, useRef, useCallback } from "react" +import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@ui/components/dialog" +import { Button } from "@ui/components/button" +import { cn } from "@lib/utils" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon, Download, Copy, Check } from "lucide-react" +import { GradientLogo } from "@ui/assets/Logo" +import { useAuth } from "@lib/auth-context" +import { toast } from "sonner" +import * as htmlToImage from "html-to-image" + +type BackgroundTheme = "gradient" | "dark-gradient" | "black" + +interface ShareModalProps { + isOpen: boolean + onClose: () => void + graphCanvasRef?: React.RefObject +} + +// X/Twitter icon +const XIcon2 = ({ className }: { className?: string }) => ( + + + +) + +// LinkedIn icon +const LinkedInIcon = ({ className }: { className?: string }) => ( + + + +) + +// Instagram icon +const InstagramIcon = ({ className }: { className?: string }) => ( + + + + + +) + +// Background gradient overlay component +const BackgroundGradient = ({ + theme, + className, +}: { + theme: BackgroundTheme + className?: string +}) => { + if (theme === "black") { + return ( +
+ {/* Dotted pattern */} +
+
+ ) + } + + if (theme === "dark-gradient") { + return ( +
+ {/* Base dark background */} +
+ {/* Blue glow effect */} +
+
+ {/* Dotted pattern */} +
+
+ ) + } + + // Default: gradient theme (first screenshot) + return ( +
+ {/* Base dark blue gradient */} +
+ {/* Blue glow from bottom */} +
+
+ {/* Dotted pattern */} +
+
+ ) +} + +// Theme selector button +const ThemeButton = ({ + theme, + isSelected, + onClick, +}: { + theme: BackgroundTheme + isSelected: boolean + onClick: () => void +}) => { + const getPreviewContent = () => { + if (theme === "black") { + return
+ } + if (theme === "dark-gradient") { + return ( +
+
+
+
+ ) + } + return ( +
+
+
+
+ ) + } + + return ( + + ) +} + +// Social button component +const SocialButton = ({ + icon, + onClick, + label, +}: { + icon: React.ReactNode + onClick: () => void + label: string +}) => ( + +) + +export function ShareModal({ + isOpen, + onClose, + graphCanvasRef, +}: ShareModalProps) { + const { user } = useAuth() + const [selectedTheme, setSelectedTheme] = + useState("gradient") + const [isCopying, setIsCopying] = useState(false) + const [copied, setCopied] = useState(false) + const previewRef = useRef(null) + + const displayName = + user?.displayUsername || + (typeof window !== "undefined" ? localStorage.getItem("username") : null) || + (typeof window !== "undefined" ? localStorage.getItem("userName") : null) || + "" + const userName = displayName ? `${displayName.split(" ")[0]}'s` : "Your" + + const capturePreview = useCallback(async (): Promise => { + if (!previewRef.current) return null + + try { + const dataUrl = await htmlToImage.toPng(previewRef.current, { + pixelRatio: 2, + quality: 1, + }) + + // Convert data URL to blob + const response = await fetch(dataUrl) + const blob = await response.blob() + return blob + } catch (error) { + console.error("Failed to capture preview:", error) + return null + } + }, []) + + const handleCopySnapshot = useCallback(async () => { + setIsCopying(true) + try { + const blob = await capturePreview() + if (!blob) { + throw new Error("Failed to capture image") + } + + await navigator.clipboard.write([ + new ClipboardItem({ + "image/png": blob, + }), + ]) + + setCopied(true) + toast.success("Snapshot copied to clipboard!") + setTimeout(() => setCopied(false), 2000) + } catch (error) { + console.error("Failed to copy:", error) + toast.error("Failed to copy snapshot. Try downloading instead.") + } finally { + setIsCopying(false) + } + }, [capturePreview]) + + const handleDownload = useCallback(async () => { + try { + const blob = await capturePreview() + if (!blob) { + throw new Error("Failed to capture image") + } + + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = `supermemory-graph-${Date.now()}.png` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + + toast.success("Snapshot downloaded!") + } catch (error) { + console.error("Failed to download:", error) + toast.error("Failed to download snapshot") + } + }, [capturePreview]) + + const handleShareTwitter = useCallback(async () => { + const text = encodeURIComponent( + "Check out my knowledge graph on supermemory! 🧠\n\nhttps://supermemory.ai", + ) + window.open(`https://twitter.com/intent/tweet?text=${text}`, "_blank") + }, []) + + const handleShareLinkedIn = useCallback(async () => { + const url = encodeURIComponent("https://supermemory.ai") + window.open( + `https://www.linkedin.com/sharing/share-offsite/?url=${url}`, + "_blank", + ) + }, []) + + const handleShareInstagram = useCallback(async () => { + // Instagram doesn't have a direct share URL, so we'll download and show a message + await handleDownload() + toast.info("Image downloaded! You can now share it on Instagram.") + }, [handleDownload]) + + const handleClose = () => { + onClose() + } + + return ( + !open && handleClose()}> + +
+ {/* Header */} +
+ + + Share snapshot of your supermemory + + + + + Close + +
+ + {/* Preview area */} +
+ + + {/* Branding header */} +
+ +
+ + {userName} + + + supermemory + +
+
+ + {/* Graph canvas placeholder - will show the actual graph */} +
+ {graphCanvasRef?.current ? ( + Memory graph + ) : ( +
+ Graph preview will appear here +
+ )} +
+
+ + {/* Bottom controls */} +
+ {/* Theme selectors */} +
+ setSelectedTheme("gradient")} + /> + setSelectedTheme("dark-gradient")} + /> + setSelectedTheme("black")} + /> +
+ + {/* Action buttons */} +
+ + + } + onClick={handleDownload} + label="Download" + /> + } + onClick={handleShareTwitter} + label="Share on X" + /> + } + onClick={handleShareLinkedIn} + label="Share on LinkedIn" + /> + } + onClick={handleShareInstagram} + label="Share on Instagram" + /> +
+
+
+
+
+ ) +} diff --git a/apps/web/components/text-morph.tsx b/apps/web/components/text-morph.tsx index 65dab8c7..e214e71f 100644 --- a/apps/web/components/text-morph.tsx +++ b/apps/web/components/text-morph.tsx @@ -55,7 +55,6 @@ export function TextMorph({ } return ( - // @ts-expect-error - style is optional {characters.map((character) => ( diff --git a/apps/web/package.json b/apps/web/package.json index aa37a4bd..19ba138a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -61,17 +61,20 @@ "@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", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", "flubber": "^0.4.2", + "html-to-image": "^1.11.13", "idb-keyval": "^6.2.2", "is-hotkey": "^0.2.0", "lucide-react": "^0.525.0", diff --git a/apps/web/public/icons/share-graph.svg b/apps/web/public/icons/share-graph.svg new file mode 100644 index 00000000..6b5bea6b --- /dev/null +++ b/apps/web/public/icons/share-graph.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/memory-graph/src/components/node-detail-panel.tsx b/packages/memory-graph/src/components/node-detail-panel.tsx index 578224b2..e3920be2 100644 --- a/packages/memory-graph/src/components/node-detail-panel.tsx +++ b/packages/memory-graph/src/components/node-detail-panel.tsx @@ -68,7 +68,6 @@ const getDocumentIcon = (type: string) => { return default: { - /*@ts-expect-error */ } return } @@ -109,7 +108,6 @@ export const NodeDetailPanel = memo(function NodeDetailPanel({ {isDocument ? ( getDocumentIcon((data as DocumentWithMemories).type ?? "") ) : ( - // @ts-expect-error )} {isDocument ? "Document" : "Memory"} diff --git a/packages/ui/components/badge.tsx b/packages/ui/components/badge.tsx index c5a03c29..49490bf9 100644 --- a/packages/ui/components/badge.tsx +++ b/packages/ui/components/badge.tsx @@ -31,10 +31,18 @@ function Badge({ ...props }: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { - const Comp = asChild ? Slot : "span"; + if (asChild) { + return ( + + ); + } return ( - & { asChild?: boolean; }) { - const Comp = asChild ? Slot : "a"; + if (asChild) { + return ( + + ); + } return ( - & { asChild?: boolean; }) { - const Comp = asChild ? Slot : "button"; + if (asChild) { + return ( + + ); + } return ( - & { asChild?: boolean }) { - const Comp = asChild ? Slot : "div"; + const classes = cn( + "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", + className, + ); + + if (asChild) { + return ( + + ); + } return ( - svg]:size-4 [&>svg]:shrink-0", - "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", - className, - )} +
& { asChild?: boolean }) { - const Comp = asChild ? Slot : "button"; + const classes = cn( + "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "after:absolute after:-inset-2 md:after:hidden", + "group-data-[collapsible=icon]:hidden", + className, + ); + + if (asChild) { + return ( + + ); + } return ( - svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 md:after:hidden", - "group-data-[collapsible=icon]:hidden", - className, - )} +