mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat(web): make /integrations a real route with connect deeplinks (#1155)
- Promote integrations from ?view=integrations to real /integrations and nested /integrations/[card] routes; the page body is shared via AppExperience and useViewMode is path-aware. - Legacy ?view= URLs (and /settings/integrations) redirect to the new routes for back-compat; middleware/ensure-workspace allow the public routes. - Add ?connect=<plugin|provider> deeplink that opens a card's connect modal instantly with a loading state (e.g. Hermes API key).
This commit is contained in:
parent
b3017eb121
commit
1e1b0b1a37
15 changed files with 1154 additions and 931 deletions
13
apps/web/app/(app)/integrations/[card]/page.tsx
Normal file
13
apps/web/app/(app)/integrations/[card]/page.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { notFound } from "next/navigation"
|
||||
import { AppExperience } from "@/components/app-experience"
|
||||
import { isIntegrationCard } from "@/lib/integration-routes"
|
||||
|
||||
export default async function IntegrationCardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ card: string }>
|
||||
}) {
|
||||
const { card } = await params
|
||||
if (!isIntegrationCard(card)) notFound()
|
||||
return <AppExperience />
|
||||
}
|
||||
5
apps/web/app/(app)/integrations/page.tsx
Normal file
5
apps/web/app/(app)/integrations/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { AppExperience } from "@/components/app-experience"
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
return <AppExperience />
|
||||
}
|
||||
|
|
@ -1,878 +1,5 @@
|
|||
"use client"
|
||||
import { AppExperience } from "@/components/app-experience"
|
||||
|
||||
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 { 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, 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()
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
|
||||
// Slack OAuth redirects back here with ?slack=connected — toast then clean up.
|
||||
useEffect(() => {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
if (sp.get("slack") !== "connected") return
|
||||
const team = sp.get("team")
|
||||
toast.success(
|
||||
team
|
||||
? `Supermemory added to ${team} on Slack`
|
||||
: "Supermemory added to your Slack",
|
||||
)
|
||||
sp.delete("slack")
|
||||
sp.delete("team")
|
||||
const qs = sp.toString()
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.pathname + (qs ? `?${qs}` : ""),
|
||||
)
|
||||
}, [])
|
||||
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,
|
||||
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(
|
||||
`${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 && !!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 (
|
||||
<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>
|
||||
) : 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}
|
||||
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>
|
||||
)
|
||||
export default function Page() {
|
||||
return <AppExperience />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,5 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function SettingsIntegrationsPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set("view", "integrations")
|
||||
router.replace(`/?${params.toString()}`)
|
||||
}, [router, searchParams])
|
||||
|
||||
return null
|
||||
redirect("/integrations")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export default function SettingsRedirect() {
|
|||
const hash = typeof window !== "undefined" ? window.location.hash : ""
|
||||
const tab = parseHashToTab(hash)
|
||||
router.replace(
|
||||
tab === "integrations" ? "/?view=integrations" : `/?settings=${tab}`,
|
||||
tab === "integrations" ? "/integrations" : `/?settings=${tab}`,
|
||||
)
|
||||
}, [router])
|
||||
|
||||
|
|
|
|||
879
apps/web/components/app-experience.tsx
Normal file
879
apps/web/components/app-experience.tsx
Normal file
|
|
@ -0,0 +1,879 @@
|
|||
"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 { 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, 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"
|
||||
|
||||
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()
|
||||
|
||||
// Slack OAuth redirects back here with ?slack=connected — toast then clean up.
|
||||
useEffect(() => {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
if (sp.get("slack") !== "connected") return
|
||||
const team = sp.get("team")
|
||||
toast.success(
|
||||
team
|
||||
? `Supermemory added to ${team} on Slack`
|
||||
: "Supermemory added to your Slack",
|
||||
)
|
||||
sp.delete("slack")
|
||||
sp.delete("team")
|
||||
const qs = sp.toString()
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.pathname + (qs ? `?${qs}` : ""),
|
||||
)
|
||||
}, [])
|
||||
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,
|
||||
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(
|
||||
`${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 && !!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 (
|
||||
<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>
|
||||
) : 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}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -23,8 +23,10 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
|
|||
const { session, organizations, isRestoring, isSessionPending } = useAuth()
|
||||
|
||||
const isPublicAppPage =
|
||||
pathname === "/" &&
|
||||
["integrations", "mcp"].includes(searchParams.get("view") ?? "")
|
||||
pathname === "/integrations" ||
|
||||
pathname === "/integrations/mcp" ||
|
||||
(pathname === "/" &&
|
||||
["integrations", "mcp"].includes(searchParams.get("view") ?? ""))
|
||||
const isGuestPublicAppPage = isPublicAppPage && !session && !isSessionPending
|
||||
const isOnboarding = pathname.startsWith("/onboarding")
|
||||
|
||||
|
|
|
|||
|
|
@ -514,7 +514,7 @@ export function PublicHeader({
|
|||
return (
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between gap-2 p-2.5 md:p-3">
|
||||
<Link
|
||||
href="/?view=integrations"
|
||||
href="/integrations"
|
||||
className="flex items-center gap-2 transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Logo className="h-6 md:h-7" />
|
||||
|
|
@ -523,7 +523,7 @@ export function PublicHeader({
|
|||
</p>
|
||||
</Link>
|
||||
|
||||
<Link href="/login?redirect=%2F%3Fview%3Dintegrations">
|
||||
<Link href="/login?redirect=%2Fintegrations">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -2429,7 +2429,8 @@ export function IntegrationsView({
|
|||
open: boolean
|
||||
key: string
|
||||
pluginId: string | null
|
||||
}>({ open: false, key: "", pluginId: null })
|
||||
loading: boolean
|
||||
}>({ open: false, key: "", pluginId: null, loading: false })
|
||||
const [connectedPluginId, setConnectedPluginId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
|
|
@ -2600,6 +2601,12 @@ export function IntegrationsView({
|
|||
},
|
||||
onMutate: (pluginId) => setConnectingPlugin(pluginId),
|
||||
onError: (err) => {
|
||||
// Tear down a pre-opened (loading) modal so a failed mint doesn't hang on a spinner.
|
||||
setNewKey((s) =>
|
||||
s.loading
|
||||
? { open: false, key: "", pluginId: null, loading: false }
|
||||
: s,
|
||||
)
|
||||
toast.error("Failed to connect plugin", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
})
|
||||
|
|
@ -2609,7 +2616,7 @@ export function IntegrationsView({
|
|||
queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] })
|
||||
},
|
||||
onSuccess: (data, pluginId) => {
|
||||
setNewKey({ open: true, key: data.key, pluginId })
|
||||
setNewKey({ open: true, key: data.key, pluginId, loading: false })
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -2657,23 +2664,26 @@ export function IntegrationsView({
|
|||
}
|
||||
}
|
||||
|
||||
const handleUpgrade = async (planId?: unknown) => {
|
||||
const checkoutPlanId = planId === "api_max" ? "api_max" : "api_pro"
|
||||
try {
|
||||
const result = await autumn.attach({
|
||||
planId: checkoutPlanId,
|
||||
successUrl: `${window.location.origin}/?view=integrations`,
|
||||
})
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
const handleUpgrade = useCallback(
|
||||
async (planId?: unknown) => {
|
||||
const checkoutPlanId = planId === "api_max" ? "api_max" : "api_pro"
|
||||
try {
|
||||
const result = await autumn.attach({
|
||||
planId: checkoutPlanId,
|
||||
successUrl: `${window.location.origin}/integrations`,
|
||||
})
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
return
|
||||
}
|
||||
autumn.refetch?.()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Failed to start checkout. Please try again.")
|
||||
}
|
||||
autumn.refetch?.()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Failed to start checkout. Please try again.")
|
||||
}
|
||||
}
|
||||
},
|
||||
[autumn],
|
||||
)
|
||||
|
||||
const redirectToLogin = useCallback(() => {
|
||||
const loginUrl = new URL("/login", window.location.origin)
|
||||
|
|
@ -2701,6 +2711,85 @@ export function IntegrationsView({
|
|||
setMcpModalOpen(true)
|
||||
}
|
||||
|
||||
// Deeplink: /integrations?connect=<plugin-id|provider> auto-opens that card's connect flow.
|
||||
const [connectTarget, setConnectTarget] = useQueryState(
|
||||
"connect",
|
||||
parseAsString,
|
||||
)
|
||||
// Tracks the last target we acted on; reset when the param clears so a fresh deeplink re-fires.
|
||||
const connectHandledRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!connectTarget) {
|
||||
connectHandledRef.current = null
|
||||
return
|
||||
}
|
||||
if (connectHandledRef.current === connectTarget) return
|
||||
const target = connectTarget
|
||||
const isPlugin = !!PLUGIN_CATALOG[target]
|
||||
const freeTier = isPlugin && isFreeTierPlugin(target)
|
||||
|
||||
// Paid plugins and granola need the plan query before deciding upgrade-vs-connect.
|
||||
const needsPlan = (isPlugin && !freeTier) || target === "granola"
|
||||
if (needsPlan && isAutumnLoading) return
|
||||
|
||||
// Defer to a macrotask and cancel on cleanup so React Strict Mode's mount→unmount→remount
|
||||
// fires this exactly once (on the surviving mount) instead of opening/minting twice.
|
||||
let cancelled = false
|
||||
const timer = setTimeout(() => {
|
||||
if (cancelled) return
|
||||
connectHandledRef.current = target
|
||||
|
||||
if (publicMode) {
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
|
||||
if (isPlugin) {
|
||||
if (!freeTier && !hasProProduct) {
|
||||
void setConnectTarget(null)
|
||||
handleUpgrade("api_pro")
|
||||
} else {
|
||||
// Open instantly; the key fills in on mint. The ?connect param stays the source
|
||||
// of truth until the modal closes.
|
||||
setNewKey({ open: true, key: "", pluginId: target, loading: true })
|
||||
createPluginKeyMutation.mutate(target)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (target === "granola") {
|
||||
if (!hasProProduct) {
|
||||
void setConnectTarget(null)
|
||||
handleUpgrade("api_pro")
|
||||
} else {
|
||||
setGranolaModalOpen(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (["notion", "google-drive", "onedrive"].includes(target)) {
|
||||
// The add-document modal is driven by its own ?add param, so clearing ?connect is safe.
|
||||
void setConnectTarget(null)
|
||||
void setAddDoc("connect")
|
||||
}
|
||||
}, 0)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [
|
||||
connectTarget,
|
||||
isAutumnLoading,
|
||||
hasProProduct,
|
||||
publicMode,
|
||||
redirectToLogin,
|
||||
setConnectTarget,
|
||||
setAddDoc,
|
||||
createPluginKeyMutation,
|
||||
handleUpgrade,
|
||||
])
|
||||
|
||||
const closeMcpModal = () => {
|
||||
setMcpModalOpen(false)
|
||||
void setMcpClient(null)
|
||||
|
|
@ -3409,7 +3498,7 @@ export function IntegrationsView({
|
|||
]
|
||||
|
||||
return (
|
||||
<div className="flex-1 p-4 md:p-6 pt-2">
|
||||
<div className="flex-1">
|
||||
{shortcutsConnect.dialog}
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -3524,13 +3613,15 @@ export function IntegrationsView({
|
|||
|
||||
<Dialog
|
||||
open={newKey.open}
|
||||
onOpenChange={(open) =>
|
||||
onOpenChange={(open) => {
|
||||
setNewKey((s) => ({
|
||||
open,
|
||||
key: open ? s.key : "",
|
||||
pluginId: open ? s.pluginId : null,
|
||||
loading: open ? s.loading : false,
|
||||
}))
|
||||
}
|
||||
if (!open) void setConnectTarget(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
|
|
@ -3562,7 +3653,9 @@ export function IntegrationsView({
|
|||
Set up {dialogPlugin?.name ?? "your plugin"}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
|
||||
Copy your key and run these steps to finish.
|
||||
{newKey.loading
|
||||
? "Generating your key…"
|
||||
: "Copy your key and run these steps to finish."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
|
|
@ -3599,15 +3692,28 @@ export function IntegrationsView({
|
|||
INSET,
|
||||
)}
|
||||
>
|
||||
<InstallSteps steps={setupSteps} apiKey={newKey.key} />
|
||||
{newKey.loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-[13px] text-[#A1A1AA]">
|
||||
<Loader className="size-4 animate-spin" />
|
||||
Generating your key…
|
||||
</div>
|
||||
) : (
|
||||
<InstallSteps steps={setupSteps} apiKey={newKey.key} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setNewKey({ open: false, key: "", pluginId: null })
|
||||
}
|
||||
onClick={() => {
|
||||
setNewKey({
|
||||
open: false,
|
||||
key: "",
|
||||
pluginId: null,
|
||||
loading: false,
|
||||
})
|
||||
void setConnectTarget(null)
|
||||
}}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
|
||||
|
|
@ -3958,7 +4064,10 @@ export function IntegrationsView({
|
|||
|
||||
<GranolaConnectModal
|
||||
open={hasProProduct && granolaModalOpen}
|
||||
onOpenChange={(open) => setGranolaModalOpen(open && hasProProduct)}
|
||||
onOpenChange={(open) => {
|
||||
setGranolaModalOpen(open && hasProProduct)
|
||||
if (!open) void setConnectTarget(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -613,7 +613,7 @@ export function PluginsDetail() {
|
|||
try {
|
||||
const result = await autumn.attach({
|
||||
planId: "api_pro",
|
||||
successUrl: `${window.location.origin}/?view=integrations`,
|
||||
successUrl: `${window.location.origin}/integrations`,
|
||||
})
|
||||
if (result?.paymentUrl) {
|
||||
window.open(result.paymentUrl, "_self")
|
||||
|
|
|
|||
|
|
@ -710,7 +710,7 @@ export default function ConnectionsMCP() {
|
|||
</p>
|
||||
|
||||
<PillButton
|
||||
onClick={() => router.push("/?view=integrations&cat=ai-clients")}
|
||||
onClick={() => router.push("/integrations?cat=ai-clients")}
|
||||
>
|
||||
<Plus className="size-[10px] text-[#FAFAFA]" />
|
||||
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ export function SettingsContent({
|
|||
}
|
||||
|
||||
const handleIntegrations = () => {
|
||||
void router.push("/?view=integrations")
|
||||
void router.push("/integrations")
|
||||
}
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
|
|
|
|||
40
apps/web/lib/integration-routes.ts
Normal file
40
apps/web/lib/integration-routes.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { ViewParamValue } from "@/lib/search-params"
|
||||
|
||||
// Integration-family views that live under the real /integrations route.
|
||||
export const INTEGRATION_VIEWS = [
|
||||
"integrations",
|
||||
"mcp",
|
||||
"plugins",
|
||||
"chrome",
|
||||
"connections",
|
||||
"shortcuts",
|
||||
"raycast",
|
||||
"import",
|
||||
] as const
|
||||
|
||||
export type IntegrationView = (typeof INTEGRATION_VIEWS)[number]
|
||||
|
||||
// Sub-view cards — each is a nested route segment under /integrations.
|
||||
export const INTEGRATION_CARDS = INTEGRATION_VIEWS.filter(
|
||||
(v) => v !== "integrations",
|
||||
) as Exclude<IntegrationView, "integrations">[]
|
||||
|
||||
export function isIntegrationView(view: string): view is IntegrationView {
|
||||
return (INTEGRATION_VIEWS as readonly string[]).includes(view)
|
||||
}
|
||||
|
||||
export function isIntegrationCard(slug: string): slug is IntegrationView {
|
||||
return (INTEGRATION_CARDS as readonly string[]).includes(slug)
|
||||
}
|
||||
|
||||
export function integrationViewToPath(view: IntegrationView): string {
|
||||
return view === "integrations" ? "/integrations" : `/integrations/${view}`
|
||||
}
|
||||
|
||||
export function pathToIntegrationView(pathname: string): ViewParamValue | null {
|
||||
const trimmed = pathname.replace(/\/$/, "")
|
||||
if (trimmed === "/integrations") return "integrations"
|
||||
const slug = trimmed.match(/^\/integrations\/([^/]+)$/)?.[1]
|
||||
if (slug && isIntegrationCard(slug)) return slug
|
||||
return null
|
||||
}
|
||||
|
|
@ -1,24 +1,76 @@
|
|||
"use client"
|
||||
|
||||
import { useQueryState } from "nuqs"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { viewParam, type ViewParamValue } from "@/lib/search-params"
|
||||
import {
|
||||
integrationViewToPath,
|
||||
isIntegrationView,
|
||||
pathToIntegrationView,
|
||||
} from "@/lib/integration-routes"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { useCallback } from "react"
|
||||
import { useCallback, useEffect } from "react"
|
||||
|
||||
export type ViewMode = ViewParamValue
|
||||
|
||||
type SetViewMode = (value: ViewMode | null) => Promise<URLSearchParams>
|
||||
const TRACKED_VIEW_MODES = [
|
||||
"dashboard",
|
||||
"graph",
|
||||
"list",
|
||||
"integrations",
|
||||
"chat",
|
||||
"digests",
|
||||
] as const
|
||||
|
||||
function isTrackedViewMode(
|
||||
mode: ViewMode,
|
||||
): mode is (typeof TRACKED_VIEW_MODES)[number] {
|
||||
return (TRACKED_VIEW_MODES as readonly string[]).includes(mode)
|
||||
}
|
||||
|
||||
export function useViewMode() {
|
||||
const [viewMode, _setViewMode] = useQueryState("view", viewParam)
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [paramView, setParamView] = useQueryState("view", viewParam)
|
||||
|
||||
// On /integrations[/card] the path is the source of truth; elsewhere the ?view param is.
|
||||
const pathView = pathToIntegrationView(pathname)
|
||||
const viewMode: ViewMode = pathView ?? paramView
|
||||
|
||||
const setViewMode = useCallback(
|
||||
(mode: ViewMode) => {
|
||||
analytics.viewModeChanged(mode)
|
||||
;(_setViewMode as SetViewMode)(mode)
|
||||
if (isTrackedViewMode(mode)) analytics.viewModeChanged(mode)
|
||||
if (isIntegrationView(mode)) {
|
||||
router.push(integrationViewToPath(mode))
|
||||
return
|
||||
}
|
||||
// Leaving (or already off) the integrations route for a non-integration view.
|
||||
if (pathToIntegrationView(pathname)) {
|
||||
router.push(mode === "dashboard" ? "/" : `/?view=${mode}`)
|
||||
return
|
||||
}
|
||||
void setParamView(mode)
|
||||
},
|
||||
[_setViewMode],
|
||||
[router, pathname, setParamView],
|
||||
)
|
||||
|
||||
return { viewMode, setViewMode, isInitialized: true }
|
||||
}
|
||||
|
||||
// Forwards legacy /?view=integrations (and sub-views) to the canonical /integrations route,
|
||||
// preserving any other query params. Call once near the app root.
|
||||
export function useLegacyViewRedirect() {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname !== "/") return
|
||||
const view = searchParams.get("view")
|
||||
if (!view || !isIntegrationView(view)) return
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete("view")
|
||||
const qs = params.toString()
|
||||
router.replace(integrationViewToPath(view) + (qs ? `?${qs}` : ""))
|
||||
}, [pathname, searchParams, router])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,14 @@ export default async function proxy(request: Request) {
|
|||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Real integrations routes, public in guest mode (mirrors view=integrations / view=mcp).
|
||||
if (
|
||||
url.pathname === "/integrations" ||
|
||||
url.pathname === "/integrations/mcp"
|
||||
) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
if (!sessionCookie) {
|
||||
console.debug("[MIDDLEWARE] API route without session, returning 401")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue