"use client" import { useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore, } from "react" import { AnimatePresence, motion } from "motion/react" import { useQueryState } from "nuqs" import { Header, PublicHeader } from "@/components/header" import { MobileBottomNav } from "@/components/bottom-nav" import { ChatSidebar, HomeChatComposer } from "@/components/chat" import type { ChatAttachmentDraft } from "@/components/chat/attachments" import { DashboardView } from "@/components/dashboard-view" import { BrainHomeView } from "@/components/brain-home/brain-home-view" import { CompanyBrainPromo } from "@/components/company-brain-promo" import { useHasCompanyBrain } from "@/hooks/use-company-brain" import { MemoriesGrid } from "@/components/memories-grid" import { GraphLayoutView } from "@/components/graph-layout-view" import { IntegrationsView, DetailWrapper } from "@/components/integrations-view" import { ConfigureView } from "@/components/configure-view" import { MCPDetailView } from "@/components/mcp-modal/mcp-detail-view" import { XBookmarksDetailView } from "@/components/onboarding/x-bookmarks-detail-view" import { ChromeDetail } from "@/components/integrations/chrome-detail" import { ShortcutsDetail } from "@/components/integrations/shortcuts-detail" import { RaycastDetail } from "@/components/integrations/raycast-detail" import { PluginsDetail } from "@/components/integrations/plugins-detail" import { AnimatedGradientBackground } from "@/components/animated-gradient-background" import { OnboardingConfetti } from "@/components/onboarding-brain/onboarding-confetti" import { SlackHandoff } from "@/components/onboarding-brain/slack-handoff" import { AddDocumentModal } from "@/components/add-document" import { DocumentModal } from "@/components/document-modal" import { DocumentsCommandPalette } from "@/components/documents-command-palette" import { FullscreenNoteModal } from "@/components/fullscreen-note-modal" import type { HighlightItem } from "@/components/highlights-card" import { DigestsView } from "@/components/digests-view" import { HotkeysProvider } from "react-hotkeys-hook" import { useHotkeys } from "react-hotkeys-hook" import { useIsMobile } from "@hooks/use-mobile" import { useAuth } from "@lib/auth-context" import { useProject } from "@/stores" import { useContainerTags } from "@/hooks/use-container-tags" import { DEFAULT_PROJECT_ID } from "@lib/constants" import { useQuickNoteDraftReset, useQuickNoteDraft, } from "@/stores/quick-note-draft" import { analytics } from "@/lib/analytics" import type { ModelId, ReasoningEffort } from "@/lib/models" import { useDocumentMutations } from "@/hooks/use-document-mutations" import { useQuery, useQueryClient } from "@tanstack/react-query" import { toast } from "sonner" import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api" import type { z } from "zod" import { useViewMode, useLegacyViewRedirect } from "@/lib/view-mode-context" import type { MemoryOfDay } from "@/components/dashboard-view" import { ErrorBoundary } from "@/components/error-boundary" import { cn } from "@lib/utils" import { addDocumentParam, searchParam, qParam, docParam, fullscreenParam, threadParam, type IntegrationParamValue, } from "@/lib/search-params" import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label" import { getToolDocumentSpace } from "@/lib/plugin-space" import { getBackendUrl } from "@/lib/url-helpers" type DocumentsResponse = z.infer type DocumentWithMemories = DocumentsResponse["documents"][0] function subscribeViewportWidth(cb: () => void) { window.addEventListener("resize", cb) return () => window.removeEventListener("resize", cb) } function getViewportWidth() { return window.innerWidth } const GRADIENT_TOP_WIDTH_MAX = 1440 function gradientTopPositionForWidth(width: number) { const minW = 320 const pctWide = 15 const pctNarrow = 55 const w = Math.min(GRADIENT_TOP_WIDTH_MAX, Math.max(minW, width)) const t = (w - minW) / (GRADIENT_TOP_WIDTH_MAX - minW) const eased = t * t return `${Math.round(pctNarrow + eased * (pctWide - pctNarrow))}%` } function ViewErrorFallback() { return (

Something went wrong.{" "}

) } export function AppExperience() { const isMobile = useIsMobile() const { user, session, isSessionPending, org } = useAuth() const { selectedProject, selectedProjects, setSelectedProject } = useProject() const selectedProjectTag = selectedProjects[0] const { allProjects } = useContainerTags() const dashboardSpaceLabel = useMemo( () => getChatSpaceDisplayLabel({ selectedProject, allProjects, }), [selectedProject, allProjects], ) const emptyStateSpaceName = selectedProjectTag ? selectedProjectTag === DEFAULT_PROJECT_ID ? "My Space" : (allProjects.find((p) => p.containerTag === selectedProjectTag)?.name ?? selectedProjectTag) : undefined const { viewMode, setViewMode } = useViewMode() useLegacyViewRedirect() const isCompanyBrain = useHasCompanyBrain() const backendUrl = getBackendUrl() // ?slack=connected: CB orgs get the handoff takeover, everyone else a toast. const [slackHandoff, setSlackHandoff] = useState<{ team: string | null } | null>(null) useEffect(() => { const sp = new URLSearchParams(window.location.search) const slackParam = sp.get("slack") if (slackParam !== "connected" && slackParam !== "error") return const team = sp.get("team") if (slackParam === "error") { const reason = sp.get("reason") const org = sp.get("linked_org") const isOrgMember = sp.get("linked_member") === "1" const conflictMessage = org && isOrgMember ? `That Slack workspace is already connected to your "${org}" organization. Switch to it to manage the connection.` : org ? `That Slack workspace is already connected to another Supermemory organization (${org}). Ask the teammate who set it up.` : "That Slack workspace is already connected to a different Supermemory organization. Disconnect it there first, or switch to that organization." toast.error( reason === "workspace_conflict" ? conflictMessage : reason === "expired" ? "That Slack connect link expired. Try connecting again." : "Slack connection failed. Try again.", { duration: 10000 }, ) } else if (isCompanyBrain) { setSlackHandoff({ team }) } else { toast.success( team ? `Supermemory added to ${team} on Slack` : "Supermemory added to your Slack", ) } sp.delete("slack") sp.delete("team") sp.delete("reason") sp.delete("linked_org") sp.delete("linked_member") const qs = sp.toString() window.history.replaceState( null, "", window.location.pathname + (qs ? `?${qs}` : ""), ) }, [isCompanyBrain]) const queryClient = useQueryClient() const [highlightsForceAt, setHighlightsForceAt] = useState(0) // Chrome extension auth: send session token via postMessage so the content script can store it useEffect(() => { const url = new URL(window.location.href) if (!url.searchParams.get("extension-auth-success")) return const sessionToken = session?.token const userData = { email: user?.email, name: user?.name, userId: user?.id } if (sessionToken && userData.email) { window.postMessage( { token: encodeURIComponent(sessionToken), userData }, window.location.origin, ) url.searchParams.delete("extension-auth-success") window.history.replaceState({}, "", url.toString()) } }, [user, session]) // URL-driven modal states const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam) const [isSearchOpen, setIsSearchOpen] = useQueryState("search", searchParam) const [searchPrefill, setSearchPrefill] = useQueryState("q", qParam) const [docId, setDocId] = useQueryState("doc", docParam) const [isFullscreen, setIsFullscreen] = useQueryState( "fullscreen", fullscreenParam, ) const [, setThreadIdUrl] = useQueryState("thread", threadParam) // Ephemeral local state (not worth URL-encoding) const [fullscreenInitialContent, setFullscreenInitialContent] = useState("") const [queuedChatSeed, setQueuedChatSeed] = useState(null) const [queuedChatModel, setQueuedChatModel] = useState(null) const [queuedChatReasoningEffort, setQueuedChatReasoningEffort] = useState(null) const [queuedChatProject, setQueuedChatProject] = useState( null, ) const [queuedChatAttachments, setQueuedChatAttachments] = useState< ChatAttachmentDraft[] | null >(null) const [queuedHighlightContent, setQueuedHighlightContent] = useState< string | null >(null) const [queuedMessageSource, setQueuedMessageSource] = useState< "highlight" | "home" >("highlight") const [selectedDocument, setSelectedDocument] = useState(null) // Clear document when docId is removed (e.g. back button) useEffect(() => { if (!docId) setSelectedDocument(null) }, [docId]) useEffect(() => { if (viewMode === "dashboard") void setThreadIdUrl(null) }, [viewMode, setThreadIdUrl]) // Resolve document from cache when loading with ?doc= (deep link / refresh) useEffect(() => { if (!docId || selectedDocument) return const tryResolve = () => { const queries = queryClient.getQueriesData<{ pages: DocumentsResponse[] }>({ queryKey: ["documents-with-memories"] }) for (const [, data] of queries) { if (!data?.pages) continue for (const page of data.pages) { const doc = page.documents?.find((d) => d.id === docId) if (doc) { setSelectedDocument(doc) return true } } } return false } if (tryResolve()) return const unsubscribe = queryClient.getQueryCache().subscribe(() => { if (tryResolve()) unsubscribe() }) return unsubscribe }, [docId, selectedDocument, queryClient]) const resetDraft = useQuickNoteDraftReset(selectedProject) const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "") const quickNoteDraftRef = useRef(quickNoteDraft) quickNoteDraftRef.current = quickNoteDraft const { noteMutation, bulkDeleteMutation } = useDocumentMutations({ onClose: () => { resetDraft() setIsFullscreen(false) }, }) const [selectedDocumentIds, setSelectedDocumentIds] = useState>( new Set(), ) const [isSelectionMode, setIsSelectionMode] = useState(false) const handleToggleSelection = useCallback((documentId: string) => { setSelectedDocumentIds((prev) => { const next = new Set(prev) if (next.has(documentId)) { next.delete(documentId) } else { next.add(documentId) } return next }) }, []) const handleClearSelection = useCallback(() => { setSelectedDocumentIds(new Set()) setIsSelectionMode(false) }, []) const handleEnterSelectionMode = useCallback(() => { setIsSelectionMode(true) }, []) const handleSelectAllVisible = useCallback((visibleIds: string[]) => { setSelectedDocumentIds((prev) => { const next = new Set(prev) for (const id of visibleIds) { next.add(id) } return next }) }, []) const handleBulkDelete = useCallback(() => { const ids = Array.from(selectedDocumentIds) if (ids.length === 0) return bulkDeleteMutation.mutate( { documentIds: ids }, { onSuccess: () => { setSelectedDocumentIds(new Set()) setIsSelectionMode(false) if (selectedDocument && ids.includes(selectedDocument.id ?? "")) { setDocId(null) } }, }, ) }, [selectedDocumentIds, bulkDeleteMutation, selectedDocument, setDocId]) 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 handleResetHighlights = useCallback(async () => { toast.success("Refreshing daily brief…") try { await caches.delete(HIGHLIGHTS_CACHE_NAME) } catch {} setHighlightsForceAt(Date.now()) }, []) const { data: highlightsData, isLoading: isLoadingHighlights } = useQuery({ queryKey: ["space-highlights", selectedProject, highlightsForceAt], queryFn: async (): Promise => { const spaceId = selectedProject || "sm_project_default" const forceRefresh = highlightsForceAt > 0 const cacheKey = `${backendUrl}/v3/space-highlights?spaceId=${spaceId}` if (!forceRefresh) { 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(`${backendUrl}/v3/space-highlights`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ spaceId, highlightsCount: 3, questionsCount: 4, includeHighlights: true, includeQuestions: true, forceRefresh, }), }) if (!response.ok) { throw new Error("Failed to fetch space highlights") } const data = await response.json() // Update browser cache with fresh data (works for both normal and forced refresh) try { const freshCache = await caches.open(HIGHLIGHTS_CACHE_NAME) const cacheResponse = new Response(JSON.stringify(data), { headers: { "Content-Type": "application/json", "x-cached-at": String(Date.now()), }, }) await freshCache.put(cacheKey, cacheResponse) } catch {} // Reset force flag after the forced fetch completes so future project-switches // use the normal cache path instead of always bypassing it. if (forceRefresh) setHighlightsForceAt(0) return data }, staleTime: HIGHLIGHTS_MAX_AGE, refetchOnWindowFocus: false, }) const { data: memoryOfDay = null } = useQuery({ queryKey: [ "memory-of-day", user?.id, org?.id, new Date().toISOString().slice(0, 10), ], queryFn: async (): Promise => { const cacheKey = `memory-of-day:v2:${user?.id}:${org?.id}:${new Date().toISOString().slice(0, 10)}` try { const stored = localStorage.getItem(cacheKey) if (stored) return JSON.parse(stored) as MemoryOfDay } catch {} const response = await fetch(`${backendUrl}/v3/memory-of-day`, { credentials: "include", }) if (!response.ok) return null const data = (await response.json()) as MemoryOfDay | null if (data) { try { localStorage.setItem(cacheKey, JSON.stringify(data)) } catch {} } return data }, staleTime: 24 * 60 * 60 * 1000, refetchOnWindowFocus: false, enabled: !!user && !!org, }) useHotkeys("c", () => { analytics.addDocumentModalOpened() setAddDoc("note") }) useHotkeys("mod+k", (e) => { e.preventDefault() analytics.searchOpened({ source: "hotkey" }) setIsSearchOpen(true) }) const handleOpenDocument = useCallback( (document: DocumentWithMemories) => { if (document.id) { analytics.documentModalOpened({ document_id: document.id }) setSelectedDocument(document) setDocId(document.id) } }, [setDocId], ) const handleOpenToolDocument = useCallback( (document: DocumentWithMemories, pluginClientId: string) => { const documentSpace = getToolDocumentSpace(document, pluginClientId) if (documentSpace) { setSelectedProject(documentSpace) } handleOpenDocument(document) void setViewMode("list") }, [handleOpenDocument, setSelectedProject, setViewMode], ) // Separate from handleOpenDocument because the graph view only has a document ID, // not the full document object. The modal will fetch the document via the docId // query param, so there may be a brief loading state (unlike handleOpenDocument // which pre-populates via setSelectedDocument). const handleOpenDocumentById = useCallback( (documentId: string) => { analytics.documentModalOpened({ document_id: documentId }) setDocId(documentId) }, [setDocId], ) const handleQuickNoteSave = useCallback( (content: string) => { if (content.trim()) { const hadPreviousContent = quickNoteDraftRef.current.trim().length > 0 noteMutation.mutate( { content, project: selectedProject }, { onSuccess: () => { if (hadPreviousContent) { analytics.quickNoteEdited() } else { analytics.quickNoteCreated() } }, }, ) } }, [selectedProject, noteMutation], ) const handleFullScreenSave = useCallback( (content: string) => { if (content.trim()) { const hadInitialContent = fullscreenInitialContent.trim().length > 0 noteMutation.mutate( { content, project: selectedProject }, { onSuccess: () => { if (hadInitialContent) { analytics.quickNoteEdited() } else { analytics.quickNoteCreated() } }, }, ) } }, [selectedProject, noteMutation, fullscreenInitialContent], ) const handleMaximize = useCallback( (content: string) => { analytics.fullscreenNoteModalOpened() setFullscreenInitialContent(content) setIsFullscreen(true) }, [setIsFullscreen], ) const handleHighlightsChat = useCallback( (highlightContent: string, userReply: string) => { setQueuedHighlightContent(highlightContent) setQueuedChatSeed(userReply) setQueuedChatModel(null) setQueuedChatReasoningEffort(null) setQueuedChatProject(null) setQueuedChatAttachments(null) setQueuedMessageSource("highlight") void setViewMode("chat") }, [setViewMode], ) const handleHomeChatStart = useCallback( ( message: string, model: ModelId, projectId: string, reasoningEffort: ReasoningEffort, attachments?: ChatAttachmentDraft[], ) => { setQueuedHighlightContent(null) setQueuedChatSeed(message) setQueuedChatModel(model) setQueuedChatReasoningEffort(reasoningEffort) setQueuedChatProject(projectId) setQueuedChatAttachments(attachments ?? null) setQueuedMessageSource("home") void setViewMode("chat") }, [setViewMode], ) const consumeQueuedChat = useCallback(() => { setQueuedChatSeed(null) setQueuedChatModel(null) setQueuedChatReasoningEffort(null) setQueuedChatProject(null) setQueuedChatAttachments(null) setQueuedHighlightContent(null) setQueuedMessageSource("highlight") }, []) const handleHighlightsShowRelated = useCallback( (query: string) => { analytics.searchOpened({ source: "highlight_related" }) setSearchPrefill(query) setIsSearchOpen(true) }, [setSearchPrefill, setIsSearchOpen], ) const handleOpenIntegrations = useCallback( (integration?: IntegrationParamValue) => { if (integration === "notion" || integration === "google-drive") { void setAddDoc("connect") return } void setViewMode(integration ?? "integrations") }, [setViewMode, setAddDoc], ) const handleOpenPlugins = useCallback(() => { void setViewMode("plugins") }, [setViewMode]) const handleAddMemory = useCallback( (tab: "note" | "link") => { analytics.addDocumentModalOpened() setAddDoc(tab) }, [setAddDoc], ) const viewportWidth = useSyncExternalStore( subscribeViewportWidth, getViewportWidth, () => GRADIENT_TOP_WIDTH_MAX, ) const gradientTopPosition = gradientTopPositionForWidth(viewportWidth) const isChatView = viewMode === "chat" const showNovaBackdrop = viewMode === "graph" || viewMode === "list" || viewMode === "dashboard" || viewMode === "digests" const isDashboardShell = viewMode === "dashboard" || (viewMode === "graph" && isMobile) const isGraphMode = viewMode === "graph" const showBottomNav = isMobile && !!session && !isChatView const isPublicIntegrations = !session && !isSessionPending && viewMode === "integrations" return ( {slackHandoff && ( setSlackHandoff(null)} /> )}
{showNovaBackdrop && (
)} {isPublicIntegrations ? ( ) : !session && viewMode === "mcp" ? ( ) : (
{ analytics.addDocumentModalOpened() setAddDoc("note") }} onOpenSearch={() => { analytics.searchOpened({ source: "header" }) setIsSearchOpen(true) }} /> )}
}> {isChatView ? (
{ if (!open) void setViewMode("dashboard") }} queuedMessage={queuedChatSeed} queuedHighlightContent={queuedHighlightContent} onConsumeQueuedMessage={consumeQueuedChat} queuedMessageSource={queuedMessageSource} queuedAttachments={queuedChatAttachments} initialSelectedModel={queuedChatModel} initialReasoningEffort={queuedChatReasoningEffort} initialChatProject={queuedChatProject} />
) : viewMode === "integrations" ? (
) : viewMode === "configure" ? (
) : viewMode === "mcp" ? ( void setViewMode("integrations")} /> ) : viewMode === "plugins" ? ( void setViewMode("integrations")} > ) : viewMode === "chrome" ? ( void setViewMode("integrations")} > ) : viewMode === "shortcuts" ? ( void setViewMode("integrations")} > ) : viewMode === "raycast" ? ( void setViewMode("integrations")} > ) : viewMode === "import" ? ( void setViewMode("integrations")} /> ) : viewMode === "digests" ? (
) : viewMode === "graph" ? (
) : viewMode === "list" ? (
) : isCompanyBrain ? (
) : ( } highlights={highlightsData?.highlights ?? []} isLoadingHighlights={isLoadingHighlights} onAddMemory={handleAddMemory} onOpenSearch={() => { analytics.searchOpened({ source: "header" }) setIsSearchOpen(true) }} onOpenIntegrations={handleOpenIntegrations} onOpenPlugins={handleOpenPlugins} onNavigateToMemories={() => void setViewMode("list")} onNavigateToGraph={() => void setViewMode("graph")} onOpenDocument={handleOpenDocument} onOpenToolDocument={handleOpenToolDocument} onHighlightsChat={handleHighlightsChat} onHighlightsShowRelated={handleHighlightsShowRelated} onResetHighlights={handleResetHighlights} onOpenDigests={() => void setViewMode("digests")} memoryOfDay={memoryOfDay} /> )}
{isDashboardShell && showBottomNav && (
)} {isDashboardShell && !isCompanyBrain && (
)} {showBottomNav && ( { analytics.addDocumentModalOpened() setAddDoc("note") }} onOpenSearch={() => { analytics.searchOpened({ source: "header" }) setIsSearchOpen(true) }} /> )} setAddDoc(null)} /> { setIsSearchOpen(open) if (!open) setSearchPrefill("") }} projectId={selectedProject} onOpenDocument={handleOpenDocument} onAddMemory={() => { analytics.addDocumentModalOpened() setAddDoc("note") }} onOpenIntegrations={() => setViewMode("integrations")} initialSearch={searchPrefill} /> setDocId(null)} /> setIsFullscreen(false)} initialContent={fullscreenInitialContent} onSave={handleFullScreenSave} isSaving={noteMutation.isPending} />
) }