mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-06 08:16:03 +00:00
Removes stale promo cards, banners, and the setup modal. Simplifies the onboarding entry so all new workspaces go through one flow.
922 lines
29 KiB
TypeScript
922 lines
29 KiB
TypeScript
"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 { 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 { isConnectorPaused } from "@/lib/connector-availability"
|
|
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<typeof DocumentsWithMemoriesResponseSchema>
|
|
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 (
|
|
<div className="flex-1 flex items-center justify-center p-8">
|
|
<p className="text-muted-foreground">
|
|
Something went wrong.{" "}
|
|
<button
|
|
type="button"
|
|
className="underline cursor-pointer"
|
|
onClick={() => window.location.reload()}
|
|
>
|
|
Reload
|
|
</button>
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<string | null>(null)
|
|
const [queuedChatModel, setQueuedChatModel] = useState<ModelId | null>(null)
|
|
const [queuedChatReasoningEffort, setQueuedChatReasoningEffort] =
|
|
useState<ReasoningEffort | null>(null)
|
|
const [queuedChatProject, setQueuedChatProject] = useState<string | null>(
|
|
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<DocumentWithMemories | null>(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=<id> (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<Set<string>>(
|
|
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<SpaceHighlightsResponse>({
|
|
queryKey: ["space-highlights", selectedProject, highlightsForceAt],
|
|
queryFn: async (): Promise<SpaceHighlightsResponse> => {
|
|
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<MemoryOfDay | null>({
|
|
queryKey: [
|
|
"memory-of-day",
|
|
user?.id,
|
|
org?.id,
|
|
new Date().toISOString().slice(0, 10),
|
|
],
|
|
queryFn: async (): Promise<MemoryOfDay | null> => {
|
|
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 && isConnectorPaused(integration)) {
|
|
void setViewMode("integrations")
|
|
return
|
|
}
|
|
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 (
|
|
<HotkeysProvider>
|
|
<OnboardingConfetti />
|
|
{slackHandoff && (
|
|
<SlackHandoff
|
|
teamName={slackHandoff.team}
|
|
onDismiss={() => setSlackHandoff(null)}
|
|
/>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
"relative flex min-h-dvh flex-col bg-[#05080D]",
|
|
(isGraphMode || isChatView || viewMode === "digests") &&
|
|
"h-dvh overflow-hidden",
|
|
showBottomNav &&
|
|
!isGraphMode &&
|
|
"pb-[calc(4rem+env(safe-area-inset-bottom))]",
|
|
)}
|
|
>
|
|
{showNovaBackdrop && (
|
|
<div className="pointer-events-none fixed inset-0 z-0">
|
|
<AnimatedGradientBackground
|
|
animateFromBottom={false}
|
|
topPosition={gradientTopPosition}
|
|
/>
|
|
<div className="absolute inset-0 bg-[#05080D]/50" aria-hidden />
|
|
<div
|
|
id="graph-dotted-grid"
|
|
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.25)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_60%,transparent_100%)]"
|
|
/>
|
|
</div>
|
|
)}
|
|
{isPublicIntegrations ? (
|
|
<PublicHeader variant="integrations" />
|
|
) : !session && viewMode === "mcp" ? (
|
|
<PublicHeader />
|
|
) : (
|
|
<Header
|
|
onAddMemory={() => {
|
|
analytics.addDocumentModalOpened()
|
|
setAddDoc("note")
|
|
}}
|
|
onOpenSearch={() => {
|
|
analytics.searchOpened({ source: "header" })
|
|
setIsSearchOpen(true)
|
|
}}
|
|
/>
|
|
)}
|
|
<AnimatePresence mode="wait">
|
|
<motion.main
|
|
key={`main-container-${viewMode}`}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -6 }}
|
|
transition={{ duration: 0.22, ease: [0.4, 0, 0.2, 1] }}
|
|
className={cn(
|
|
"relative z-10 flex min-h-0 flex-1 flex-col",
|
|
(isGraphMode || isChatView || viewMode === "digests") &&
|
|
"overflow-hidden",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"relative z-10 flex min-h-0 flex-1 flex-col md:flex-row",
|
|
)}
|
|
>
|
|
<ErrorBoundary fallback={<ViewErrorFallback />}>
|
|
{isChatView ? (
|
|
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col md:self-stretch">
|
|
<ChatSidebar
|
|
layout="page"
|
|
isChatOpen
|
|
setIsChatOpen={(open) => {
|
|
if (!open) void setViewMode("dashboard")
|
|
}}
|
|
queuedMessage={queuedChatSeed}
|
|
queuedHighlightContent={queuedHighlightContent}
|
|
onConsumeQueuedMessage={consumeQueuedChat}
|
|
queuedMessageSource={queuedMessageSource}
|
|
queuedAttachments={queuedChatAttachments}
|
|
initialSelectedModel={queuedChatModel}
|
|
initialReasoningEffort={queuedChatReasoningEffort}
|
|
initialChatProject={queuedChatProject}
|
|
/>
|
|
</div>
|
|
) : viewMode === "integrations" ? (
|
|
<div className="min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0">
|
|
<IntegrationsView
|
|
publicMode={isPublicIntegrations}
|
|
onOpenDocument={handleOpenDocument}
|
|
/>
|
|
</div>
|
|
) : viewMode === "configure" ? (
|
|
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto p-4 pt-2! md:p-6">
|
|
<ConfigureView />
|
|
</div>
|
|
) : viewMode === "mcp" ? (
|
|
<MCPDetailView
|
|
onBack={() => void setViewMode("integrations")}
|
|
/>
|
|
) : viewMode === "plugins" ? (
|
|
<DetailWrapper
|
|
onBack={() => void setViewMode("integrations")}
|
|
>
|
|
<PluginsDetail />
|
|
</DetailWrapper>
|
|
) : viewMode === "chrome" ? (
|
|
<DetailWrapper
|
|
onBack={() => void setViewMode("integrations")}
|
|
>
|
|
<ChromeDetail />
|
|
</DetailWrapper>
|
|
) : viewMode === "shortcuts" ? (
|
|
<DetailWrapper
|
|
onBack={() => void setViewMode("integrations")}
|
|
>
|
|
<ShortcutsDetail />
|
|
</DetailWrapper>
|
|
) : viewMode === "raycast" ? (
|
|
<DetailWrapper
|
|
onBack={() => void setViewMode("integrations")}
|
|
>
|
|
<RaycastDetail />
|
|
</DetailWrapper>
|
|
) : viewMode === "import" ? (
|
|
<XBookmarksDetailView
|
|
onBack={() => void setViewMode("integrations")}
|
|
/>
|
|
) : viewMode === "digests" ? (
|
|
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto lg:overflow-hidden">
|
|
<DigestsView />
|
|
</div>
|
|
) : viewMode === "graph" ? (
|
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
<GraphLayoutView onOpenDocument={handleOpenDocumentById} />
|
|
</div>
|
|
) : viewMode === "list" ? (
|
|
<div
|
|
className={cn(
|
|
"min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0",
|
|
"pb-10 md:pb-12",
|
|
)}
|
|
>
|
|
<MemoriesGrid
|
|
isChatOpen={false}
|
|
onOpenDocument={handleOpenDocument}
|
|
isSelectionMode={isSelectionMode}
|
|
selectedDocumentIds={selectedDocumentIds}
|
|
onEnterSelectionMode={handleEnterSelectionMode}
|
|
onToggleSelection={handleToggleSelection}
|
|
onClearSelection={handleClearSelection}
|
|
onSelectAllVisible={handleSelectAllVisible}
|
|
onBulkDelete={handleBulkDelete}
|
|
isBulkDeleting={bulkDeleteMutation.isPending}
|
|
quickNoteProps={{
|
|
onSave: handleQuickNoteSave,
|
|
onMaximize: handleMaximize,
|
|
isSaving: noteMutation.isPending,
|
|
}}
|
|
highlightsProps={{
|
|
items: highlightsData?.highlights || [],
|
|
onChat: handleHighlightsChat,
|
|
onShowRelated: handleHighlightsShowRelated,
|
|
isLoading: isLoadingHighlights,
|
|
}}
|
|
emptyStateProps={{
|
|
onAddMemory: handleAddMemory,
|
|
onOpenIntegrations: handleOpenIntegrations,
|
|
isAllSpaces: false,
|
|
spaceName: emptyStateSpaceName,
|
|
onSwitchToAllSpaces: undefined,
|
|
}}
|
|
/>
|
|
</div>
|
|
) : isCompanyBrain ? (
|
|
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto p-4 pt-2! pb-[180px] md:p-6">
|
|
<BrainHomeView />
|
|
</div>
|
|
) : (
|
|
<DashboardView
|
|
spaceLabel={dashboardSpaceLabel}
|
|
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}
|
|
/>
|
|
)}
|
|
</ErrorBoundary>
|
|
</div>
|
|
</motion.main>
|
|
</AnimatePresence>
|
|
|
|
{isDashboardShell && showBottomNav && (
|
|
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-20 h-64 bg-gradient-to-t from-[#05080D] via-[#05080D]/95 to-transparent" />
|
|
)}
|
|
{isDashboardShell && !isCompanyBrain && (
|
|
<div
|
|
className={cn(
|
|
"pointer-events-none fixed inset-x-0 z-30",
|
|
showBottomNav
|
|
? "bottom-[calc(4rem+env(safe-area-inset-bottom))]"
|
|
: "bottom-0 bg-gradient-to-t from-black via-black/40 to-transparent pt-12",
|
|
)}
|
|
>
|
|
<div className="pointer-events-auto">
|
|
<HomeChatComposer onStartChat={handleHomeChatStart} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showBottomNav && (
|
|
<MobileBottomNav
|
|
onAddMemory={() => {
|
|
analytics.addDocumentModalOpened()
|
|
setAddDoc("note")
|
|
}}
|
|
onOpenSearch={() => {
|
|
analytics.searchOpened({ source: "header" })
|
|
setIsSearchOpen(true)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<AddDocumentModal
|
|
isOpen={addDoc !== null}
|
|
onClose={() => setAddDoc(null)}
|
|
/>
|
|
<DocumentsCommandPalette
|
|
open={isSearchOpen}
|
|
onOpenChange={(open) => {
|
|
setIsSearchOpen(open)
|
|
if (!open) setSearchPrefill("")
|
|
}}
|
|
projectId={selectedProject}
|
|
onOpenDocument={handleOpenDocument}
|
|
onAddMemory={() => {
|
|
analytics.addDocumentModalOpened()
|
|
setAddDoc("note")
|
|
}}
|
|
onOpenIntegrations={() => setViewMode("integrations")}
|
|
initialSearch={searchPrefill}
|
|
/>
|
|
<DocumentModal
|
|
document={selectedDocument}
|
|
isOpen={docId !== null}
|
|
onClose={() => setDocId(null)}
|
|
/>
|
|
<FullscreenNoteModal
|
|
isOpen={isFullscreen}
|
|
onClose={() => setIsFullscreen(false)}
|
|
initialContent={fullscreenInitialContent}
|
|
onSave={handleFullScreenSave}
|
|
isSaving={noteMutation.isPending}
|
|
/>
|
|
</div>
|
|
</HotkeysProvider>
|
|
)
|
|
}
|