"use client" import { useAuth } from "@lib/auth-context" import { $fetch } from "@lib/api" import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" import { useInfiniteQuery, useQuery } from "@tanstack/react-query" import { useCallback, memo, useMemo, useState, useRef, useEffect } from "react" import { useQueryState } from "nuqs" import { AnimatePresence } from "motion/react" import type { z } from "zod" import { Masonry, useInfiniteLoader } from "masonic" import { dmSansClassName } from "@/lib/fonts" import { ErrorBoundary } from "@/components/error-boundary" import { cn } from "@lib/utils" import { useProject } from "@/stores" import { useIsMobile } from "@hooks/use-mobile" import type { Tweet } from "react-tweet/api" import { TweetPreview } from "./document-cards/tweet-preview" import { WebsitePreview } from "./document-cards/website-preview" import { GoogleDocsPreview } from "./document-cards/google-docs-preview" import { FilePreview } from "./document-cards/file-preview" import { NotePreview } from "./document-cards/note-preview" import { claudeCodeTokenBadge, parsePluginDocument, type ParsedPluginDocument, } from "@/lib/plugin-document" 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, isSupermemoryFileUrl } from "@/lib/url-helpers" import { QuickNoteCard } from "./quick-note-card" import type { HighlightItem } from "./highlights-card" import { Button } from "@ui/components/button" import { ToggleGroup, ToggleGroupItem } from "@ui/components/toggle-group" import { agentSourceParam, categoriesParam, type IntegrationParamValue, } from "@/lib/search-params" import { AGENT_SOURCE_FILTERS, agentSourceValues, isAgentsSelection, type AgentSourceFilter, } from "@/lib/agent-space" import { NovaEmptyState } from "@/components/nova/nova-empty-state" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@ui/components/alert-dialog" import { AlignLeft, BoxSelect, CheckIcon, LayoutGrid, Loader, MoreHorizontal, Trash2Icon, UserRound, XIcon, } from "lucide-react" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@ui/components/dropdown-menu" import { useProcessingDocuments } from "@/hooks/use-processing-documents" import { TimelineView } from "./timeline-view" import { SpaceProfilePanel } from "@/components/space-profile-panel" import { SpaceProfileModal } from "@/components/space-profile-modal" // Document category type type DocumentCategory = | "webpage" | "tweet" | "google_drive" | "notion" | "onedrive" | "files" | "notes" | "mcp" type DocumentFacet = { category: DocumentCategory count: number label: string } type FacetsResponse = { facets: DocumentFacet[] total: number } type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] type OgData = { title?: string image?: string } const EXTENSION_PLATFORM_LABELS: Record = { chatgpt: "ChatGPT", claude: "Claude", gemini: "Gemini", t3: "T3 Chat", twitter: "X / Twitter", } function getExtensionSourceLabel( document: DocumentWithMemories, ): string | null { const metadata = document.metadata if (!metadata || typeof metadata !== "object") return null const label = metadata.sm_origin_platform_label if (typeof label === "string" && label.trim()) { return label.trim() } const platform = metadata.sm_origin_platform if (typeof platform === "string" && platform.trim()) { const normalized = platform.trim().toLowerCase() return EXTENSION_PLATFORM_LABELS[normalized] || platform.trim() } return null } const ogCache = new Map() const ogInflight = new Map>() const ogFailures = new Map() const OG_FAILURE_TTL = 30_000 function fetchOgData(url: string): Promise { const cached = ogCache.get(url) if (cached) return Promise.resolve(cached) const failedAt = ogFailures.get(url) if (failedAt && Date.now() - failedAt < OG_FAILURE_TTL) { return Promise.resolve(null) } const inflight = ogInflight.get(url) if (inflight) return inflight const promise = fetch(`/api/og?url=${encodeURIComponent(url)}`) .then((res) => { if (!res.ok) throw new Error("Failed") return res.json() }) .then((data) => { const result: OgData = { title: data?.title, image: data?.image } if (!result.title && !result.image) { throw new Error("Empty metadata") } ogCache.set(url, result) ogInflight.delete(url) ogFailures.delete(url) return result }) .catch(() => { ogInflight.delete(url) ogFailures.set(url, Date.now()) return null }) ogInflight.set(url, promise) return promise } const PAGE_SIZE = 100 const MAX_TOTAL = 1000 const EMPTY_SET = new Set() const MEMORIES_LOADING_LABELS = [ "Getting your supermemories…", "Fetching your documents…", "Warming up Nova…", "Almost there…", ] as const function useRotatingLoadingLabel( labels: readonly string[], intervalMs = 2400, ): string { const [index, setIndex] = useState(0) useEffect(() => { const id = window.setInterval(() => { setIndex((i) => (i + 1) % labels.length) }, intervalMs) return () => window.clearInterval(id) }, [labels.length, intervalMs]) const label = labels.at(index) ?? labels.at(0) return label ?? "Loading…" } function MemoriesGridLoading() { const label = useRotatingLoadingLabel(MEMORIES_LOADING_LABELS) return (

