supermemory/apps/web/app/(app)/page.tsx
sohamd22 9017a1b5d4 add weekly digests, log, and opt out options (#1107)
### TL;DR

Adds a Weekly Digests view to the web app, allowing users to browse and read their personalized weekly memory recaps directly in the UI, along with a notification preference toggle to opt out of digest emails.

### What changed?

- Added a new `digests` view mode that renders a `DigestsView` component, accessible from the dashboard via a new weekly digest preview card that appears when a digest exists.
- `DigestsView` displays a master-detail layout: a scrollable list of past weekly digests on the left, and a full digest content pane on the right. The detail pane renders the intro (with a floating brain illustration), numbered highlights, and feature recommendations styled to mirror the email layout.
- Added thumbs-up/thumbs-down feedback controls and an optional free-text input on each digest, wired to analytics events.
- Added `useDigests` and `useDigest` hooks that fetch digest list and detail data from the API, with 5- and 10-minute stale times respectively.
- Registered four new API schema endpoints: `GET /digests`, `GET /digests/:id`, `GET /digests/preferences`, and `POST /digests/preferences`.
- Added a `DigestPreferences` section to the account settings page with a toggle that lets users opt in or out of the weekly digest email.
- Extended the `viewModeChanged` analytics event and the `viewLiterals` search param list to include `"digests"`, and added `digestViewed`, `digestFeedback`, and `digestFeedbackDetail` analytics events.
- Added documentation for the `SUPERMEMORY_EMBEDDING_RAM_LIMIT` and `SUPERMEMORY_INGEST_CONCURRENCY` environment variables, explaining the memory-bounded ingestion queue and its live terminal status output.

### How to test?

1. Navigate to the dashboard and confirm the weekly digest preview card appears when a digest exists, and clicking it transitions to the `digests` view.
2. In the digests view, verify the list renders past digests with the most recent highlighted by a gradient border, and selecting a row loads the correct detail content.
3. Confirm the empty state renders correctly when no digests exist.
4. Use the thumbs-up/thumbs-down buttons and the detailed feedback textarea on a digest, verifying the toast confirmation appears on submission.
5. Open account settings, locate the "Notifications" section, and toggle the weekly digest switch on and off, verifying the preference persists without errors.
6. Confirm the `digests` view mode is reflected in the URL search params when active.

### Why make this change?

Users currently receive weekly digest emails but have no way to revisit past digests within the app. This change surfaces digest history directly in the UI, adds in-product feedback collection on digest quality, and gives users control over whether they receive the emails — improving discoverability, engagement, and preference management.
2026-06-18 01:06:40 +00:00

850 lines
26 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 { MemoriesGrid } from "@/components/memories-grid"
import { GraphLayoutView } from "@/components/graph-layout-view"
import { IntegrationsView, DetailWrapper } from "@/components/integrations-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 { 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 } 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"
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 default function NewPage() {
const isMobile = useIsMobile()
const { user, session, isSessionPending } = 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()
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 = `${process.env.NEXT_PUBLIC_BACKEND_URL}/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(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/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,
new Date().toISOString().slice(0, 10),
],
queryFn: async (): Promise<MemoryOfDay | null> => {
const cacheKey = `memory-of-day:v2:${user?.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(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/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,
})
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 (
<HotkeysProvider>
<OnboardingConfetti />
<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 === "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>
) : (
<DashboardView
spaceLabel={dashboardSpaceLabel}
headerNotice={undefined}
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 && (
<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>
)
}