{label}

) } // Discriminated union for masonry items type MasonryItem = | { type: "document" id: string data: DocumentWithMemories isSelectionMode: boolean isSelected: boolean } | { type: "quick-note"; id: "quick-note" } interface QuickNoteProps { onSave: (content: string) => void onMaximize: (content: string) => void isSaving: boolean } interface HighlightsProps { items: HighlightItem[] onChat: (highlightContent: string, userReply: string) => void onShowRelated: (query: string) => void isLoading: boolean } interface NovaEmptyStateProps { onAddMemory: (tab: "note" | "link") => void onOpenIntegrations: (integration?: IntegrationParamValue) => void isAllSpaces: boolean spaceName?: string onSwitchToAllSpaces?: () => void } interface MemoriesGridProps { isChatOpen: boolean onOpenDocument: (document: DocumentWithMemories) => void isSelectionMode?: boolean selectedDocumentIds?: Set onEnterSelectionMode?: () => void onToggleSelection?: (documentId: string) => void onClearSelection?: () => void onSelectAllVisible?: (visibleIds: string[]) => void onBulkDelete?: () => void isBulkDeleting?: boolean quickNoteProps?: QuickNoteProps highlightsProps?: HighlightsProps emptyStateProps?: NovaEmptyStateProps } export function MemoriesGrid({ isChatOpen, onOpenDocument, isSelectionMode = false, selectedDocumentIds = EMPTY_SET, onEnterSelectionMode, onToggleSelection, onClearSelection, onSelectAllVisible, onBulkDelete, isBulkDeleting = false, quickNoteProps, highlightsProps, emptyStateProps, }: MemoriesGridProps) { const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false) const [profileOpen, setProfileOpen] = useState(false) const [localViewMode, setLocalViewMode] = useState<"grid" | "timeline">( () => { if (typeof window === "undefined") return "grid" return ( (localStorage.getItem("memories-view-mode") as "grid" | "timeline") ?? "grid" ) }, ) const { user, isSessionPending } = useAuth() const { effectiveContainerTags, selectedProject } = useProject() const profileContainerTag = selectedProject ?? effectiveContainerTags[0] ?? "" const processingStatusMap = useProcessingDocuments() const isMobile = useIsMobile() const [selectedCategories, setSelectedCategories] = useQueryState( "categories", categoriesParam, ) const [selectedAgentSource, setSelectedAgentSource] = useQueryState( "agent", agentSourceParam, ) const selectedCategoriesSet = useMemo( () => new Set(selectedCategories), [selectedCategories], ) const showAgentFilters = useMemo( () => isAgentsSelection(effectiveContainerTags), [effectiveContainerTags], ) const selectedSources = useMemo( () => showAgentFilters ? agentSourceValues(selectedAgentSource) : undefined, [showAgentFilters, selectedAgentSource], ) const { data: facetsData } = useQuery({ queryKey: ["document-facets", effectiveContainerTags], queryFn: async (): Promise => { const response = await $fetch("@post/documents/documents/facets", { body: { containerTags: effectiveContainerTags, }, disableValidation: true, }) if (response.error) { throw new Error(response.error?.message || "Failed to fetch facets") } return response.data as FacetsResponse }, staleTime: 5 * 60 * 1000, enabled: !!user, }) const { data: agentSourceCounts } = useQuery({ queryKey: ["agent-source-counts", effectiveContainerTags], queryFn: async (): Promise>> => { const entries = await Promise.all( AGENT_SOURCE_FILTERS.map(async (filter) => { const response = await $fetch("@post/documents/documents", { body: { page: 1, limit: 1, sort: "createdAt", order: "desc", containerTags: effectiveContainerTags, sources: [...filter.sources], }, disableValidation: true, }) if (response.error) { throw new Error( response.error?.message || "Failed to fetch agent source count", ) } const result = response.data as { pagination?: { totalItems?: number } } | null return [filter.value, result?.pagination?.totalItems ?? 0] as const }), ) return Object.fromEntries(entries) }, staleTime: 5 * 60 * 1000, enabled: !!user && showAgentFilters, }) useEffect(() => { if (!selectedAgentSource || !agentSourceCounts) return if ((agentSourceCounts[selectedAgentSource] ?? 0) === 0) { void setSelectedAgentSource(null) } }, [agentSourceCounts, selectedAgentSource, setSelectedAgentSource]) const { data, error, isPending, isFetchingNextPage, hasNextPage, fetchNextPage, } = useInfiniteQuery({ queryKey: [ "documents-with-memories", effectiveContainerTags, selectedCategories, selectedSources, ], initialPageParam: 1, queryFn: async ({ pageParam }) => { const response = await $fetch("@post/documents/documents", { body: { page: pageParam as number, limit: PAGE_SIZE, sort: "createdAt", order: "desc", containerTags: effectiveContainerTags, categories: selectedCategories.length > 0 ? selectedCategories : undefined, sources: selectedSources, }, disableValidation: true, }) if (response.error) { throw new Error(response.error?.message || "Failed to fetch documents") } return response.data }, getNextPageParam: (lastPage, allPages) => { 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, }) const handleSetViewMode = useCallback((mode: "grid" | "timeline") => { setLocalViewMode(mode) localStorage.setItem("memories-view-mode", mode) }, []) const handleToggleProfile = useCallback(() => { if (isMobile) { setProfileOpen(true) return } setProfileOpen((open) => !open) }, [isMobile]) const handleCategoryToggle = useCallback( (category: DocumentCategory) => { setSelectedCategories((prev) => { const current = prev ?? [] if (current.includes(category)) { const next = current.filter((c) => c !== category) return next.length === 0 ? null : next } return [...current, category] }) }, [setSelectedCategories], ) const handleSelectAll = useCallback(() => { setSelectedCategories(null) setSelectedAgentSource(null) }, [setSelectedCategories, setSelectedAgentSource]) const documents = useMemo(() => { return ( data?.pages.flatMap((p: DocumentsResponse) => p.documents ?? []) ?? [] ) }, [data]) const hasQuickNote = !!quickNoteProps const _hasHighlights = !!highlightsProps const masonryItems: MasonryItem[] = useMemo(() => { const items: MasonryItem[] = [] if (!isMobile && hasQuickNote) { items.push({ type: "quick-note", id: "quick-note" }) } for (const doc of documents) { items.push({ type: "document", id: doc.id, data: doc, isSelectionMode, isSelected: doc.id ? selectedDocumentIds.has(doc.id) : false, }) } return items }, [documents, isMobile, hasQuickNote, isSelectionMode, selectedDocumentIds]) // Reset Masonry when the actual rendered item set changes. Masonic caches // positions by index, so mobile removing the quick note must remount it. const masonryKey = useMemo(() => { const itemIds = masonryItems.map((item) => item.id).join(",") return `masonry-${isMobile ? "mobile" : "desktop"}-${masonryItems.length}-${itemIds}-${isChatOpen}` }, [masonryItems, isChatOpen, isMobile]) const getMasonryItemKey = useCallback((item: MasonryItem) => item.id, []) const isLoadingMore = isFetchingNextPage const loadMoreDocuments = useCallback(async (): Promise => { if (hasNextPage && !isFetchingNextPage) { await fetchNextPage() return } return }, [hasNextPage, isFetchingNextPage, fetchNextPage]) const maybeLoadMore = useInfiniteLoader( async (_startIndex, _stopIndex, _currentItems) => { if (hasNextPage && !isFetchingNextPage) { await loadMoreDocuments() } }, { isItemLoaded: (index, items) => !!items[index], minimumBatchSize: 10, threshold: 5, }, ) const handleCardClick = useCallback( (document: DocumentWithMemories) => { if (isSelectionMode && onToggleSelection && document.id) { onToggleSelection(document.id) } else { onOpenDocument(document) } }, [isSelectionMode, onToggleSelection, onOpenDocument], ) const handleSelectAllVisible = useCallback(() => { if (onSelectAllVisible) { onSelectAllVisible(documents.map((d) => d.id).filter(Boolean) as string[]) } }, [documents, onSelectAllVisible]) const handleBulkDeleteClick = useCallback(() => { if (selectedDocumentIds.size === 0) return setShowBulkDeleteConfirm(true) }, [selectedDocumentIds.size]) const handleBulkDeleteConfirm = useCallback(() => { setShowBulkDeleteConfirm(false) onBulkDelete?.() }, [onBulkDelete]) // All mutable values the render function needs — kept in a ref so the // function identity never changes (masonic uses render as a React component // type, so a new reference unmounts every item and kills textarea focus). const renderRef = useRef({ quickNoteProps, handleCardClick, onToggleSelection, processingStatusMap, }) renderRef.current = { quickNoteProps, handleCardClick, onToggleSelection, processingStatusMap, } const renderMasonryItem = useCallback( ({ index, data, width, }: { index: number data: MasonryItem width: number }) => { const r = renderRef.current if (data.type === "quick-note") { return r.quickNoteProps ? (
) : null } if (data.type === "document") { const doc = data.data return ( r.onToggleSelection?.(doc.id as string) : undefined } processingStatus={ doc.id ? r.processingStatusMap.get(doc.id) : undefined } /> ) } return null }, // eslint-disable-next-line react-hooks/exhaustive-deps [], ) if (isSessionPending) { return } if (!user) { return (

Please log in to view your memories

) } const isEmpty = documents.length === 0 && !isPending const showNovaEmptyState = isEmpty && emptyStateProps const allVisibleSelected = documents.length > 0 && documents.every((d) => d.id && selectedDocumentIds.has(d.id)) return (
{(!isEmpty || (facetsData?.total ?? 0) > 0) && !isSelectionMode && (
{facetsData?.facets.map((facet: DocumentFacet) => ( ))} {showAgentFilters && ( setSelectedAgentSource( value ? (value as AgentSourceFilter) : null, ) } aria-label="Filter memories by agent" className="gap-1.5" > {AGENT_SOURCE_FILTERS.map((filter) => { const count = agentSourceCounts?.[filter.value] if (!count) return null return ( {filter.label} ({count}) ) })} )}
{/* View mode toggle — segmented control */}
{onEnterSelectionMode && ( Select memories )} Space profile
)} {!isEmpty && isSelectionMode && (
{selectedDocumentIds.size} {selectedDocumentIds.size === 1 ? "selected" : "selected"} {selectedDocumentIds.size === 0 && ( Tap documents to select )}
)} Delete selected memories? This will permanently delete {selectedDocumentIds.size}{" "} {selectedDocumentIds.size === 1 ? "memory" : "memories"}. This action cannot be undone. setShowBulkDeleteConfirm(false)} > Cancel {isBulkDeleting ? "Deleting…" : "Delete"}
{error ? (
Error loading documents: {error.message}
) : isPending ? ( ) : showNovaEmptyState ? ( ) : isEmpty ? (
No memories found
) : (
{localViewMode === "timeline" ? ( ) : ( )} {isLoadingMore && localViewMode === "grid" && (
)}
{profileOpen && !isMobile && ( setProfileOpen(false)} /> )}
)}
) } function DocumentUrlDisplay({ url }: { url: string }) { const isYouTube = isYouTubeUrl(url) const { data: channelName, isLoading } = useYouTubeChannelName( isYouTube ? url : null, ) if (isYouTube) { return (

{isLoading ? "YouTube" : channelName || "YouTube"}

) } return (

{getAbsoluteUrl(url)}

) } function isTemporaryId(id: string | null | undefined): boolean { if (!id) return false return id.startsWith("temp-") || id.startsWith("temp-file-") } const PROCESSING_WORDS = [ "Reading", "Absorbing", "Scanning", "Thinking", "Connecting", "Pondering", "Synthesizing", "Reflecting", "Understanding", "Organizing", "Memorizing", "Filing", "Saving", "Learning", "Cataloguing", "Weaving", ] function ProcessingBadge() { const [wordIndex, setWordIndex] = useState(() => Math.floor(Math.random() * PROCESSING_WORDS.length), ) useEffect(() => { const id = setInterval(() => { setWordIndex((i) => (i + 1) % PROCESSING_WORDS.length) }, 1800) return () => clearInterval(id) }, []) return (
{PROCESSING_WORDS[wordIndex]}
) } function DoneBadge() { return (
Done
) } const DocumentCard = memo( ({ index: _index, data: document, width, onClick, isSelectionMode = false, isSelected = false, onToggleSelection, processingStatus, }: { index: number data: DocumentWithMemories width: number onClick: (document: DocumentWithMemories) => void isSelectionMode?: boolean isSelected?: boolean onToggleSelection?: () => void processingStatus?: string }) => { const canSelect = !isTemporaryId(document.id) && !isTemporaryId(document.customId) const pluginDocument = useMemo( () => parsePluginDocument(document), [document], ) const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 }) const cardRef = useRef(null) const [ogData, setOgData] = useState(null) const [showDone, setShowDone] = useState(false) const prevStatusRef = useRef(processingStatus) useEffect(() => { const prev = prevStatusRef.current prevStatusRef.current = processingStatus // Show the "done" checkmark briefly when the card leaves the processing map if (prev && !processingStatus) { setShowDone(true) const id = setTimeout(() => setShowDone(false), 2000) return () => clearTimeout(id) } }, [processingStatus]) const ogImage = (document as DocumentWithMemories & { ogImage?: string }) .ogImage const needsOgData = document.url && document.type !== "notion_doc" && !document.url.includes("x.com") && !document.url.includes("twitter.com") && !isSupermemoryFileUrl(document.url) && !document.url.includes("docs.googleapis.com") && !document.url.includes("notion.so") && (!document.title || !ogImage) const hideURL = document.url?.includes("docs.googleapis.com") useEffect(() => { if (!needsOgData || ogData || !document.url) return let timeoutId: ReturnType let mounted = true const attemptFetch = () => { if (!mounted || !document.url) return fetchOgData(document.url).then((data) => { if (!mounted) return if (data) { setOgData(data) } else { // Retry when the global TTL expires timeoutId = setTimeout(attemptFetch, 30_000) } }) } attemptFetch() return () => { mounted = false clearTimeout(timeoutId) } }, [needsOgData, ogData, document.url]) useEffect(() => { if (isSelectionMode) setRotation({ rotateX: 0, rotateY: 0 }) }, [isSelectionMode]) const handleMouseMove = (e: React.MouseEvent) => { if (isSelectionMode || !cardRef.current) return const rect = cardRef.current.getBoundingClientRect() const centerX = rect.left + rect.width / 2 const centerY = rect.top + rect.height / 2 const mouseX = e.clientX - centerX const mouseY = e.clientY - centerY // Calculate rotation angles (max 15 degrees) const rotateY = (mouseX / (rect.width / 2)) * 15 const rotateX = -(mouseY / (rect.height / 2)) * 15 setRotation({ rotateX, rotateY }) } const handleMouseLeave = () => { setRotation({ rotateX: 0, rotateY: 0 }) } return (
{isSelectionMode && canSelect && ( )}
) }, ) DocumentCard.displayName = "DocumentCard" function ContentPreview({ document, ogData, parsed, }: { document: DocumentWithMemories ogData?: OgData | null parsed?: ParsedPluginDocument | null }) { if ( document.url?.includes("https://docs.googleapis.com/v1/documents") || document.url?.includes("docs.google.com/document") || document.type === "google_doc" ) { return } if (document.metadata?.sm_internal_twitter_metadata) { return ( ) } if ( document.url?.includes("x.com/") || document.url?.includes("twitter.com/") ) { return } if (document.source === "mcp") { return } if (isYouTubeUrl(document.url)) { return } if (document.type === "notion_doc") { return } if ( document.type === "pdf" || document.type === "image" || document.type === "video" || document.metadata?.mimeType ) { return } if (document.url?.includes("https://")) { return } // Default to Note return }