- {(item.file.size / 1024 / 1024).toFixed(2)} MB
+ {formatFileSize(item.file.size)}
{item.status === "error" && item.errorMessage ? (
diff --git a/apps/web/components/add-document/index.tsx b/apps/web/components/add-document/index.tsx
index cc308f90..1c231f7a 100644
--- a/apps/web/components/add-document/index.tsx
+++ b/apps/web/components/add-document/index.tsx
@@ -105,7 +105,7 @@ const tabs = [
id: "connect" as const,
icon: ZapIcon,
title: "Connect knowledge bases",
- compactLabel: "Connections",
+ compactLabel: "Connect",
description: "Sync with Google Drive, Notion and OneDrive and import data",
isPro: true,
},
@@ -147,9 +147,10 @@ export function AddDocument({
const fileDataRef = useRef(fileData)
fileDataRef.current = fileData
- const { noteMutation, linkMutation, fileMutation } = useDocumentMutations({
- onClose,
- })
+ const { noteMutation, linkMutation, bulkLinkMutation, fileMutation } =
+ useDocumentMutations({
+ onClose,
+ })
const autumn = useCustomer()
const {
@@ -187,13 +188,30 @@ export function AddDocument({
const handleLinkSubmit = useCallback(
(data: LinkData) => {
+ // In bulk mode the selection is the source of truth, not the raw textarea.
+ if (data.bulkUrls) {
+ if (data.bulkUrls.length >= 2) {
+ bulkLinkMutation.mutate({
+ urls: data.bulkUrls,
+ project: localSelectedProject,
+ })
+ return
+ }
+ const [onlyUrl] = data.bulkUrls
+ if (onlyUrl) {
+ linkMutation.mutate({ url: onlyUrl, project: localSelectedProject })
+ return
+ }
+ toast.error("Select at least one link")
+ return
+ }
if (!data.url.trim()) {
toast.error("Please enter a URL")
return
}
linkMutation.mutate({ url: data.url, project: localSelectedProject })
},
- [linkMutation, localSelectedProject],
+ [linkMutation, bulkLinkMutation, localSelectedProject],
)
const handleFileSubmit = useCallback(
@@ -263,6 +281,14 @@ export function AddDocument({
setFileData(data)
}, [])
+ const handleNoteImportLinks = useCallback(
+ (urls: string[]) => {
+ setLinkData({ url: urls.join("\n"), title: "", description: "" })
+ setActiveTab("link")
+ },
+ [setActiveTab],
+ )
+
const handleButtonClick = () => {
switch (activeTab) {
case "note":
@@ -278,12 +304,33 @@ export function AddDocument({
}
const isSubmitting =
- noteMutation.isPending || linkMutation.isPending || fileMutation.isPending
+ noteMutation.isPending ||
+ linkMutation.isPending ||
+ bulkLinkMutation.isPending ||
+ fileMutation.isPending
const fileTabHasPending = fileData.items.some((i) => i.status === "pending")
const fileTabSubmitDisabled =
activeTab === "file" && (!fileTabHasPending || isSubmitting)
+ const linkBulkCount = linkData.bulkUrls?.length ?? 0
+ const linkTabSubmitDisabled =
+ activeTab === "link" &&
+ linkData.bulkUrls !== undefined &&
+ linkBulkCount === 0
+
+ const spaceSelector = (
+
+ setLocalSelectedProject(projects[0] ?? localSelectedProject)
+ }
+ variant="insideOut"
+ compact={isMobile}
+ triggerClassName={cn(isMobile && "h-12 shrink-0")}
+ />
+ )
+
return (
{isMobile && !hasPaidPlan && (
@@ -445,11 +492,29 @@ export function AddDocument({
isMobile ? "w-full px-4 pt-1" : "w-2/3 px-1",
)}
>
+ {isMobile && (
+
+ {tabs.map((tab) => (
+ setActiveTab(tab.id)}
+ icon={tab.icon}
+ title={tab.title}
+ compactLabel={tab.compactLabel}
+ description={tab.description}
+ isPro={tab.isPro}
+ compact
+ />
+ ))}
+
+ )}
{activeTab === "note" && (
@@ -487,38 +554,14 @@ export function AddDocument({
: "justify-between gap-2 pt-3",
)}
>
- {isMobile && (
-
- {tabs.map((tab) => (
- setActiveTab(tab.id)}
- icon={tab.icon}
- title={tab.title}
- compactLabel={tab.compactLabel}
- description={tab.description}
- isPro={tab.isPro}
- compact
- />
- ))}
-
- )}
- {!isMobile && (
-
- setLocalSelectedProject(projects[0] ?? localSelectedProject)
- }
- variant="insideOut"
- />
- )}
+ {!isMobile && spaceSelector}
+ {isMobile && spaceSelector}
{!isMobile && (
{isSubmitting ? (
<>
Adding…
>
+ ) : activeTab === "link" && linkBulkCount >= 2 ? (
+ `+ Add ${linkBulkCount} memories`
) : (
<>
+ Add {activeTab}{" "}
diff --git a/apps/web/components/add-document/link.tsx b/apps/web/components/add-document/link.tsx
index f2c7bc16..1f478dee 100644
--- a/apps/web/components/add-document/link.tsx
+++ b/apps/web/components/add-document/link.tsx
@@ -1,18 +1,20 @@
"use client"
-import { useState, useEffect } from "react"
+import { useState, useEffect, useMemo } from "react"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { dmSansClassName } from "@/lib/fonts"
import { useHotkeys } from "react-hotkeys-hook"
import { Image as ImageIcon, Loader2 } from "lucide-react"
import { toast } from "sonner"
+import { extractUrls, getFaviconUrl, normalizeUrl } from "@/lib/url-helpers"
export interface LinkData {
url: string
title: string
description: string
image?: string
+ bulkUrls?: string[]
}
interface LinkContentProps {
@@ -23,6 +25,9 @@ interface LinkContentProps {
initialData?: LinkData
}
+const prettyUrl = (url: string) =>
+ url.replace(/^https?:\/\//, "").replace(/\/$/, "")
+
export function LinkContent({
onSubmit,
onDataChange,
@@ -35,50 +40,48 @@ export function LinkContent({
const [description, setDescription] = useState(initialData?.description ?? "")
const [image, setImage] = useState(initialData?.image)
const [isPreviewLoading, setIsPreviewLoading] = useState(false)
+ const [deselected, setDeselected] = useState>(new Set())
- const canSubmit = url.trim().length > 0 && !isSubmitting
+ const { urls: detectedUrls, duplicates } = useMemo(
+ () => extractUrls(url),
+ [url],
+ )
+ const isBulk = detectedUrls.length >= 2
+ const selectedUrls = useMemo(
+ () => detectedUrls.filter((u) => !deselected.has(u)),
+ [detectedUrls, deselected],
+ )
+
+ const canSubmit =
+ (isBulk ? selectedUrls.length > 0 : url.trim().length > 0) && !isSubmitting
+
+ useEffect(() => {
+ const selected = detectedUrls.filter((u) => !deselected.has(u))
+ onDataChange?.({
+ url: url.trim(),
+ title,
+ description,
+ ...(image && { image }),
+ ...(detectedUrls.length >= 2 && { bulkUrls: selected }),
+ })
+ }, [url, title, description, image, detectedUrls, deselected, onDataChange])
const handleSubmit = () => {
- if (canSubmit && onSubmit) {
- let normalizedUrl = url.trim()
- if (
- !normalizedUrl.startsWith("http://") &&
- !normalizedUrl.startsWith("https://")
- ) {
- normalizedUrl = `https://${normalizedUrl}`
- }
- onSubmit({ url: normalizedUrl, title, description })
+ if (!canSubmit || !onSubmit) return
+ if (isBulk) {
+ onSubmit({ url: "", title, description, bulkUrls: selectedUrls })
+ return
}
+ onSubmit({ url: normalizeUrl(url.trim()), title, description })
}
- const updateData = (
- newUrl: string,
- newTitle: string,
- newDescription: string,
- newImage?: string,
- ) => {
- onDataChange?.({
- url: newUrl,
- title: newTitle,
- description: newDescription,
- ...(newImage && { image: newImage }),
+ const toggleUrl = (u: string) =>
+ setDeselected((prev) => {
+ const next = new Set(prev)
+ if (next.has(u)) next.delete(u)
+ else next.add(u)
+ return next
})
- }
-
- const handleUrlChange = (newUrl: string) => {
- setUrl(newUrl)
- updateData(newUrl, title, description, image)
- }
-
- const handleTitleChange = (newTitle: string) => {
- setTitle(newTitle)
- updateData(url, newTitle, description)
- }
-
- const handleDescriptionChange = (newDescription: string) => {
- setDescription(newDescription)
- updateData(url, title, newDescription, image)
- }
const handlePreviewLink = async () => {
if (!url.trim()) {
@@ -86,15 +89,8 @@ export function LinkContent({
return
}
- let normalizedUrl = url.trim()
- if (
- !normalizedUrl.startsWith("http://") &&
- !normalizedUrl.startsWith("https://")
- ) {
- normalizedUrl = `https://${normalizedUrl}`
- setUrl(normalizedUrl)
- updateData(normalizedUrl, title, description, image)
- }
+ const normalizedUrl = normalizeUrl(url.trim())
+ if (normalizedUrl !== url) setUrl(normalizedUrl)
setIsPreviewLoading(true)
try {
@@ -109,16 +105,11 @@ export function LinkContent({
const data = await response.json()
- const newTitle = data.title || ""
- const newDescription = data.description || ""
- const newImage = data.image || undefined
+ setTitle(data.title || "")
+ setDescription(data.description || "")
+ setImage(data.image || undefined)
- setTitle(newTitle)
- setDescription(newDescription)
- setImage(newImage)
- updateData(url, newTitle, newDescription, newImage)
-
- if (!newTitle && !newDescription && !newImage) {
+ if (!data.title && !data.description && !data.image) {
toast.info("No Open Graph data found for this URL")
} else {
toast.success("Preview loaded successfully")
@@ -138,13 +129,13 @@ export function LinkContent({
enableOnFormTags: ["INPUT", "TEXTAREA"],
})
- // Reset content when modal closes
useEffect(() => {
if (!isOpen) {
setUrl("")
setTitle("")
setDescription("")
setImage(undefined)
+ setDeselected(new Set())
onDataChange?.({ url: "", title: "", description: "" })
}
}, [isOpen, onDataChange])
@@ -160,86 +151,160 @@ export function LinkContent({
- Paste a link to turn it into a memory
+ Paste one link — or many — to turn them into memories
- handleUrlChange(e.target.value)}
- placeholder="https://example.com"
- disabled={isSubmitting}
- className="w-full p-4 rounded-xl bg-[#14161A] shadow-inside-out disabled:opacity-50 outline-1 outline-transparent focus:outline-[#525D6EB2]"
- />
-
- {isPreviewLoading ? (
- <>
-
- Loading…
- >
- ) : (
- "Preview Link"
- )}
-
-
-
-
-
-
- Link title
-
-
handleTitleChange(e.target.value)}
- placeholder="Mahesh Sanikommu - Portfolio"
- disabled
- className="w-full px-4 py-3 bg-[#0F1217] rounded-xl disabled:opacity-50 outline-1 outline-transparent focus:outline-[#525D6EB2]"
- />
-
-
-
-
- Link Preview Image
-
- {image ? (
-
-
{
- e.currentTarget.style.display = "none"
- e.currentTarget.parentElement?.classList.add("opacity-50")
- e.currentTarget.parentElement?.classList.add("flex")
- e.currentTarget.parentElement?.classList.add("items-center")
- e.currentTarget.parentElement?.classList.add("justify-center")
- }}
- />
-
- ) : (
-
-
-
+ {!isBulk && (
+
+ {isPreviewLoading ? (
+ <>
+
+ Loading…
+ >
+ ) : (
+ "Preview Link"
+ )}
+
)}
+
+ {isBulk ? (
+
+
+
+ Found {detectedUrls.length} links
+ {duplicates > 0 && (
+
+ {" "}
+ · {duplicates} duplicate{duplicates === 1 ? "" : "s"} removed
+
+ )}
+
+
+ setDeselected(new Set())}
+ >
+ Select all
+
+ setDeselected(new Set(detectedUrls))}
+ >
+ Clear
+
+
+
+
+
+ {selectedUrls.length} selected · each link becomes its own memory
+
+
+ ) : (
+
+
+
+ Link title
+
+
setTitle(e.target.value)}
+ placeholder="Mahesh Sanikommu - Portfolio"
+ disabled
+ className="w-full px-4 py-3 bg-[#0F1217] rounded-xl disabled:opacity-50 outline-1 outline-transparent focus:outline-[#525D6EB2]"
+ />
+
+
+
+
+ Link Preview Image
+
+ {image ? (
+
+
{
+ e.currentTarget.style.display = "none"
+ e.currentTarget.parentElement?.classList.add("opacity-50")
+ e.currentTarget.parentElement?.classList.add("flex")
+ e.currentTarget.parentElement?.classList.add("items-center")
+ e.currentTarget.parentElement?.classList.add(
+ "justify-center",
+ )
+ }}
+ />
+
+ ) : (
+
+
+
+ )}
+
+
+ )}
)
}
diff --git a/apps/web/components/add-document/note.tsx b/apps/web/components/add-document/note.tsx
index 912d20a7..ef462df4 100644
--- a/apps/web/components/add-document/note.tsx
+++ b/apps/web/components/add-document/note.tsx
@@ -1,11 +1,17 @@
"use client"
-import { useState } from "react"
+import { useMemo, useState } from "react"
+import { LinkIcon } from "lucide-react"
+import { cn } from "@lib/utils"
+import { Button } from "@ui/components/button"
+import { dmSansClassName } from "@/lib/fonts"
import { TextEditor } from "../text-editor"
+import { extractUrls } from "@/lib/url-helpers"
interface NoteContentProps {
onSubmit?: (content: string) => void
onContentChange?: (content: string) => void
+ onImportLinks?: (urls: string[]) => void
isSubmitting?: boolean
isOpen?: boolean
initialContent?: string
@@ -14,11 +20,16 @@ interface NoteContentProps {
export function NoteContent({
onSubmit,
onContentChange,
+ onImportLinks,
isSubmitting,
initialContent,
}: NoteContentProps) {
const [content, setContent] = useState(initialContent ?? "")
const [seededContent] = useState(initialContent || undefined)
+ const [dismissed, setDismissed] = useState(false)
+
+ const { urls: detectedUrls } = useMemo(() => extractUrls(content), [content])
+ const showBulkOffer = detectedUrls.length >= 2 && !dismissed
const canSubmit = content.trim().length > 0 && !isSubmitting
@@ -34,13 +45,53 @@ export function NoteContent({
}
return (
-
-
+
+ {showBulkOffer && (
+
+
+
+
+
+ {detectedUrls.length} links
+ {" "}
+ detected — save each as its own memory?
+
+
+
+ setDismissed(true)}
+ disabled={isSubmitting}
+ className="rounded-full px-3 text-[#737373]/80 hover:text-white"
+ >
+ Dismiss
+
+ onImportLinks?.(detectedUrls)}
+ disabled={isSubmitting}
+ className="rounded-full px-4"
+ >
+ Save as {detectedUrls.length} memories
+
+
+
+ )}
+
+
+
)
}
diff --git a/apps/web/components/add-space-modal.tsx b/apps/web/components/add-space-modal.tsx
index 7c9f3cf3..9f09da7c 100644
--- a/apps/web/components/add-space-modal.tsx
+++ b/apps/web/components/add-space-modal.tsx
@@ -2,7 +2,7 @@
import { useState } from "react"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
-import { Dialog, DialogContent } from "@repo/ui/components/dialog"
+import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { cn } from "@lib/utils"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon, Loader2 } from "lucide-react"
@@ -10,6 +10,7 @@ import { Button } from "@ui/components/button"
import { useProjectMutations } from "@/hooks/use-project-mutations"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import { analytics } from "@/lib/analytics"
+import { $fetch } from "@lib/api"
const EMOJI_LIST = [
"📁",
@@ -62,6 +63,25 @@ const EMOJI_LIST = [
"🤍",
]
+export const CONTEXT_PRESETS: { label: string; text: string }[] = [
+ {
+ label: "Work project",
+ text: "Tracks a work project — decisions, owners, deadlines, and current status.",
+ },
+ {
+ label: "Client",
+ text: "About a client — meetings, requirements, and account context.",
+ },
+ {
+ label: "Research",
+ text: "Research notes — sources, key findings, and open questions.",
+ },
+ {
+ label: "Personal",
+ text: "My personal space — notes, ideas, and things to remember.",
+ },
+]
+
export function AddSpaceModal({
isOpen,
onClose,
@@ -72,6 +92,8 @@ export function AddSpaceModal({
onCreated?: (containerTag: string) => void
}) {
const [spaceName, setSpaceName] = useState("")
+ const [spaceContext, setSpaceContext] = useState("")
+ const [showContext, setShowContext] = useState(false)
const [emoji, setEmoji] = useState("📁")
const [isEmojiOpen, setIsEmojiOpen] = useState(false)
const { createProjectMutation } = useProjectMutations()
@@ -79,6 +101,8 @@ export function AddSpaceModal({
const handleClose = () => {
onClose()
setSpaceName("")
+ setSpaceContext("")
+ setShowContext(false)
setEmoji("📁")
}
@@ -89,11 +113,18 @@ export function AddSpaceModal({
createProjectMutation.mutate(
{ name: trimmedName, emoji: emoji || undefined },
{
- onSuccess: (data) => {
+ onSuccess: async (data) => {
analytics.spaceCreated()
- if (data?.containerTag) {
- onCreated?.(data.containerTag)
+ const tag = data?.containerTag
+ const context = showContext ? spaceContext.trim() : ""
+ if (tag && context) {
+ try {
+ await $fetch(`@patch/container-tags/${tag}`, {
+ body: { entityContext: context },
+ })
+ } catch {}
}
+ if (tag) onCreated?.(tag)
handleClose()
},
},
@@ -132,21 +163,21 @@ export function AddSpaceModal({
-
- Create new space
-
+ New space
+
- Create spaces to organize your memories and documents and create
- a context rich environment
+ Group related memories and give Nova context for this space.
+ {!showContext ? (
+
setShowContext(true)}
+ className={cn(
+ dmSansClassName(),
+ "flex cursor-pointer items-center gap-1.5 self-start pl-1 text-[13px] font-medium text-[#A3A3A3] transition-colors hover:text-[#fafafa]",
+ )}
+ >
+ +
+
+ Tell Nova what to remember
+
+ New
+
+
+
+ ) : (
+
+
+
+ What to remember
+
+
+ Optional
+
+
+
+ )}
+
) : (
<>
-
+
+
+
Create Space
>
)}
diff --git a/apps/web/components/app-experience.tsx b/apps/web/components/app-experience.tsx
new file mode 100644
index 00000000..259584ee
--- /dev/null
+++ b/apps/web/components/app-experience.tsx
@@ -0,0 +1,877 @@
+"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"
+import { getBackendUrl } from "@/lib/url-helpers"
+
+type DocumentsResponse = z.infer
+type DocumentWithMemories = DocumentsResponse["documents"][0]
+
+function subscribeViewportWidth(cb: () => void) {
+ window.addEventListener("resize", cb)
+ return () => window.removeEventListener("resize", cb)
+}
+
+function getViewportWidth() {
+ return window.innerWidth
+}
+
+const GRADIENT_TOP_WIDTH_MAX = 1440
+
+function gradientTopPositionForWidth(width: number) {
+ const minW = 320
+ const pctWide = 15
+ const pctNarrow = 55
+ const w = Math.min(GRADIENT_TOP_WIDTH_MAX, Math.max(minW, width))
+ const t = (w - minW) / (GRADIENT_TOP_WIDTH_MAX - minW)
+ const eased = t * t
+ return `${Math.round(pctNarrow + eased * (pctWide - pctNarrow))}%`
+}
+
+function ViewErrorFallback() {
+ return (
+
+
+ Something went wrong.{" "}
+ window.location.reload()}
+ >
+ Reload
+
+
+
+ )
+}
+
+export function AppExperience() {
+ const isMobile = useIsMobile()
+ const { user, session, isSessionPending, org } = useAuth()
+
+ const { selectedProject, selectedProjects, setSelectedProject } = useProject()
+ const selectedProjectTag = selectedProjects[0]
+ const { allProjects } = useContainerTags()
+ const dashboardSpaceLabel = useMemo(
+ () =>
+ getChatSpaceDisplayLabel({
+ selectedProject,
+ allProjects,
+ }),
+ [selectedProject, allProjects],
+ )
+ const emptyStateSpaceName = selectedProjectTag
+ ? selectedProjectTag === DEFAULT_PROJECT_ID
+ ? "My Space"
+ : (allProjects.find((p) => p.containerTag === selectedProjectTag)?.name ??
+ selectedProjectTag)
+ : undefined
+
+ const { viewMode, setViewMode } = useViewMode()
+ useLegacyViewRedirect()
+ const isCompanyBrain = useHasCompanyBrain()
+ const backendUrl = getBackendUrl()
+
+ // Slack 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(null)
+ const [queuedChatModel, setQueuedChatModel] = useState(null)
+ const [queuedChatReasoningEffort, setQueuedChatReasoningEffort] =
+ useState(null)
+ const [queuedChatProject, setQueuedChatProject] = useState(
+ null,
+ )
+ const [queuedChatAttachments, setQueuedChatAttachments] = useState<
+ ChatAttachmentDraft[] | null
+ >(null)
+ const [queuedHighlightContent, setQueuedHighlightContent] = useState<
+ string | null
+ >(null)
+ const [queuedMessageSource, setQueuedMessageSource] = useState<
+ "highlight" | "home"
+ >("highlight")
+ const [selectedDocument, setSelectedDocument] =
+ useState(null)
+
+ // Clear document when docId is removed (e.g. back button)
+ useEffect(() => {
+ if (!docId) setSelectedDocument(null)
+ }, [docId])
+
+ useEffect(() => {
+ if (viewMode === "dashboard") void setThreadIdUrl(null)
+ }, [viewMode, setThreadIdUrl])
+
+ // Resolve document from cache when loading with ?doc= (deep link / refresh)
+ useEffect(() => {
+ if (!docId || selectedDocument) return
+
+ const tryResolve = () => {
+ const queries = queryClient.getQueriesData<{
+ pages: DocumentsResponse[]
+ }>({ queryKey: ["documents-with-memories"] })
+ for (const [, data] of queries) {
+ if (!data?.pages) continue
+ for (const page of data.pages) {
+ const doc = page.documents?.find((d) => d.id === docId)
+ if (doc) {
+ setSelectedDocument(doc)
+ return true
+ }
+ }
+ }
+ return false
+ }
+
+ if (tryResolve()) return
+
+ const unsubscribe = queryClient.getQueryCache().subscribe(() => {
+ if (tryResolve()) unsubscribe()
+ })
+ return unsubscribe
+ }, [docId, selectedDocument, queryClient])
+
+ const resetDraft = useQuickNoteDraftReset(selectedProject)
+ const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "")
+ const quickNoteDraftRef = useRef(quickNoteDraft)
+ quickNoteDraftRef.current = quickNoteDraft
+
+ const { noteMutation, bulkDeleteMutation } = useDocumentMutations({
+ onClose: () => {
+ resetDraft()
+ setIsFullscreen(false)
+ },
+ })
+
+ const [selectedDocumentIds, setSelectedDocumentIds] = useState>(
+ new Set(),
+ )
+ const [isSelectionMode, setIsSelectionMode] = useState(false)
+
+ const handleToggleSelection = useCallback((documentId: string) => {
+ setSelectedDocumentIds((prev) => {
+ const next = new Set(prev)
+ if (next.has(documentId)) {
+ next.delete(documentId)
+ } else {
+ next.add(documentId)
+ }
+ return next
+ })
+ }, [])
+
+ const handleClearSelection = useCallback(() => {
+ setSelectedDocumentIds(new Set())
+ setIsSelectionMode(false)
+ }, [])
+
+ const handleEnterSelectionMode = useCallback(() => {
+ setIsSelectionMode(true)
+ }, [])
+
+ const handleSelectAllVisible = useCallback((visibleIds: string[]) => {
+ setSelectedDocumentIds((prev) => {
+ const next = new Set(prev)
+ for (const id of visibleIds) {
+ next.add(id)
+ }
+ return next
+ })
+ }, [])
+
+ const handleBulkDelete = useCallback(() => {
+ const ids = Array.from(selectedDocumentIds)
+ if (ids.length === 0) return
+ bulkDeleteMutation.mutate(
+ { documentIds: ids },
+ {
+ onSuccess: () => {
+ setSelectedDocumentIds(new Set())
+ setIsSelectionMode(false)
+ if (selectedDocument && ids.includes(selectedDocument.id ?? "")) {
+ setDocId(null)
+ }
+ },
+ },
+ )
+ }, [selectedDocumentIds, bulkDeleteMutation, selectedDocument, setDocId])
+
+ type SpaceHighlightsResponse = {
+ highlights: HighlightItem[]
+ questions: string[]
+ generatedAt: string
+ }
+
+ const HIGHLIGHTS_CACHE_NAME = "space-highlights-v1"
+ const HIGHLIGHTS_MAX_AGE = 4 * 60 * 60 * 1000 // 4 hours
+
+ const handleResetHighlights = useCallback(async () => {
+ toast.success("Refreshing daily brief…")
+ try {
+ await caches.delete(HIGHLIGHTS_CACHE_NAME)
+ } catch {}
+ setHighlightsForceAt(Date.now())
+ }, [])
+
+ const { data: highlightsData, isLoading: isLoadingHighlights } =
+ useQuery({
+ queryKey: ["space-highlights", selectedProject, highlightsForceAt],
+ queryFn: async (): Promise => {
+ const spaceId = selectedProject || "sm_project_default"
+ const forceRefresh = highlightsForceAt > 0
+ const cacheKey = `${backendUrl}/v3/space-highlights?spaceId=${spaceId}`
+
+ if (!forceRefresh) {
+ const cache = await caches.open(HIGHLIGHTS_CACHE_NAME)
+ const cached = await cache.match(cacheKey)
+ if (cached) {
+ const age =
+ Date.now() - Number(cached.headers.get("x-cached-at") || 0)
+ if (age < HIGHLIGHTS_MAX_AGE) {
+ return cached.json()
+ }
+ }
+ }
+
+ const response = await fetch(`${backendUrl}/v3/space-highlights`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({
+ spaceId,
+ highlightsCount: 3,
+ questionsCount: 4,
+ includeHighlights: true,
+ includeQuestions: true,
+ forceRefresh,
+ }),
+ })
+
+ if (!response.ok) {
+ throw new Error("Failed to fetch space highlights")
+ }
+
+ const data = await response.json()
+
+ // Update browser cache with fresh data (works for both normal and forced refresh)
+ try {
+ const freshCache = await caches.open(HIGHLIGHTS_CACHE_NAME)
+ const cacheResponse = new Response(JSON.stringify(data), {
+ headers: {
+ "Content-Type": "application/json",
+ "x-cached-at": String(Date.now()),
+ },
+ })
+ await freshCache.put(cacheKey, cacheResponse)
+ } catch {}
+
+ // Reset force flag after the forced fetch completes so future project-switches
+ // use the normal cache path instead of always bypassing it.
+ if (forceRefresh) setHighlightsForceAt(0)
+
+ return data
+ },
+ staleTime: HIGHLIGHTS_MAX_AGE,
+ refetchOnWindowFocus: false,
+ })
+
+ const { data: memoryOfDay = null } = useQuery({
+ queryKey: [
+ "memory-of-day",
+ user?.id,
+ org?.id,
+ new Date().toISOString().slice(0, 10),
+ ],
+ queryFn: async (): Promise => {
+ const cacheKey = `memory-of-day:v2:${user?.id}:${org?.id}:${new Date().toISOString().slice(0, 10)}`
+ try {
+ const stored = localStorage.getItem(cacheKey)
+ if (stored) return JSON.parse(stored) as MemoryOfDay
+ } catch {}
+
+ const response = await fetch(`${backendUrl}/v3/memory-of-day`, {
+ credentials: "include",
+ })
+ if (!response.ok) return null
+ const data = (await response.json()) as MemoryOfDay | null
+ if (data) {
+ try {
+ localStorage.setItem(cacheKey, JSON.stringify(data))
+ } catch {}
+ }
+ return data
+ },
+ staleTime: 24 * 60 * 60 * 1000,
+ refetchOnWindowFocus: false,
+ enabled: !!user && !!org,
+ })
+
+ useHotkeys("c", () => {
+ analytics.addDocumentModalOpened()
+ setAddDoc("note")
+ })
+ useHotkeys("mod+k", (e) => {
+ e.preventDefault()
+ analytics.searchOpened({ source: "hotkey" })
+ setIsSearchOpen(true)
+ })
+
+ const handleOpenDocument = useCallback(
+ (document: DocumentWithMemories) => {
+ if (document.id) {
+ analytics.documentModalOpened({ document_id: document.id })
+ setSelectedDocument(document)
+ setDocId(document.id)
+ }
+ },
+ [setDocId],
+ )
+
+ const handleOpenToolDocument = useCallback(
+ (document: DocumentWithMemories, pluginClientId: string) => {
+ const documentSpace = getToolDocumentSpace(document, pluginClientId)
+ if (documentSpace) {
+ setSelectedProject(documentSpace)
+ }
+ handleOpenDocument(document)
+ void setViewMode("list")
+ },
+ [handleOpenDocument, setSelectedProject, setViewMode],
+ )
+
+ // Separate from handleOpenDocument because the graph view only has a document ID,
+ // not the full document object. The modal will fetch the document via the docId
+ // query param, so there may be a brief loading state (unlike handleOpenDocument
+ // which pre-populates via setSelectedDocument).
+ const handleOpenDocumentById = useCallback(
+ (documentId: string) => {
+ analytics.documentModalOpened({ document_id: documentId })
+ setDocId(documentId)
+ },
+ [setDocId],
+ )
+
+ const handleQuickNoteSave = useCallback(
+ (content: string) => {
+ if (content.trim()) {
+ const hadPreviousContent = quickNoteDraftRef.current.trim().length > 0
+ noteMutation.mutate(
+ { content, project: selectedProject },
+ {
+ onSuccess: () => {
+ if (hadPreviousContent) {
+ analytics.quickNoteEdited()
+ } else {
+ analytics.quickNoteCreated()
+ }
+ },
+ },
+ )
+ }
+ },
+ [selectedProject, noteMutation],
+ )
+
+ const handleFullScreenSave = useCallback(
+ (content: string) => {
+ if (content.trim()) {
+ const hadInitialContent = fullscreenInitialContent.trim().length > 0
+ noteMutation.mutate(
+ { content, project: selectedProject },
+ {
+ onSuccess: () => {
+ if (hadInitialContent) {
+ analytics.quickNoteEdited()
+ } else {
+ analytics.quickNoteCreated()
+ }
+ },
+ },
+ )
+ }
+ },
+ [selectedProject, noteMutation, fullscreenInitialContent],
+ )
+
+ const handleMaximize = useCallback(
+ (content: string) => {
+ analytics.fullscreenNoteModalOpened()
+ setFullscreenInitialContent(content)
+ setIsFullscreen(true)
+ },
+ [setIsFullscreen],
+ )
+
+ const handleHighlightsChat = useCallback(
+ (highlightContent: string, userReply: string) => {
+ setQueuedHighlightContent(highlightContent)
+ setQueuedChatSeed(userReply)
+ setQueuedChatModel(null)
+ setQueuedChatReasoningEffort(null)
+ setQueuedChatProject(null)
+ setQueuedChatAttachments(null)
+ setQueuedMessageSource("highlight")
+ void setViewMode("chat")
+ },
+ [setViewMode],
+ )
+
+ const handleHomeChatStart = useCallback(
+ (
+ message: string,
+ model: ModelId,
+ projectId: string,
+ reasoningEffort: ReasoningEffort,
+ attachments?: ChatAttachmentDraft[],
+ ) => {
+ setQueuedHighlightContent(null)
+ setQueuedChatSeed(message)
+ setQueuedChatModel(model)
+ setQueuedChatReasoningEffort(reasoningEffort)
+ setQueuedChatProject(projectId)
+ setQueuedChatAttachments(attachments ?? null)
+ setQueuedMessageSource("home")
+ void setViewMode("chat")
+ },
+ [setViewMode],
+ )
+
+ const consumeQueuedChat = useCallback(() => {
+ setQueuedChatSeed(null)
+ setQueuedChatModel(null)
+ setQueuedChatReasoningEffort(null)
+ setQueuedChatProject(null)
+ setQueuedChatAttachments(null)
+ setQueuedHighlightContent(null)
+ setQueuedMessageSource("highlight")
+ }, [])
+
+ const handleHighlightsShowRelated = useCallback(
+ (query: string) => {
+ analytics.searchOpened({ source: "highlight_related" })
+ setSearchPrefill(query)
+ setIsSearchOpen(true)
+ },
+ [setSearchPrefill, setIsSearchOpen],
+ )
+
+ const handleOpenIntegrations = useCallback(
+ (integration?: IntegrationParamValue) => {
+ if (integration === "notion" || integration === "google-drive") {
+ void setAddDoc("connect")
+ return
+ }
+ void setViewMode(integration ?? "integrations")
+ },
+ [setViewMode, setAddDoc],
+ )
+
+ const handleOpenPlugins = useCallback(() => {
+ void setViewMode("plugins")
+ }, [setViewMode])
+
+ const handleAddMemory = useCallback(
+ (tab: "note" | "link") => {
+ analytics.addDocumentModalOpened()
+ setAddDoc(tab)
+ },
+ [setAddDoc],
+ )
+
+ const viewportWidth = useSyncExternalStore(
+ subscribeViewportWidth,
+ getViewportWidth,
+ () => GRADIENT_TOP_WIDTH_MAX,
+ )
+ const gradientTopPosition = gradientTopPositionForWidth(viewportWidth)
+
+ const isChatView = viewMode === "chat"
+ const showNovaBackdrop =
+ viewMode === "graph" ||
+ viewMode === "list" ||
+ viewMode === "dashboard" ||
+ viewMode === "digests"
+ const isDashboardShell =
+ viewMode === "dashboard" || (viewMode === "graph" && isMobile)
+ const isGraphMode = viewMode === "graph"
+ const showBottomNav = isMobile && !!session && !isChatView
+ const isPublicIntegrations =
+ !session && !isSessionPending && viewMode === "integrations"
+
+ return (
+
+
+
+ {showNovaBackdrop && (
+
+ )}
+ {isPublicIntegrations ? (
+
+ ) : !session && viewMode === "mcp" ? (
+
+ ) : (
+
{
+ analytics.addDocumentModalOpened()
+ setAddDoc("note")
+ }}
+ onOpenSearch={() => {
+ analytics.searchOpened({ source: "header" })
+ setIsSearchOpen(true)
+ }}
+ />
+ )}
+
+
+
+
}>
+ {isChatView ? (
+
+ {
+ if (!open) void setViewMode("dashboard")
+ }}
+ queuedMessage={queuedChatSeed}
+ queuedHighlightContent={queuedHighlightContent}
+ onConsumeQueuedMessage={consumeQueuedChat}
+ queuedMessageSource={queuedMessageSource}
+ queuedAttachments={queuedChatAttachments}
+ initialSelectedModel={queuedChatModel}
+ initialReasoningEffort={queuedChatReasoningEffort}
+ initialChatProject={queuedChatProject}
+ />
+
+ ) : viewMode === "integrations" ? (
+
+
+
+ ) : viewMode === "mcp" ? (
+
void setViewMode("integrations")}
+ />
+ ) : viewMode === "plugins" ? (
+ void setViewMode("integrations")}
+ >
+
+
+ ) : viewMode === "chrome" ? (
+ void setViewMode("integrations")}
+ >
+
+
+ ) : viewMode === "shortcuts" ? (
+ void setViewMode("integrations")}
+ >
+
+
+ ) : viewMode === "raycast" ? (
+ void setViewMode("integrations")}
+ >
+
+
+ ) : viewMode === "import" ? (
+ void setViewMode("integrations")}
+ />
+ ) : viewMode === "digests" ? (
+
+
+
+ ) : viewMode === "graph" ? (
+
+
+
+ ) : viewMode === "list" ? (
+
+
+
+ ) : isCompanyBrain ? (
+
+
+
+ ) : (
+ {
+ analytics.searchOpened({ source: "header" })
+ setIsSearchOpen(true)
+ }}
+ onOpenIntegrations={handleOpenIntegrations}
+ onOpenPlugins={handleOpenPlugins}
+ onNavigateToMemories={() => void setViewMode("list")}
+ onNavigateToGraph={() => void setViewMode("graph")}
+ onOpenDocument={handleOpenDocument}
+ onOpenToolDocument={handleOpenToolDocument}
+ onHighlightsChat={handleHighlightsChat}
+ onHighlightsShowRelated={handleHighlightsShowRelated}
+ onResetHighlights={handleResetHighlights}
+ onOpenDigests={() => void setViewMode("digests")}
+ memoryOfDay={memoryOfDay}
+ />
+ )}
+
+
+
+
+
+ {isDashboardShell && showBottomNav && (
+
+ )}
+ {isDashboardShell && !isCompanyBrain && (
+
+ )}
+
+ {showBottomNav && (
+ {
+ analytics.addDocumentModalOpened()
+ setAddDoc("note")
+ }}
+ onOpenSearch={() => {
+ analytics.searchOpened({ source: "header" })
+ setIsSearchOpen(true)
+ }}
+ />
+ )}
+
+ setAddDoc(null)}
+ />
+ {
+ setIsSearchOpen(open)
+ if (!open) setSearchPrefill("")
+ }}
+ projectId={selectedProject}
+ onOpenDocument={handleOpenDocument}
+ onAddMemory={() => {
+ analytics.addDocumentModalOpened()
+ setAddDoc("note")
+ }}
+ onOpenIntegrations={() => setViewMode("integrations")}
+ initialSearch={searchPrefill}
+ />
+ setDocId(null)}
+ />
+ setIsFullscreen(false)}
+ initialContent={fullscreenInitialContent}
+ onSave={handleFullScreenSave}
+ isSaving={noteMutation.isPending}
+ />
+
+
+ )
+}
diff --git a/apps/web/components/bottom-nav.tsx b/apps/web/components/bottom-nav.tsx
index c611de5a..af72c5a6 100644
--- a/apps/web/components/bottom-nav.tsx
+++ b/apps/web/components/bottom-nav.tsx
@@ -10,7 +10,6 @@ import {
LifeBuoy,
Settings,
} from "lucide-react"
-import { useRouter } from "next/navigation"
import { useQueryState } from "nuqs"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
@@ -25,6 +24,7 @@ import {
import { useViewMode, type ViewMode } from "@/lib/view-mode-context"
import { feedbackParam } from "@/lib/search-params"
import NovaOrb from "@/components/nova/nova-orb"
+import { useSettingsModal } from "@/components/settings/settings-modal"
const INTEGRATION_VIEWS: ViewMode[] = [
"integrations",
@@ -43,7 +43,7 @@ interface BottomNavProps {
}
export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
- const router = useRouter()
+ const { openSettings } = useSettingsModal()
const { viewMode, setViewMode } = useViewMode()
const [, setFeedbackOpen] = useQueryState("feedback", feedbackParam)
@@ -55,11 +55,11 @@ export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
-
+
void setViewMode("chat")}
- className="group relative flex size-11 shrink-0 items-center justify-center self-center rounded-full outline-none transition-transform hover:scale-[1.03] focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/50"
+ className="group relative flex size-11 shrink-0 items-center justify-center rounded-full shadow-[0_0_18px_rgba(75,160,250,0.35)] outline-none transition-transform hover:scale-[1.04] focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/60 active:scale-95"
>
@@ -127,7 +127,7 @@ export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
router.push("/settings")}
+ onClick={() => openSettings()}
/>
@@ -172,13 +172,13 @@ function NavTabButton({
aria-current={active ? "page" : undefined}
onClick={onClick}
className={cn(
- "flex shrink-0 flex-col items-center gap-1 rounded-full px-3 py-1.5 outline-none transition-colors",
+ "flex flex-1 flex-col items-center gap-1 rounded-lg py-1 outline-none transition-colors active:scale-95",
active ? "text-white" : "text-[#737373] hover:text-white",
)}
{...props}
>
{children}
- {label}
+ {label}
)
}
diff --git a/apps/web/components/brain-connector-icons.tsx b/apps/web/components/brain-connector-icons.tsx
new file mode 100644
index 00000000..b957a9e2
--- /dev/null
+++ b/apps/web/components/brain-connector-icons.tsx
@@ -0,0 +1,119 @@
+import { cn } from "@lib/utils"
+import { Granola, Notion } from "@ui/assets/icons"
+import { dmSans125ClassName } from "@/lib/fonts"
+
+export function SlackMark({ className }: { className?: string }) {
+ return (
+
+ Slack
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function GithubMark({ className }: { className?: string }) {
+ return (
+
+ GitHub
+
+
+ )
+}
+
+function LinearMark({ className }: { className?: string }) {
+ return (
+
+ Linear
+
+
+ )
+}
+
+function SentryMark({ className }: { className?: string }) {
+ return (
+
+ Sentry
+
+
+ )
+}
+
+function PostHogMark({ className }: { className?: string }) {
+ return (
+
+ PostHog
+
+
+ )
+}
+
+function LetterMark({ label, color }: { label: string; color: string }) {
+ return (
+
+ {label.charAt(0).toUpperCase()}
+
+ )
+}
+
+// Real brand logos where we have them, a branded lettermark otherwise.
+export function brainConnectorIcon(
+ slug: string,
+ name: string,
+ className = "size-[18px]",
+): React.ReactNode {
+ switch (slug) {
+ case "github":
+ return
+ case "linear":
+ return
+ case "slack":
+ return
+ case "notion":
+ return
+ case "granola":
+ return
+ case "sentry":
+ return
+ case "posthog":
+ return
+ default:
+ return
+ }
+}
diff --git a/apps/web/components/brain-home/brain-home-view.tsx b/apps/web/components/brain-home/brain-home-view.tsx
new file mode 100644
index 00000000..3be036fc
--- /dev/null
+++ b/apps/web/components/brain-home/brain-home-view.tsx
@@ -0,0 +1,442 @@
+"use client"
+
+import { $fetch } from "@lib/api"
+import { useAuth } from "@lib/auth-context"
+import { cn } from "@lib/utils"
+import { useQuery } from "@tanstack/react-query"
+import { ArrowRight, Check, FileText, Loader2, UserPlus } from "lucide-react"
+import Link from "next/link"
+import { useQueryState } from "nuqs"
+import { useSettingsModal } from "@/components/settings/settings-modal"
+import { dmSans125ClassName } from "@/lib/fonts"
+import { ConnectionsBoard } from "./connections-board"
+
+const BACKEND =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+
+const cardStyle = {
+ boxShadow:
+ "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
+}
+
+type RecentDoc = {
+ id?: string
+ title?: string | null
+ createdAt?: string | Date | null
+ updatedAt?: string | Date | null
+}
+
+function useBrainOverview() {
+ const { user, org } = useAuth()
+ const enabled = !!user && !!org?.id
+
+ const docs = useQuery({
+ queryKey: ["brain-recents", org?.id],
+ queryFn: async () => {
+ const res = await $fetch("@post/documents/documents", {
+ body: {
+ page: 1,
+ limit: 6,
+ sort: "createdAt",
+ order: "desc",
+ containerTags: [],
+ },
+ disableValidation: true,
+ })
+ if (res.error) throw new Error(res.error?.message)
+ return res.data as unknown as {
+ documents?: RecentDoc[]
+ pagination?: { totalItems?: number }
+ }
+ },
+ staleTime: 60_000,
+ enabled,
+ })
+
+ const lastUpdated = useQuery({
+ queryKey: ["brain-last-updated", org?.id],
+ queryFn: async () => {
+ const res = await $fetch("@post/documents/documents", {
+ body: {
+ page: 1,
+ limit: 1,
+ sort: "updatedAt",
+ order: "desc",
+ containerTags: [],
+ },
+ disableValidation: true,
+ })
+ if (res.error) return null
+ const docs = (res.data as { documents?: RecentDoc[] })?.documents
+ return docs?.[0]?.updatedAt ?? null
+ },
+ staleTime: 60_000,
+ enabled,
+ })
+
+ const connectors = useQuery({
+ queryKey: ["brain-home", "connectors"],
+ queryFn: async () => {
+ const res = await $fetch("@post/connections/list", {
+ body: { containerTags: [] },
+ })
+ if (res.error) return [] as Array<{ provider?: string }>
+ return (res.data ?? []) as Array<{ provider?: string }>
+ },
+ staleTime: 30_000,
+ enabled,
+ })
+
+ const brain = useQuery({
+ queryKey: ["brain-connections"],
+ queryFn: async () => {
+ const [c, s] = await Promise.all([
+ fetch(`${BACKEND}/brain/connections`, { credentials: "include" }),
+ fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
+ ])
+ const toolkits = c.ok
+ ? ((await c.json()) as { toolkits: { org: boolean; user: boolean }[] })
+ .toolkits
+ : []
+ const slack = s.ok
+ ? ((await s.json()) as { connected: boolean }).connected
+ : false
+ return {
+ activeCount: toolkits.filter((t) => t.org || t.user).length,
+ slack,
+ }
+ },
+ staleTime: 30_000,
+ enabled,
+ })
+
+ const mcp = useQuery({
+ queryKey: ["mcp-status"],
+ queryFn: async () => {
+ const res = await $fetch("@get/mcp/has-login")
+ if (res.error) return false
+ return Boolean((res.data as { previousLogin?: boolean })?.previousLogin)
+ },
+ staleTime: 60_000,
+ enabled,
+ })
+
+ const memoriesCount = docs.data?.pagination?.totalItems ?? 0
+ const connectedCount =
+ (brain.data?.activeCount ?? 0) +
+ (brain.data?.slack ? 1 : 0) +
+ (connectors.data?.length ?? 0)
+
+ const currentRole = org?.members
+ ?.find((m) => m.userId === user?.id)
+ ?.role?.toLowerCase()
+
+ return {
+ loading: docs.isPending,
+ recentDocs: docs.data?.documents ?? [],
+ lastUpdatedAt: lastUpdated.data ?? null,
+ memoriesCount,
+ connectedCount,
+ membersCount: org?.members?.length ?? 0,
+ canInvite: currentRole === "owner" || currentRole === "admin",
+ hasSource: connectedCount > 0,
+ hasAgent: mcp.data ?? false,
+ hasMemory: memoriesCount > 0,
+ }
+}
+
+export function BrainHomeView() {
+ const o = useBrainOverview()
+ const stepsDone = [o.hasSource, o.hasAgent, o.hasMemory].filter(
+ Boolean,
+ ).length
+ const showGettingStarted = !o.loading && stepsDone < 3
+
+ return (
+
+
+
+
+
+ {showGettingStarted && (
+
+ )}
+
+
+ )
+}
+
+function StatsRow({
+ memories,
+ connected,
+ members,
+ canInvite,
+ setupDone,
+ lastUpdatedAt,
+}: {
+ memories: number
+ connected: number
+ members: number
+ canInvite: boolean
+ setupDone: number
+ lastUpdatedAt: string | Date | null
+}) {
+ const { openSettings } = useSettingsModal()
+ const [, setInvite] = useQueryState("invite")
+
+ const onInvite = () => {
+ setInvite("1")
+ openSettings("account")
+ }
+
+ const tiles: {
+ label: string
+ value: string
+ action?: React.ReactNode
+ }[] = [
+ { label: "Memories", value: memories.toLocaleString() },
+ { label: "Connected sources", value: String(connected) },
+ {
+ label: "Active members",
+ value: String(members),
+ action: canInvite ? (
+
+
+ Invite
+
+ ) : undefined,
+ },
+ setupDone < 3
+ ? { label: "Setup", value: `${setupDone}/3` }
+ : {
+ label: "Last updated",
+ value: formatWhen(lastUpdatedAt) || "—",
+ },
+ ]
+ return (
+
+ {tiles.map((t) => (
+
+
+
+ {t.label}
+
+ {t.action}
+
+
+ {t.value}
+
+
+ ))}
+
+ )
+}
+
+function RecentMemories({
+ docs,
+ loading,
+}: {
+ docs: RecentDoc[]
+ loading: boolean
+}) {
+ return (
+
+
+ Recent memories
+
+
+ {loading ? (
+
+
+ Loading…
+
+ ) : docs.length === 0 ? (
+
+
+
+
+
+
+ No memories yet
+
+
+ Connect a source or ask your brain below — what you save shows up
+ here.
+
+
+
+ ) : (
+
+ )}
+
+ )
+}
+
+function GettingStarted({
+ hasSource,
+ hasAgent,
+ hasMemory,
+}: {
+ hasSource: boolean
+ hasAgent: boolean
+ hasMemory: boolean
+}) {
+ const steps = [
+ {
+ done: hasSource,
+ title: "Connect a source",
+ hint: "GitHub, Linear, Drive or Slack.",
+ href: "/settings/integrations",
+ },
+ {
+ done: hasAgent,
+ title: "Install a coding agent",
+ hint: "Claude Code, Codex or Cursor.",
+ href: "/settings/integrations",
+ },
+ {
+ done: hasMemory,
+ title: "Add your first memory",
+ hint: "Save a doc, or ask your brain below.",
+ },
+ ]
+ return (
+
+
+
+ Getting started
+
+
+ A few steps to make your brain useful.
+
+
+
+ {steps.map((step) => (
+
+
+ {step.done && }
+
+
+
+
+ {step.title}
+
+ {!step.done && step.href && (
+
+ Set up
+
+
+ )}
+
+ {!step.done && (
+
+ {step.hint}
+
+ )}
+
+
+ ))}
+
+
+ )
+}
+
+function formatWhen(value?: string | Date | null): string {
+ if (!value) return ""
+ const d = new Date(value)
+ if (Number.isNaN(d.getTime())) return ""
+ const min = Math.round((Date.now() - d.getTime()) / 60000)
+ if (min < 1) return "just now"
+ if (min < 60) return `${min}m`
+ const hr = Math.round(min / 60)
+ if (hr < 24) return `${hr}h`
+ const day = Math.round(hr / 24)
+ if (day < 7) return `${day}d`
+ return d.toLocaleDateString()
+}
diff --git a/apps/web/components/brain-home/connections-board.tsx b/apps/web/components/brain-home/connections-board.tsx
new file mode 100644
index 00000000..7f39f183
--- /dev/null
+++ b/apps/web/components/brain-home/connections-board.tsx
@@ -0,0 +1,460 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import { ArrowRight, Loader2 } from "lucide-react"
+import { useCallback, useEffect, useState } from "react"
+import { toast } from "sonner"
+import { dmSans125ClassName } from "@/lib/fonts"
+import { useSettingsModal } from "@/components/settings/settings-modal"
+import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
+
+// Apps surfaced on the dashboard; the rest live behind "More" in settings.
+const FEATURED_SLUGS = ["linear", "granola", "sentry"] as const
+// Example prompts on the right card — can include apps not shown on the left.
+const PREVIEW_PROMPT_SLUGS = ["linear", "granola", "github", "sentry"] as const
+
+const BACKEND =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+const MCP_BASE = `${BACKEND}/brain/mcp-connections`
+
+const cardStyle = {
+ boxShadow:
+ "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
+}
+const tileStyle = {
+ boxShadow:
+ "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
+}
+
+type AuthType = "oauth" | "static" | "none"
+type CatalogEntry = {
+ slug: string
+ name: string
+ category: string
+ authType: AuthType
+ tokenHint?: string
+}
+type ConnRow = {
+ serverSlug: string
+ status: "active" | "pending" | "error"
+ userId: string | null
+}
+
+// Example asks that connecting each app unlocks for the Slack agent.
+const AGENT_PROMPTS: Record = {
+ linear: "What's blocking the sprint?",
+ github: "Summarize the open PRs on auth",
+ sentry: "Any new errors since the deploy?",
+ notion: "Find our launch checklist",
+ posthog: "How's activation trending this week?",
+ plain: "What are customers asking about?",
+ granola: "Recap yesterday's standup",
+}
+
+function titleCase(s: string) {
+ return s.replace(/\b\w/g, (c) => c.toUpperCase())
+}
+
+export function ConnectionsBoard() {
+ const [catalog, setCatalog] = useState(null)
+ const [rows, setRows] = useState([])
+ const [slack, setSlack] = useState<{
+ connected: boolean
+ teamName: string | null
+ } | null>(null)
+ const [busy, setBusy] = useState(null)
+
+ const load = useCallback(async () => {
+ try {
+ const [cat, conn, s] = await Promise.all([
+ fetch(`${MCP_BASE}/catalog`, { credentials: "include" }),
+ fetch(`${MCP_BASE}/`, { credentials: "include" }),
+ fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
+ ])
+ // Parse each response independently so one bad payload can't strand the others.
+ try {
+ if (cat.ok) {
+ const data: { catalog?: CatalogEntry[] } = await cat.json()
+ setCatalog(data.catalog ?? [])
+ } else setCatalog([])
+ } catch {
+ setCatalog([])
+ }
+ try {
+ if (conn.ok) {
+ const data: { connections?: ConnRow[] } = await conn.json()
+ setRows(data.connections ?? [])
+ } else setRows([])
+ } catch {
+ setRows([])
+ }
+ try {
+ if (s.ok) setSlack(await s.json())
+ } catch {}
+ } catch {
+ setCatalog([])
+ setRows([])
+ }
+ }, [])
+
+ useEffect(() => {
+ void load()
+ const onFocus = () => void load()
+ window.addEventListener("focus", onFocus)
+ return () => window.removeEventListener("focus", onFocus)
+ }, [load])
+
+ const isConnected = (slug: string) =>
+ rows.some((r) => r.serverSlug === slug && r.status === "active")
+
+ const connect = async (entry: CatalogEntry) => {
+ setBusy(entry.slug)
+ try {
+ if (entry.authType === "static") {
+ const token = window.prompt(
+ `Paste a token for ${entry.name}.${entry.tokenHint ? `\n${entry.tokenHint}` : ""}`,
+ )
+ if (!token) return
+ const res = await fetch(`${MCP_BASE}/${entry.slug}/connect-static`, {
+ method: "POST",
+ credentials: "include",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ token, shared: false }),
+ })
+ if (!res.ok) {
+ toast.error("Couldn't connect.")
+ return
+ }
+ toast.success(`${entry.name} connected.`)
+ await load()
+ return
+ }
+ const res = await fetch(`${MCP_BASE}/${entry.slug}/connect`, {
+ method: "POST",
+ credentials: "include",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ shared: false,
+ redirectUrl: window.location.href,
+ }),
+ })
+ if (!res.ok) {
+ toast.error("Couldn't start the connection.")
+ return
+ }
+ const data: { authUrl?: string; ok?: boolean } = await res.json()
+ if (data.authUrl) window.open(data.authUrl, "_blank", "noopener")
+ else if (data.ok) {
+ toast.success(`${entry.name} connected.`)
+ await load()
+ } else toast.error("Couldn't start the connection.")
+ } catch {
+ toast.error("Couldn't start the connection.")
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ const { openSettings } = useSettingsModal()
+ const apps = catalog ?? []
+ const loading = catalog === null
+ const featured = FEATURED_SLUGS.map((slug) =>
+ apps.find((a) => a.slug === slug),
+ ).filter((a): a is CatalogEntry => Boolean(a))
+ const previewApps = PREVIEW_PROMPT_SLUGS.map((slug) =>
+ apps.find((a) => a.slug === slug),
+ ).filter((a): a is CatalogEntry => Boolean(a))
+ const remainingCount = Math.max(apps.length - featured.length, 0)
+ const connectedCount = apps.filter((a) => isConnected(a.slug)).length
+
+ return (
+
+ {slack && !slack.connected &&
}
+
+
+
+
+
+ Connect your tools
+
+
+ Give your Slack agent live access to the apps your team already
+ uses.
+
+
+
+
+ {loading ? (
+ Array.from({ length: 3 }).map((_, i) => (
+
+ ))
+ ) : (
+ <>
+ {featured.map((entry, i) => (
+
connect(entry)}
+ showDivider={i < featured.length - 1 || remainingCount > 0}
+ />
+ ))}
+ {remainingCount > 0 && (
+ openSettings("company-brain")}
+ />
+ )}
+ >
+ )}
+
+
+
+
+
+
+ )
+}
+
+function AgentPreview({
+ apps,
+ isConnected,
+ connectedCount,
+}: {
+ apps: CatalogEntry[]
+ isConnected: (slug: string) => boolean
+ connectedCount: number
+}) {
+ const prompts = apps
+ .filter((a) => AGENT_PROMPTS[a.slug])
+ .slice(0, 6)
+ .map((a) => ({
+ slug: a.slug,
+ name: a.name,
+ prompt: AGENT_PROMPTS[a.slug],
+ connected: isConnected(a.slug),
+ }))
+
+ return (
+
+
+
+ {connectedCount > 0
+ ? "Things your agent can answer now:"
+ : "Connect a tool and your agent can answer:"}
+
+
+
+ {prompts.map((p, i) => (
+
+
+ {brainConnectorIcon(p.slug, p.name, "size-4")}
+
+
+ "{p.prompt}"
+
+
+ ))}
+
+
+ )
+}
+
+function AppTile({
+ icon,
+ name,
+ subtitle,
+ connected,
+ busy,
+ onConnect,
+ showDivider = false,
+}: {
+ icon: React.ReactNode
+ name: string
+ subtitle: string
+ connected: boolean
+ busy: boolean
+ onConnect: () => void
+ showDivider?: boolean
+}) {
+ return (
+
+
+ {icon}
+
+
+
+ {name}
+
+
+ {subtitle}
+
+
+ {connected ? (
+
+
+ Connected
+
+ ) : (
+
+ {busy && }
+ Connect
+
+ )}
+
+ )
+}
+
+function MoreTile({ count, onClick }: { count: number; onClick: () => void }) {
+ return (
+
+
+ +{count}
+
+
+
+ {count} more {count === 1 ? "app" : "apps"}
+
+
+ Notion, PostHog, Plain and more
+
+
+
+
+ )
+}
+
+function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) {
+ return (
+
+ )
+}
+
+function SlackBanner() {
+ return (
+
+
+
+
+
+
+
+
+
+ Company Brain in Slack
+
+
+ Install Supermemory so your team can{" "}
+ @supermemory in any
+ channel.
+
+
+
+
+
+
+ Add to Slack
+
+
+
+ )
+}
diff --git a/apps/web/components/brain-home/data.ts b/apps/web/components/brain-home/data.ts
new file mode 100644
index 00000000..0a44cac5
--- /dev/null
+++ b/apps/web/components/brain-home/data.ts
@@ -0,0 +1,466 @@
+export type Stat = {
+ label: string
+ value: string
+ delta?: string
+ deltaTint?: "up" | "down" | "neutral"
+}
+
+export const STATS: Stat[] = [
+ {
+ label: "Items ingested",
+ value: "2,272",
+ delta: "+148 this week",
+ deltaTint: "up",
+ },
+ {
+ label: "Agent sessions",
+ value: "184",
+ delta: "+42 vs last wk",
+ deltaTint: "up",
+ },
+ {
+ label: "Active contributors",
+ value: "4",
+ delta: "of 6 invited",
+ deltaTint: "neutral",
+ },
+ {
+ label: "Top space",
+ value: "Gmail",
+ delta: "1,843 items",
+ deltaTint: "neutral",
+ },
+]
+
+export type AgentSeries = { key: string; label: string; color: string }
+
+export const AGENT_SERIES: AgentSeries[] = [
+ { key: "cursor", label: "Cursor", color: "#4BA0FA" },
+ { key: "claudeCode", label: "Claude Code", color: "#FF8A47" },
+ { key: "claudeDesktop", label: "Claude Desktop", color: "#B19CFF" },
+ { key: "chatgpt", label: "ChatGPT", color: "#10A37F" },
+ { key: "other", label: "Other", color: "#525D6E" },
+]
+
+export const SESSIONS_BY_DAY: {
+ label: string
+ values: Record
+}[] = [
+ {
+ label: "Mon",
+ values: {
+ cursor: 8,
+ claudeCode: 4,
+ claudeDesktop: 3,
+ chatgpt: 2,
+ other: 1,
+ },
+ },
+ {
+ label: "Tue",
+ values: {
+ cursor: 10,
+ claudeCode: 6,
+ claudeDesktop: 4,
+ chatgpt: 3,
+ other: 1,
+ },
+ },
+ {
+ label: "Wed",
+ values: {
+ cursor: 12,
+ claudeCode: 9,
+ claudeDesktop: 5,
+ chatgpt: 4,
+ other: 1,
+ },
+ },
+ {
+ label: "Thu",
+ values: {
+ cursor: 9,
+ claudeCode: 5,
+ claudeDesktop: 4,
+ chatgpt: 3,
+ other: 1,
+ },
+ },
+ {
+ label: "Fri",
+ values: {
+ cursor: 14,
+ claudeCode: 10,
+ claudeDesktop: 6,
+ chatgpt: 4,
+ other: 2,
+ },
+ },
+ {
+ label: "Sat",
+ values: {
+ cursor: 5,
+ claudeCode: 3,
+ claudeDesktop: 2,
+ chatgpt: 1,
+ other: 1,
+ },
+ },
+ {
+ label: "Sun",
+ values: {
+ cursor: 17,
+ claudeCode: 12,
+ claudeDesktop: 7,
+ chatgpt: 4,
+ other: 1,
+ },
+ },
+]
+
+export const RECENT_ANSWERS = [
+ {
+ question: "What did we decide about pricing?",
+ answer: "Pro stays at $20/mo. No overages, top-up only.",
+ who: "You",
+ when: "2h ago",
+ },
+ {
+ question: "What's blocking the Acme deal?",
+ answer: "MSA review — Jane drafting a net-30 counter.",
+ who: "You",
+ when: "4h ago",
+ },
+ {
+ question: "Who owns the onboarding rewrite?",
+ answer: "Design team. Ship target Friday.",
+ who: "Sarah Kim",
+ when: "Yesterday",
+ },
+]
+
+export const WEEKLY_DIGEST = {
+ range: "Mon – Fri",
+ headline:
+ "Your brain captured 642 items, surfaced 12 decisions, and answered 184 questions across the team.",
+ bullets: [
+ { label: "5 deadlines coming up next week", tint: "text-[#4BA0FA]" },
+ {
+ label: "2 conflicts surfaced between Notion and Slack",
+ tint: "text-[#FF8A47]",
+ },
+ {
+ label: "Sarah Kim led contributions with 14 docs",
+ tint: "text-[#A1A1AA]",
+ },
+ ],
+}
+
+export const TEAM_THIS_WEEK = [
+ {
+ name: "Sarah Kim",
+ email: "sarah@acme.com",
+ contributions: 14,
+ summary: "14 docs · 3 spaces",
+ color: "#FF8A47",
+ },
+ {
+ name: "Jane Doe",
+ email: "jane@acme.com",
+ contributions: 11,
+ summary: "11 docs · 2 spaces",
+ color: "#4BA0FA",
+ },
+ {
+ name: "Mahesh S.",
+ email: "mahesh@acme.com",
+ contributions: 9,
+ summary: "9 docs · 1 space",
+ color: "#A1A1AA",
+ },
+ {
+ name: "Alex Chen",
+ email: "alex@acme.com",
+ contributions: 7,
+ summary: "7 docs · 2 spaces",
+ color: "#10A37F",
+ },
+]
+
+export const TEAM_RECENT = [
+ { who: "Sarah Kim", what: "added a memo to acme-deal-q2", when: "14m" },
+ { who: "Jane Doe", what: "updated the Acme MSA draft", when: "1h" },
+ { who: "You", what: 'asked "What did we decide about pricing?"', when: "2h" },
+ {
+ who: "Sarah Kim",
+ what: "connected Notion to Sales space",
+ when: "Yesterday",
+ },
+]
+
+export type ActivityEvent = {
+ when: string
+ text: string
+ source: "drive" | "notion" | "slack" | "system" | "answer" | "decision"
+}
+
+export const ACTIVITY: ActivityEvent[] = [
+ { when: "2m", text: "23 new docs ingested from Drive", source: "drive" },
+ { when: "14m", text: "Jane added a memo to acme-deal-q2", source: "notion" },
+ {
+ when: "1h",
+ text: "Decision surfaced: pricing locked at $20 Pro",
+ source: "slack",
+ },
+ {
+ when: "3h",
+ text: "14 Notion pages updated by Sarah Kim",
+ source: "notion",
+ },
+ { when: "5h", text: "New entity formed: pricing-v3", source: "system" },
+ {
+ when: "Yesterday",
+ text: "MSA contract uploaded · auto-tagged contracts/MSA",
+ source: "drive",
+ },
+]
+
+export type TimelineKind =
+ | "answer"
+ | "decision"
+ | "ingest"
+ | "mention"
+ | "system"
+
+export type TimelineEvent = {
+ kind: TimelineKind
+ when: string
+ title: string
+ detail?: string
+ source?: string
+ who?: string
+}
+
+export const TIMELINE_EVENTS: TimelineEvent[] = [
+ {
+ kind: "answer",
+ when: "2m ago",
+ title: "What did we decide about pricing?",
+ detail: "Pro stays at $20/mo. No overages, top-up only.",
+ source: "You asked",
+ },
+ {
+ kind: "ingest",
+ when: "14m ago",
+ title: "23 new docs ingested from Drive",
+ detail: "Q2 roadmap, Acme MSA draft v3, customer feedback Q1, +20 more.",
+ source: "Drive",
+ },
+ {
+ kind: "decision",
+ when: "1h ago",
+ title: "Decision: pricing locked at $20 Pro",
+ detail:
+ "Decided in #leadership thread to keep Pro at $20/mo. No overages — top-up only.",
+ source: "Slack · #leadership",
+ },
+ {
+ kind: "answer",
+ when: "4h ago",
+ title: "What's blocking the Acme deal?",
+ detail: "MSA review — Jane drafting a net-30 counter.",
+ source: "You asked",
+ },
+ {
+ kind: "mention",
+ when: "Today",
+ title: "onboarding-rewrite mentioned in 3 docs",
+ detail: "Owner: design team · Ship target: Friday.",
+ source: "Drive",
+ who: "Mahesh",
+ },
+ {
+ kind: "system",
+ when: "5h ago",
+ title: "New entity formed: pricing-v3",
+ detail: "Linked across 4 sources · auto-tagged by the brain.",
+ },
+ {
+ kind: "ingest",
+ when: "Yesterday",
+ title: "MSA contract uploaded · auto-tagged contracts/MSA",
+ source: "Drive",
+ },
+ {
+ kind: "answer",
+ when: "Yesterday",
+ title: "Who owns the onboarding rewrite?",
+ detail: "Design team. Ship target Friday.",
+ source: "Sarah Kim asked",
+ },
+ {
+ kind: "ingest",
+ when: "Yesterday",
+ title: "14 Notion pages updated by Sarah Kim",
+ source: "Notion",
+ who: "Sarah Kim",
+ },
+]
+
+export type SourceStatus = "healthy" | "syncing" | "warning" | "error"
+
+export type ConnectedSource = {
+ id: string
+ name: string
+ iconKey: "drive" | "notion" | "gmail" | "onedrive" | "github"
+ status: SourceStatus
+ delta: string
+ when: string
+ statusMessage?: string
+}
+
+export const CONNECTED_SOURCES: ConnectedSource[] = [
+ {
+ id: "drive",
+ name: "Google Drive",
+ iconKey: "drive",
+ status: "healthy",
+ delta: "+24 items",
+ when: "12m ago",
+ },
+ {
+ id: "notion",
+ name: "Company Notion",
+ iconKey: "notion",
+ status: "warning",
+ delta: "Sync stalled",
+ when: "4h ago",
+ statusMessage: "Re-auth · token expired",
+ },
+ {
+ id: "gmail",
+ name: "Gmail",
+ iconKey: "gmail",
+ status: "syncing",
+ delta: "Ingesting…",
+ when: "Just now",
+ },
+]
+
+export type RecommendedSource = {
+ id: string
+ name: string
+ iconKey: "slack" | "linear" | "granola" | "github" | "calendar"
+ reason: string
+}
+
+export const RECOMMENDED_SOURCES: RecommendedSource[] = [
+ {
+ id: "slack",
+ name: "Slack",
+ iconKey: "slack",
+ reason: "Decisions and threads — most teams connect this.",
+ },
+ {
+ id: "github",
+ name: "GitHub",
+ iconKey: "github",
+ reason: "PRs, issues, READMEs from your repos.",
+ },
+ {
+ id: "linear",
+ name: "Linear",
+ iconKey: "linear",
+ reason: "Track project decisions and blockers.",
+ },
+]
+
+export type LiveSession = {
+ name: string
+ avatarColor: string
+ status: "live" | "idle" | "away"
+ tool: string
+ toolColor: string
+ doing?: string
+ elapsed?: string
+}
+
+export const LIVE_SESSIONS: LiveSession[] = [
+ {
+ name: "Sarah Kim",
+ avatarColor: "#FF8A47",
+ status: "live",
+ tool: "Cursor",
+ toolColor: "#4BA0FA",
+ doing: "Refactoring auth flow with brain context",
+ elapsed: "3m elapsed",
+ },
+ {
+ name: "Jane Doe",
+ avatarColor: "#4BA0FA",
+ status: "live",
+ tool: "Claude Code",
+ toolColor: "#FF8A47",
+ doing: "MSA review · pulling Acme history",
+ elapsed: "18m elapsed",
+ },
+ {
+ name: "You",
+ avatarColor: "#A1A1AA",
+ status: "idle",
+ tool: "Cursor",
+ toolColor: "#4BA0FA",
+ elapsed: "Idle · 12m",
+ },
+]
+
+export const TODAY_SESSIONS_SUMMARY: {
+ name: string
+ avatarColor: string
+ tool: string
+ count: number
+ lastAt: string
+}[] = [
+ {
+ name: "Alex Chen",
+ avatarColor: "#10A37F",
+ tool: "ChatGPT",
+ count: 4,
+ lastAt: "2h ago",
+ },
+ {
+ name: "Mahesh S.",
+ avatarColor: "#A1A1AA",
+ tool: "Claude Desktop",
+ count: 7,
+ lastAt: "3h ago",
+ },
+ {
+ name: "Sarah Kim",
+ avatarColor: "#FF8A47",
+ tool: "Cursor",
+ count: 11,
+ lastAt: "Now",
+ },
+]
+
+export type ChatMessage = { role: "user" | "brain"; text: string }
+
+export const SAMPLE_CHAT: ChatMessage[] = [
+ {
+ role: "user",
+ text: "What did we decide about pricing this quarter?",
+ },
+ {
+ role: "brain",
+ text: "Pro is locked at $20/mo. No overages — top-up only. Decided in the #leadership Slack thread on May 28.",
+ },
+ {
+ role: "user",
+ text: "Who's working on the Acme MSA?",
+ },
+ {
+ role: "brain",
+ text: "Jane Doe is drafting a net-30 counter to Acme's net-60 ask. Latest draft is in Drive (Acme · MSA draft v3.pdf).",
+ },
+]
diff --git a/apps/web/components/brain-home/widgets.tsx b/apps/web/components/brain-home/widgets.tsx
new file mode 100644
index 00000000..69ff83f3
--- /dev/null
+++ b/apps/web/components/brain-home/widgets.tsx
@@ -0,0 +1,671 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import { dmSans125ClassName } from "@/lib/fonts"
+import { GoogleDrive, Notion } from "@ui/assets/icons"
+import {
+ AlertCircle,
+ ArrowRight,
+ Calendar,
+ CheckCircle2,
+ FileText,
+ Github,
+ Loader2,
+ MessageSquare,
+ Plus,
+ Quote,
+ Sparkles,
+ Target,
+} from "lucide-react"
+import {
+ ACTIVITY,
+ AGENT_SERIES,
+ CONNECTED_SOURCES,
+ RECENT_ANSWERS,
+ RECOMMENDED_SOURCES,
+ SESSIONS_BY_DAY,
+ STATS,
+ TEAM_RECENT,
+ TEAM_THIS_WEEK,
+ WEEKLY_DIGEST,
+ type ActivityEvent,
+ type ConnectedSource,
+ type RecommendedSource,
+ type Stat,
+} from "./data"
+
+function GmailIcon({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+export const modalCardStyle = {
+ boxShadow:
+ "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
+}
+
+export const inputBevelStyle = {
+ boxShadow:
+ "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
+}
+
+export function StatsStrip({ stats = STATS }: { stats?: Stat[] }) {
+ return (
+
+ {stats.map((s) => (
+
+
+ {s.label}
+
+
+ {s.value}
+
+ {s.delta && (
+
+ {s.delta}
+
+ )}
+
+ ))}
+
+ )
+}
+
+export function SectionHeader({ title, cta }: { title: string; cta?: string }) {
+ return (
+
+
+ {title}
+
+ {cta && (
+
+ {cta}
+
+ )}
+
+ )
+}
+
+export function RecentAnswersCard() {
+ return (
+
+
+
+ Recent answers
+
+
+ See all →
+
+
+
+ {RECENT_ANSWERS.map((a) => (
+
+
+ {a.question}
+
+
+ → {a.answer}
+
+
+ {a.who} · {a.when}
+
+
+ ))}
+
+
+ )
+}
+
+export function WeeklyDigestCard() {
+ return (
+
+
+
+
+ This week
+
+
+ {WEEKLY_DIGEST.range}
+
+
+
+ {WEEKLY_DIGEST.headline.split(/(\d+)/).map((piece, i) =>
+ /^\d+$/.test(piece) ? (
+
+ {piece}
+
+ ) : (
+ {piece}
+ ),
+ )}
+
+
+ {WEEKLY_DIGEST.bullets.map((b) => (
+
+
+
+ {b.label}
+
+
+ ))}
+
+
+ Open weekly digest
+
+
+ )
+}
+
+export function BrainPulseBlock() {
+ return (
+
+ )
+}
+
+export function BrainActivityChart() {
+ const totalsByAgent = AGENT_SERIES.map((s) => ({
+ ...s,
+ total: SESSIONS_BY_DAY.reduce((sum, d) => sum + (d.values[s.key] ?? 0), 0),
+ }))
+ const grandTotal = totalsByAgent.reduce((sum, s) => sum + s.total, 0)
+ const dayTotals = SESSIONS_BY_DAY.map((d) =>
+ AGENT_SERIES.reduce((sum, s) => sum + (d.values[s.key] ?? 0), 0),
+ )
+ const max = Math.max(...dayTotals)
+ return (
+
+
+
+
+
+ Sessions by agent
+
+
+ {grandTotal} agent sessions, last 7 days — which tool the team
+ reached for.
+
+
+
+ {totalsByAgent.map((s) => (
+
+
+ {s.label}{" "}
+ {s.total}
+
+ ))}
+
+
+
+ {SESSIONS_BY_DAY.map((d, dayIdx) => {
+ const BAR_AREA_PX = 140
+ const totalForDay = dayTotals[dayIdx] ?? 0
+ const barHeightPx = Math.max(
+ 6,
+ Math.round((totalForDay / max) * BAR_AREA_PX),
+ )
+ return (
+
+
+
+ {AGENT_SERIES.map((series, sIdx) => {
+ const val = d.values[series.key] ?? 0
+ if (val === 0) return null
+ const segHeight =
+ totalForDay > 0 ? (val / totalForDay) * 100 : 0
+ return (
+
+ )
+ })}
+
+
+
+ {d.label}
+
+
+ )
+ })}
+
+
+ )
+}
+
+function activitySourceIcon(s: ActivityEvent["source"]) {
+ if (s === "drive") return
+ if (s === "notion") return
+ if (s === "slack")
+ return
+ if (s === "answer") return
+ if (s === "decision")
+ return
+ return
+}
+
+export function ActivityFeedBlock() {
+ return (
+
+
+
+ {ACTIVITY.map((a, i) => (
+
+
+ {activitySourceIcon(a.source)}
+
+
+ {a.text}
+
+
+ {a.when}
+
+
+ ))}
+
+
+ )
+}
+
+export function TeamActivityRail() {
+ return (
+
+
+
+ Team activity
+
+
+ This week
+
+
+
+ What your team's been adding to the brain.
+
+
+ {TEAM_THIS_WEEK.map((member) => (
+
+
+ {member.name[0]}
+
+
+
+ {member.name}
+
+
+ {member.summary}
+
+
+
+ {member.contributions}
+
+
+ ))}
+
+
+
+ Recent
+
+
+ {TEAM_RECENT.map((event, i) => (
+
+ {event.who} {event.what}
+ · {event.when}
+
+ ))}
+
+
+
+
+ Invite teammates
+
+
+ )
+}
+
+export function BrainHomeBackground() {
+ return (
+
+ )
+}
+
+export function Quotation({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+ )
+}
+
+function connectedSourceIcon(key: ConnectedSource["iconKey"]) {
+ if (key === "drive") return
+ if (key === "notion") return
+ if (key === "gmail") return
+ if (key === "github") return
+ return
+}
+
+function sourceStatusMeta(status: ConnectedSource["status"]) {
+ if (status === "healthy")
+ return {
+ dot: "bg-[#4BA0FA]",
+ tint: "text-[#4BA0FA]",
+ icon: ,
+ }
+ if (status === "syncing")
+ return {
+ dot: "bg-[#A1A1AA]",
+ tint: "text-[#A1A1AA]",
+ icon: ,
+ }
+ if (status === "warning")
+ return {
+ dot: "bg-[#FF8A47]",
+ tint: "text-[#FF8A47]",
+ icon: ,
+ }
+ return {
+ dot: "bg-[#FF5C5C]",
+ tint: "text-[#FF5C5C]",
+ icon: ,
+ }
+}
+
+export function SourcesHealthRail({ empty }: { empty?: boolean }) {
+ if (empty) {
+ return (
+
+
+ Sources health
+
+
+ Nothing connected yet — Drive, Notion, Gmail and more are ready when
+ you are.
+
+
+
+ Connect a source
+
+
+ )
+ }
+ const issueCount = CONNECTED_SOURCES.filter(
+ (s) => s.status === "warning" || s.status === "error",
+ ).length
+ return (
+
+
+
+ Sources health
+
+ {issueCount > 0 ? (
+
+ {issueCount} need attention
+
+ ) : (
+
+ All healthy
+
+ )}
+
+
+ Is the brain getting fed?
+
+
+
+
+ )
+}
+
+function recommendedIcon(key: RecommendedSource["iconKey"]) {
+ if (key === "slack")
+ return
+ if (key === "github") return
+ if (key === "linear") return
+ if (key === "granola") return
+ if (key === "calendar") return
+ return
+}
+
+export function RecommendedRail() {
+ return (
+
+
+ Add more to the brain
+
+
+ Teams like yours connect these next.
+
+
+
+
+ )
+}
diff --git a/apps/web/components/chat/attachments.ts b/apps/web/components/chat/attachments.ts
new file mode 100644
index 00000000..c627c303
--- /dev/null
+++ b/apps/web/components/chat/attachments.ts
@@ -0,0 +1,82 @@
+export const CHAT_ATTACHMENT_ACCEPT =
+ "image/*,.pdf,application/pdf,.doc,.docx,.txt,.md,.mdx,.markdown,text/markdown"
+
+export const CHAT_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024
+
+const SUPPORTED_EXTENSIONS = new Set([
+ ".pdf",
+ ".doc",
+ ".docx",
+ ".txt",
+ ".md",
+ ".mdx",
+ ".markdown",
+])
+
+export type ChatAttachment = {
+ id: string
+ documentId?: string
+ filename: string
+ mediaType: string
+ size: number
+ saveToMemory: boolean
+ status: "ready" | "processing" | "failed"
+ url?: string
+ contentPreview?: string
+}
+
+export type ChatAttachmentDraftStatus =
+ | "queued"
+ | "uploading"
+ | "uploaded"
+ | "error"
+
+export type ChatAttachmentDraft = {
+ id: string
+ file: File
+ saveToMemory: boolean
+ status: ChatAttachmentDraftStatus
+ errorMessage?: string
+ uploaded?: ChatAttachment
+}
+
+export type ChatAttachmentMessageMetadata = {
+ attachments?: ChatAttachment[]
+}
+
+export function isAcceptedChatAttachment(file: File): boolean {
+ if (file.size > CHAT_ATTACHMENT_MAX_BYTES) return false
+ const name = file.name.toLowerCase()
+ const ext = name.includes(".") ? name.slice(name.lastIndexOf(".")) : ""
+ if (SUPPORTED_EXTENSIONS.has(ext)) return true
+ if (file.type.startsWith("image/")) return true
+ if (file.type === "application/pdf") return true
+ if (file.type === "text/markdown") return true
+ return false
+}
+
+export function chatAttachmentKey(file: File): string {
+ return `${file.name}:${file.size}:${file.lastModified}`
+}
+
+export function createChatAttachmentDraft(file: File): ChatAttachmentDraft {
+ return {
+ id: crypto.randomUUID(),
+ file,
+ saveToMemory: true,
+ status: "queued",
+ }
+}
+
+export function formatAttachmentSize(size: number): string {
+ if (size < 1024) return `${size} B`
+ const kb = size / 1024
+ if (kb < 1024) return `${kb.toFixed(1)} KB`
+ return `${(kb / 1024).toFixed(1)} MB`
+}
+
+export function getChatMessageAttachments(metadata: unknown): ChatAttachment[] {
+ const attachments = (metadata as ChatAttachmentMessageMetadata | undefined)
+ ?.attachments
+ return Array.isArray(attachments) ? attachments : []
+}
diff --git a/apps/web/components/chat/home-chat-composer.tsx b/apps/web/components/chat/home-chat-composer.tsx
index 8f4188a8..7f9d28af 100644
--- a/apps/web/components/chat/home-chat-composer.tsx
+++ b/apps/web/components/chat/home-chat-composer.tsx
@@ -7,27 +7,115 @@ import { useProject } from "@/stores"
import { cn } from "@lib/utils"
import type { ModelId } from "@/lib/models"
import { SpaceSelector } from "@/components/space-selector"
+import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
+import { toast } from "sonner"
+import {
+ chatAttachmentKey,
+ CHAT_ATTACHMENT_ACCEPT,
+ createChatAttachmentDraft,
+ type ChatAttachmentDraft,
+ isAcceptedChatAttachment,
+} from "./attachments"
+import { ReasoningSelector } from "./reasoning-selector"
+import { getDefaultReasoningEffort, type ReasoningEffort } from "@/lib/models"
export function HomeChatComposer({
onStartChat,
className,
}: {
- onStartChat: (message: string, model: ModelId, projectId: string) => void
+ onStartChat: (
+ message: string,
+ model: ModelId,
+ projectId: string,
+ reasoningEffort: ReasoningEffort,
+ attachments?: ChatAttachmentDraft[],
+ ) => void
className?: string
}) {
const [input, setInput] = useState("")
- const [selectedModel, setSelectedModel] = useState("gemini-2.5-pro")
+ const [attachmentDrafts, setAttachmentDrafts] = useState<
+ ChatAttachmentDraft[]
+ >([])
+ const [selectedModel, setSelectedModel] = useState("grok-4.3")
+ const [reasoningEffort, setReasoningEffort] = useState(
+ getDefaultReasoningEffort("grok-4.3"),
+ )
const { selectedProject } = useProject()
const [chatSpaceProjects, setChatSpaceProjects] = useState([
- selectedProject,
+ AUTO_CHAT_SPACE_ID,
])
+ const handleModelChange = useCallback((model: ModelId) => {
+ setSelectedModel(model)
+ setReasoningEffort(getDefaultReasoningEffort(model))
+ }, [])
+
const send = useCallback(() => {
const t = input.trim()
- if (!t) return
- onStartChat(t, selectedModel, chatSpaceProjects[0] ?? selectedProject)
+ if (!t && attachmentDrafts.length === 0) return
+ onStartChat(
+ t,
+ selectedModel,
+ chatSpaceProjects[0] ?? selectedProject,
+ reasoningEffort,
+ attachmentDrafts,
+ )
setInput("")
- }, [chatSpaceProjects, input, onStartChat, selectedModel, selectedProject])
+ setAttachmentDrafts([])
+ }, [
+ attachmentDrafts,
+ chatSpaceProjects,
+ input,
+ onStartChat,
+ reasoningEffort,
+ selectedModel,
+ selectedProject,
+ ])
+
+ const handleAddAttachmentFiles = useCallback(
+ (files: FileList | File[]) => {
+ const incoming = Array.from(files)
+ const accepted = incoming.filter(isAcceptedChatAttachment)
+ const rejected = incoming.length - accepted.length
+ if (rejected > 0) {
+ toast.error(
+ rejected === 1
+ ? "One attachment is not supported or is over 50MB"
+ : `${rejected} attachments are not supported or are over 50MB`,
+ )
+ }
+ if (accepted.length === 0) return
+
+ const existingKeys = new Set(
+ attachmentDrafts.map((item) => chatAttachmentKey(item.file)),
+ )
+ const nextItems: ChatAttachmentDraft[] = []
+ let duplicateCount = 0
+ for (const file of accepted) {
+ const key = chatAttachmentKey(file)
+ if (existingKeys.has(key)) {
+ duplicateCount++
+ continue
+ }
+ existingKeys.add(key)
+ nextItems.push(createChatAttachmentDraft(file))
+ }
+ if (duplicateCount > 0) {
+ toast.message(
+ duplicateCount === 1
+ ? "Skipped duplicate attachment"
+ : `Skipped ${duplicateCount} duplicate attachments`,
+ )
+ }
+ if (nextItems.length === 0) return
+ setAttachmentDrafts((prev) => [...prev, ...nextItems])
+ },
+ [attachmentDrafts],
+ )
+
+ const handleRemoveAttachment = useCallback((id: string) => {
+ setAttachmentDrafts((prev) => prev.filter((item) => item.id !== id))
+ }, [])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
@@ -38,7 +126,7 @@ export function HomeChatComposer({
return (
-
+
setInput(e.target.value)}
@@ -46,23 +134,34 @@ export function HomeChatComposer({
onStop={() => {}}
onKeyDown={handleKeyDown}
isResponding={false}
+ attachments={attachmentDrafts}
+ onAddAttachmentFiles={handleAddAttachmentFiles}
+ onRemoveAttachment={handleRemoveAttachment}
+ canSend={input.trim().length > 0 || attachmentDrafts.length > 0}
+ attachmentAccept={CHAT_ATTACHMENT_ACCEPT}
showStatusStrip={false}
stackedToolbar={
- <>
-
-
- >
+
+ }
+ toolbarTrailing={
+
+ }
+ toolbarEnd={
+
}
/>
diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx
index db4be786..c78df7f7 100644
--- a/apps/web/components/chat/index.tsx
+++ b/apps/web/components/chat/index.tsx
@@ -7,6 +7,7 @@ import { useQueryState } from "nuqs"
import type { UIMessage } from "@ai-sdk/react"
import { motion } from "motion/react"
import { useChat } from "@ai-sdk/react"
+import { isWebSearchToolName } from "@/lib/chat-web-search-tools"
import { DefaultChatTransport } from "ai"
import NovaOrb from "@/components/nova/nova-orb"
import { Button } from "@ui/components/button"
@@ -19,11 +20,12 @@ import {
} from "@ui/components/sheet"
import { ScrollArea } from "@ui/components/scroll-area"
import {
- ArrowLeft,
+ ArrowLeftIcon,
Check,
ChevronDownIcon,
HistoryIcon,
Plus,
+ Search,
SquarePenIcon,
Trash2,
XIcon,
@@ -37,11 +39,19 @@ import { getNovaChatErrorCopy } from "@/lib/chat-stream-error"
import { useProject } from "@/stores"
import { useContainerTags } from "@/hooks/use-container-tags"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
-import { modelNames, type ModelId } from "@/lib/models"
+import {
+ getDefaultReasoningEffort,
+ modelNames,
+ type ModelId,
+ type ReasoningEffort,
+} from "@/lib/models"
import { SpaceSelector } from "@/components/space-selector"
import { SuperLoader } from "../superloader"
import { UserMessage } from "./message/user-message"
-import { AgentMessage } from "./message/agent-message"
+import {
+ AgentMessage,
+ isChatToolDisplayPartType,
+} from "./message/agent-message"
import { ChatGraphContextRail } from "./chat-graph-context-rail"
import { ChainOfThought } from "./input/chain-of-thought"
import { useIsMobile } from "@hooks/use-mobile"
@@ -52,6 +62,46 @@ import { useViewMode } from "@/lib/view-mode-context"
import { threadParam } from "@/lib/search-params"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import { ChatEmptyStatePlaceholder } from "./chat-empty-state"
+import { toast } from "sonner"
+import {
+ chatAttachmentKey,
+ CHAT_ATTACHMENT_ACCEPT,
+ createChatAttachmentDraft,
+ type ChatAttachment,
+ type ChatAttachmentDraft,
+ isAcceptedChatAttachment,
+} from "./attachments"
+import { cacheFileBlob, removeCachedFile } from "@/lib/file-cache"
+import { ReasoningSelector } from "./reasoning-selector"
+
+type ChatMessageSendSource = "typed" | "suggested" | "highlight" | "home"
+
+const DISCARD_ATTACHMENT_MAX_ATTEMPTS = 15
+const DISCARD_ATTACHMENT_RETRY_MS = 2000
+
+type RawChatAttachmentResponse = Partial
& {
+ attachment?: Partial
+}
+
+function normalizeChatAttachmentResponse(
+ data: RawChatAttachmentResponse,
+ draft: ChatAttachmentDraft,
+): ChatAttachment {
+ const attachment = data.attachment ?? data
+ const id = attachment.id ?? attachment.documentId ?? draft.id
+ return {
+ id,
+ documentId: attachment.documentId,
+ filename: attachment.filename ?? draft.file.name,
+ mediaType:
+ (attachment.mediaType ?? draft.file.type) || "application/octet-stream",
+ size: attachment.size ?? draft.file.size,
+ saveToMemory: attachment.saveToMemory ?? draft.saveToMemory,
+ status: attachment.status ?? "ready",
+ url: attachment.url,
+ contentPreview: attachment.contentPreview,
+ }
+}
export function ChatLaunchFab({
onOpen,
@@ -95,14 +145,42 @@ export function ChatLaunchFab({
)
}
+type QueuedChatMessage = {
+ id: string
+ text: string
+ model: ModelId
+ reasoningEffort: ReasoningEffort
+ attachments?: ChatAttachment[]
+}
+
+const CHAT_QUEUE_LIMIT = 3
+
+function normalizeModelId(value: unknown): ModelId | null {
+ if (typeof value !== "string") return null
+ return value in modelNames ? (value as ModelId) : null
+}
+
+function getMessageResponseModel(message: UIMessage): ModelId | null {
+ const metadata = (
+ message as UIMessage & { metadata?: Record }
+ ).metadata
+ return (
+ normalizeModelId(metadata?.model) ??
+ normalizeModelId(metadata?.responseModel) ??
+ null
+ )
+}
+
export function ChatSidebar({
isChatOpen,
- setIsChatOpen,
+ setIsChatOpen: _setIsChatOpen,
queuedMessage,
queuedHighlightContent,
onConsumeQueuedMessage,
queuedMessageSource = "highlight",
+ queuedAttachments = null,
initialSelectedModel = null,
+ initialReasoningEffort = null,
initialChatProject = null,
emptyStateSuggestions,
layout = "sidebar",
@@ -113,7 +191,9 @@ export function ChatSidebar({
queuedHighlightContent?: string | null
onConsumeQueuedMessage?: () => void
queuedMessageSource?: "highlight" | "home"
+ queuedAttachments?: ChatAttachmentDraft[] | null
initialSelectedModel?: ModelId | null
+ initialReasoningEffort?: ReasoningEffort | null
initialChatProject?: string | null
emptyStateSuggestions?: string[]
layout?: "sidebar" | "page"
@@ -121,11 +201,24 @@ export function ChatSidebar({
const isMobile = useIsMobile()
const isPageDesktop = layout === "page" && !isMobile
const [input, setInput] = useState("")
+ const [attachmentDrafts, setAttachmentDrafts] = useState<
+ ChatAttachmentDraft[]
+ >([])
+ const [isChatDraggingFiles, setIsChatDraggingFiles] = useState(false)
const [selectedModel, setSelectedModel] = useState(
- initialSelectedModel ?? "claude-sonnet-4.6",
+ initialSelectedModel ?? "grok-4.3",
+ )
+ const [reasoningEffort, setReasoningEffort] = useState(
+ initialReasoningEffort ??
+ getDefaultReasoningEffort(initialSelectedModel ?? "grok-4.3"),
)
const selectedModelRef = useRef(selectedModel)
selectedModelRef.current = selectedModel
+ const reasoningEffortRef = useRef(reasoningEffort)
+ reasoningEffortRef.current = reasoningEffort
+ const [messageQueue, setMessageQueue] = useState([])
+ const queuedDispatchInFlightRef = useRef(false)
+ const queuedDispatchSawResponseRef = useRef(false)
const [copiedMessageId, setCopiedMessageId] = useState(null)
const [hoveredMessageId, setHoveredMessageId] = useState(null)
const [messageFeedback, setMessageFeedback] = useState<
@@ -136,24 +229,60 @@ export function ChatSidebar({
const [isScrolledToBottom, setIsScrolledToBottom] = useState(true)
const [heightOffset, setHeightOffset] = useState(95)
const [isHistoryOpen, setIsHistoryOpen] = useState(false)
+ const [historyScope, setHistoryScope] = useState<"current" | "all">("current")
+ const [historySearch, setHistorySearch] = useState("")
const [threads, setThreads] = useState<
- Array<{ id: string; title: string; createdAt: string; updatedAt: string }>
+ Array<{
+ id: string
+ title: string
+ createdAt: string
+ updatedAt: string
+ space?: {
+ containerTag: string
+ name: string
+ emoji?: string
+ isDefault: boolean
+ }
+ }>
>([])
const [isLoadingThreads, setIsLoadingThreads] = useState(false)
const [confirmingDeleteId, setConfirmingDeleteId] = useState(
null,
)
const messagesContainerRef = useRef(null)
+ const chatDragDepthRef = useRef(0)
const isScrolledToBottomRef = useRef(true)
const userJustSentRef = useRef(false)
const sentQueuedMessageRef = useRef(null)
+ const truncateFromMessageIdRef = useRef(null)
+ const pendingRegenerationRef = useRef<{
+ text: string
+ } | null>(null)
+ const pendingSendSettingsRef = useRef<{
+ model: ModelId
+ reasoningEffort: ReasoningEffort
+ } | null>(null)
+ const pendingResponseModelsRef = useRef([])
+ const seenAssistantMessageIdsRef = useRef>(new Set())
+ const [responseModelByMessageId, setResponseModelByMessageId] = useState<
+ Record
+ >({})
+ const [regenerationBaseLength, setRegenerationBaseLength] = useState<
+ number | null
+ >(null)
const pendingHighlightReplyRef = useRef(null)
const awaitingHighlightInjectionRef = useRef(false)
const pendingHighlightMessageRef = useRef(null)
const targetHighlightChatIdRef = useRef(null)
+ const pendingRequestAttachmentsRef = useRef([])
+ const uploadPromisesRef = useRef>>(
+ new Map(),
+ )
+ const abortControllersRef = useRef>(new Map())
+ const discardedDraftIdsRef = useRef>(new Set())
const { selectedProject } = useProject()
const [chatSpaceProjects, setChatSpaceProjects] = useState([
- initialChatProject ?? selectedProject,
+ initialChatProject ?? AUTO_CHAT_SPACE_ID,
])
const chatProject = chatSpaceProjects[0] ?? selectedProject
const { allProjects } = useContainerTags()
@@ -225,22 +354,42 @@ export function ChatSidebar({
new DefaultChatTransport({
api: `${chatApiBase}/chat`,
credentials: "include",
- prepareSendMessagesRequest: ({ messages }) => ({
- body: {
- messages,
- metadata: {
- chatId: chatIdRef.current,
- projectId: selectedProjectRef.current,
- spaceMode:
- selectedProjectRef.current === AUTO_CHAT_SPACE_ID
- ? "auto"
- : "manual",
- enableSpaceDiscovery:
- selectedProjectRef.current === AUTO_CHAT_SPACE_ID,
- model: selectedModelRef.current,
+ prepareSendMessagesRequest: ({ messages }) => {
+ const sendSettings = pendingSendSettingsRef.current
+ pendingSendSettingsRef.current = null
+
+ // Tool parts (incl. Anthropic server-side web search) don't round-trip
+ // through convertToModelMessages and cause tool_use/tool_result mismatches.
+ const sanitizedMessages = messages.map((m) => ({
+ ...m,
+ parts: (m.parts ?? []).filter(
+ (p) => !p.type.startsWith("tool-") && p.type !== "dynamic-tool",
+ ),
+ }))
+
+ return {
+ body: {
+ messages: sanitizedMessages,
+ metadata: {
+ chatId: chatIdRef.current,
+ projectId: selectedProjectRef.current,
+ spaceMode:
+ selectedProjectRef.current === AUTO_CHAT_SPACE_ID
+ ? "auto"
+ : "manual",
+ enableSpaceDiscovery:
+ selectedProjectRef.current === AUTO_CHAT_SPACE_ID,
+ model: sendSettings?.model ?? selectedModelRef.current,
+ reasoningEffort:
+ sendSettings?.reasoningEffort ?? reasoningEffortRef.current,
+ truncateFromMessageId: truncateFromMessageIdRef.current,
+ ...(pendingRequestAttachmentsRef.current.length > 0 && {
+ attachments: pendingRequestAttachmentsRef.current,
+ }),
+ },
},
- },
- }),
+ }
+ },
}),
[chatApiBase],
)
@@ -292,14 +441,366 @@ export function ChatSidebar({
[error, selectedModel],
)
+ useEffect(() => {
+ if (error) {
+ pendingResponseModelsRef.current = []
+ }
+ }, [error])
+
+ useEffect(() => {
+ const updates: Record = {}
+
+ for (const message of messages) {
+ if (message.role !== "assistant") continue
+ if (seenAssistantMessageIdsRef.current.has(message.id)) continue
+
+ seenAssistantMessageIdsRef.current.add(message.id)
+ const responseModel =
+ getMessageResponseModel(message) ??
+ pendingResponseModelsRef.current.shift() ??
+ null
+
+ if (responseModel) {
+ updates[message.id] = responseModel
+ }
+ }
+
+ if (Object.keys(updates).length === 0) return
+ setResponseModelByMessageId((prev) => ({ ...prev, ...updates }))
+ }, [messages])
+
const handleModelChange = useCallback(
(modelId: ModelId) => {
setSelectedModel(modelId)
+ setReasoningEffort(getDefaultReasoningEffort(modelId))
clearError()
},
[clearError],
)
+ const setAttachmentDraftState = useCallback(
+ (id: string, patch: Partial) => {
+ setAttachmentDrafts((prev) =>
+ prev.map((item) => (item.id === id ? { ...item, ...patch } : item)),
+ )
+ },
+ [],
+ )
+
+ const discardUploadedAttachment = useCallback(
+ (documentId: string) => {
+ void removeCachedFile(documentId)
+
+ const run = async (attempt: number): Promise => {
+ try {
+ const response = await fetch(
+ `${chatApiBase}/chat/attachments/${documentId}`,
+ {
+ method: "DELETE",
+ credentials: "include",
+ },
+ )
+
+ if (
+ response.status === 409 &&
+ attempt < DISCARD_ATTACHMENT_MAX_ATTEMPTS
+ ) {
+ setTimeout(() => {
+ void run(attempt + 1)
+ }, DISCARD_ATTACHMENT_RETRY_MS)
+ return
+ }
+
+ if (!response.ok && response.status !== 404) {
+ console.warn("Failed to discard chat attachment", {
+ documentId,
+ status: response.status,
+ })
+ }
+ } catch (error) {
+ if (attempt < DISCARD_ATTACHMENT_MAX_ATTEMPTS) {
+ setTimeout(() => {
+ void run(attempt + 1)
+ }, DISCARD_ATTACHMENT_RETRY_MS)
+ return
+ }
+ console.warn("Failed to discard chat attachment", {
+ documentId,
+ error,
+ })
+ }
+ }
+
+ void run(1)
+ },
+ [chatApiBase],
+ )
+
+ const uploadAttachmentDraft = useCallback(
+ (
+ draft: ChatAttachmentDraft,
+ chatIdForUpload: string,
+ ): Promise => {
+ if (draft.status === "uploaded" && draft.uploaded) {
+ return Promise.resolve(draft.uploaded)
+ }
+
+ const inflight = uploadPromisesRef.current.get(draft.id)
+ if (inflight) return inflight
+
+ const uploadPromise = (async (): Promise => {
+ const controller = new AbortController()
+ abortControllersRef.current.set(draft.id, controller)
+
+ setAttachmentDraftState(draft.id, {
+ status: "uploading",
+ errorMessage: undefined,
+ })
+
+ const formData = new FormData()
+ formData.append("file", draft.file)
+ formData.append("threadId", chatIdForUpload)
+ formData.append("projectId", selectedProjectRef.current)
+ formData.append("saveToMemory", String(draft.saveToMemory))
+
+ try {
+ const response = await fetch(`${chatApiBase}/chat/attachments`, {
+ method: "POST",
+ body: formData,
+ credentials: "include",
+ signal: controller.signal,
+ })
+
+ if (!response.ok) {
+ let message = "Failed to upload attachment"
+ try {
+ const error = (await response.json()) as {
+ error?: string
+ message?: string
+ }
+ message = error.error ?? error.message ?? message
+ } catch {
+ // keep the fallback error
+ }
+ throw new Error(message)
+ }
+
+ const data = (await response.json()) as RawChatAttachmentResponse
+ const attachment = normalizeChatAttachmentResponse(data, draft)
+
+ abortControllersRef.current.delete(draft.id)
+
+ if (discardedDraftIdsRef.current.has(draft.id)) {
+ discardedDraftIdsRef.current.delete(draft.id)
+ if (attachment.documentId) {
+ discardUploadedAttachment(attachment.documentId)
+ }
+ return attachment
+ }
+
+ if (attachment.documentId) {
+ void cacheFileBlob(
+ attachment.documentId,
+ draft.file,
+ draft.file.type,
+ )
+ }
+ setAttachmentDraftState(draft.id, {
+ status: "uploaded",
+ uploaded: attachment,
+ })
+ return attachment
+ } catch (error) {
+ abortControllersRef.current.delete(draft.id)
+ uploadPromisesRef.current.delete(draft.id)
+
+ if (error instanceof DOMException && error.name === "AbortError") {
+ throw error
+ }
+
+ if (discardedDraftIdsRef.current.has(draft.id)) {
+ discardedDraftIdsRef.current.delete(draft.id)
+ throw error
+ }
+
+ const message =
+ error instanceof Error
+ ? error.message
+ : "Failed to upload attachment"
+ setAttachmentDraftState(draft.id, {
+ status: "error",
+ errorMessage: message,
+ })
+ throw error
+ }
+ })()
+
+ uploadPromisesRef.current.set(draft.id, uploadPromise)
+ return uploadPromise
+ },
+ [chatApiBase, discardUploadedAttachment, setAttachmentDraftState],
+ )
+
+ const uploadAttachmentDrafts = useCallback(
+ async (drafts: ChatAttachmentDraft[], chatIdForUpload: string) => {
+ const uploaded: ChatAttachment[] = []
+ for (const draft of drafts) {
+ uploaded.push(await uploadAttachmentDraft(draft, chatIdForUpload))
+ }
+ return uploaded
+ },
+ [uploadAttachmentDraft],
+ )
+
+ const handleAddAttachmentFiles = useCallback(
+ (files: FileList | File[]) => {
+ const incoming = Array.from(files)
+ const accepted = incoming.filter(isAcceptedChatAttachment)
+ const rejected = incoming.length - accepted.length
+ if (rejected > 0) {
+ toast.error(
+ rejected === 1
+ ? "One attachment is not supported or is over 50MB"
+ : `${rejected} attachments are not supported or are over 50MB`,
+ )
+ }
+ if (accepted.length === 0) return
+
+ const existingKeys = new Set(
+ attachmentDrafts.map((item) => chatAttachmentKey(item.file)),
+ )
+ const nextItems: ChatAttachmentDraft[] = []
+ let duplicateCount = 0
+ for (const file of accepted) {
+ const key = chatAttachmentKey(file)
+ if (existingKeys.has(key)) {
+ duplicateCount++
+ continue
+ }
+ existingKeys.add(key)
+ nextItems.push(createChatAttachmentDraft(file))
+ }
+ if (duplicateCount > 0) {
+ toast.message(
+ duplicateCount === 1
+ ? "Skipped duplicate attachment"
+ : `Skipped ${duplicateCount} duplicate attachments`,
+ )
+ }
+ if (nextItems.length === 0) return
+ setAttachmentDrafts((prev) => [...prev, ...nextItems])
+
+ for (const draft of nextItems) {
+ void uploadAttachmentDraft(draft, currentChatId).catch(() => {
+ // Upload errors are reflected on the draft state unless the draft was removed.
+ })
+ }
+ },
+ [attachmentDrafts, currentChatId, uploadAttachmentDraft],
+ )
+
+ const handleRemoveAttachment = useCallback(
+ (id: string) => {
+ const draft = attachmentDrafts.find((item) => item.id === id)
+ discardedDraftIdsRef.current.add(id)
+
+ const controller = abortControllersRef.current.get(id)
+ if (controller) {
+ controller.abort()
+ abortControllersRef.current.delete(id)
+ }
+ uploadPromisesRef.current.delete(id)
+
+ const documentId = draft?.uploaded?.documentId
+ if (draft?.status === "uploaded" && documentId) {
+ discardUploadedAttachment(documentId)
+ }
+
+ setAttachmentDrafts((prev) => prev.filter((item) => item.id !== id))
+ },
+ [attachmentDrafts, discardUploadedAttachment],
+ )
+
+ const handleRetryAttachment = useCallback(
+ (id: string) => {
+ const draft = attachmentDrafts.find((item) => item.id === id)
+ if (!draft) return
+ void uploadAttachmentDraft(draft, currentChatId)
+ },
+ [attachmentDrafts, currentChatId, uploadAttachmentDraft],
+ )
+
+ const hasDraggedFiles = useCallback(
+ (event: React.DragEvent) =>
+ Array.from(event.dataTransfer.types).includes("Files"),
+ [],
+ )
+
+ const resetChatFileDrag = useCallback(() => {
+ chatDragDepthRef.current = 0
+ setIsChatDraggingFiles(false)
+ }, [])
+
+ const handleChatDragEnter = useCallback(
+ (event: React.DragEvent) => {
+ if (!hasDraggedFiles(event)) return
+ event.preventDefault()
+ event.stopPropagation()
+
+ chatDragDepthRef.current += 1
+ if (status !== "submitted" && status !== "streaming") {
+ setIsChatDraggingFiles(true)
+ }
+ },
+ [hasDraggedFiles, status],
+ )
+
+ const handleChatDragOver = useCallback(
+ (event: React.DragEvent) => {
+ if (!hasDraggedFiles(event)) return
+ event.preventDefault()
+ event.stopPropagation()
+ event.dataTransfer.dropEffect =
+ status === "submitted" || status === "streaming" ? "none" : "copy"
+ },
+ [hasDraggedFiles, status],
+ )
+
+ const handleChatDragLeave = useCallback(
+ (event: React.DragEvent) => {
+ if (!hasDraggedFiles(event)) return
+ event.preventDefault()
+ event.stopPropagation()
+
+ chatDragDepthRef.current = Math.max(0, chatDragDepthRef.current - 1)
+ if (chatDragDepthRef.current === 0) {
+ setIsChatDraggingFiles(false)
+ }
+ },
+ [hasDraggedFiles],
+ )
+
+ const handleChatDrop = useCallback(
+ (event: React.DragEvent) => {
+ if (!hasDraggedFiles(event)) return
+ event.preventDefault()
+ event.stopPropagation()
+
+ resetChatFileDrag()
+ const files = event.dataTransfer.files
+ if (status !== "submitted" && status !== "streaming" && files.length) {
+ handleAddAttachmentFiles(files)
+ }
+ },
+ [handleAddAttachmentFiles, hasDraggedFiles, resetChatFileDrag, status],
+ )
+
+ useEffect(() => {
+ if (status === "submitted" || status === "streaming") {
+ resetChatFileDrag()
+ }
+ }, [resetChatFileDrag, status])
+
useEffect(() => {
if (pendingThreadLoad && currentChatId === pendingThreadLoad.id) {
setMessages(pendingThreadLoad.messages)
@@ -333,44 +834,207 @@ export function ChatSidebar({
}
}, [])
+ const submitChatMessage = useCallback(
+ async (
+ text: string,
+ source: ChatMessageSendSource,
+ drafts = attachmentDrafts,
+ ): Promise => {
+ const trimmed = text.trim()
+ if (!trimmed && drafts.length === 0) return false
+
+ const hasBusy = drafts.some(
+ (d) => d.status === "uploading" || d.status === "queued",
+ )
+ if (hasBusy) return false
+ const hasErrored = drafts.some((d) => d.status === "error")
+ if (hasErrored) return false
+
+ const chatIdForSend = threadId ?? fallbackChatId
+
+ try {
+ const uploadedAttachments =
+ drafts.length > 0
+ ? await uploadAttachmentDrafts(drafts, chatIdForSend)
+ : []
+ const messageText = trimmed || "Analyze the attached file(s)."
+ const isRespondingNow = status === "submitted" || status === "streaming"
+
+ if (isRespondingNow) {
+ if (messageQueue.length >= CHAT_QUEUE_LIMIT) return false
+ setMessageQueue((prev) => {
+ if (prev.length >= CHAT_QUEUE_LIMIT) return prev
+ return [
+ ...prev,
+ {
+ id: generateId(),
+ text: messageText,
+ model: selectedModel,
+ reasoningEffort,
+ attachments: uploadedAttachments,
+ },
+ ]
+ })
+ analytics.chatMessageSent({
+ source,
+ attachment_count: uploadedAttachments.length,
+ saved_attachment_count: uploadedAttachments.filter(
+ (attachment) => attachment.saveToMemory,
+ ).length,
+ temporary_attachment_count: uploadedAttachments.filter(
+ (attachment) => !attachment.saveToMemory,
+ ).length,
+ })
+ setInput("")
+ setAttachmentDrafts([])
+ uploadPromisesRef.current.clear()
+ abortControllersRef.current.clear()
+ discardedDraftIdsRef.current.clear()
+ userJustSentRef.current = true
+ scrollToBottom()
+ return true
+ }
+
+ truncateFromMessageIdRef.current = null
+ if (!threadId) setThreadId(fallbackChatId)
+ pendingRequestAttachmentsRef.current = uploadedAttachments
+ analytics.chatMessageSent({
+ source,
+ attachment_count: uploadedAttachments.length,
+ saved_attachment_count: uploadedAttachments.filter(
+ (attachment) => attachment.saveToMemory,
+ ).length,
+ temporary_attachment_count: uploadedAttachments.filter(
+ (attachment) => !attachment.saveToMemory,
+ ).length,
+ })
+
+ setInput("")
+ setAttachmentDrafts([])
+ uploadPromisesRef.current.clear()
+ abortControllersRef.current.clear()
+ discardedDraftIdsRef.current.clear()
+ userJustSentRef.current = true
+ scrollToBottom()
+ pendingResponseModelsRef.current.push(selectedModel)
+
+ void sendMessage({
+ text: messageText,
+ metadata:
+ uploadedAttachments.length > 0
+ ? { attachments: uploadedAttachments }
+ : undefined,
+ }).finally(() => {
+ pendingRequestAttachmentsRef.current = []
+ })
+
+ return true
+ } catch (error) {
+ pendingRequestAttachmentsRef.current = []
+ toast.error("Failed to send message", {
+ description:
+ error instanceof Error ? error.message : "Please try again.",
+ })
+ return false
+ }
+ },
+ [
+ attachmentDrafts,
+ fallbackChatId,
+ messageQueue.length,
+ reasoningEffort,
+ scrollToBottom,
+ selectedModel,
+ sendMessage,
+ setThreadId,
+ status,
+ threadId,
+ uploadAttachmentDrafts,
+ ],
+ )
+
const handleSend = () => {
- if (!input.trim() || status === "submitted" || status === "streaming")
- return
- if (!threadId) setThreadId(fallbackChatId)
- analytics.chatMessageSent({ source: "typed" })
- sendMessage({ text: input })
- setInput("")
- userJustSentRef.current = true
- scrollToBottom()
+ void submitChatMessage(input, "typed")
}
const handleSuggestedQuestion = useCallback(
(suggestion: string) => {
if (status === "submitted" || status === "streaming") return
- if (!threadId) setThreadId(fallbackChatId)
analytics.chatSuggestedQuestionClicked()
- analytics.chatMessageSent({ source: "suggested" })
- sendMessage({ text: suggestion })
+ void submitChatMessage(suggestion, "suggested", [])
+ },
+ [status, submitChatMessage],
+ )
+
+ const handleRegenerateFromUserMessage = useCallback(
+ (
+ messageId: string,
+ text: string,
+ model: ModelId,
+ nextReasoningEffort: ReasoningEffort,
+ ) => {
+ const trimmed = text.trim()
+ if (!trimmed || status === "submitted" || status === "streaming") return
+ const messageIndex = messages.findIndex(
+ (message) => message.id === messageId,
+ )
+ if (messageIndex === -1) return
+
+ truncateFromMessageIdRef.current = messageId
+ pendingSendSettingsRef.current = {
+ model,
+ reasoningEffort: nextReasoningEffort,
+ }
+ clearError()
+ pendingRegenerationRef.current = {
+ text: trimmed,
+ }
+ setRegenerationBaseLength(messageIndex)
+ setMessages(messages.slice(0, messageIndex))
userJustSentRef.current = true
scrollToBottom()
},
- [
- fallbackChatId,
- sendMessage,
- setThreadId,
- status,
- threadId,
- scrollToBottom,
- ],
+ [clearError, messages, scrollToBottom, setMessages, status],
)
+ useEffect(() => {
+ const pending = pendingRegenerationRef.current
+ if (
+ !pending ||
+ regenerationBaseLength === null ||
+ messages.length !== regenerationBaseLength ||
+ status !== "ready"
+ ) {
+ return
+ }
+
+ pendingRegenerationRef.current = null
+ setRegenerationBaseLength(null)
+ analytics.chatMessageSent({ source: "typed" })
+ queuedDispatchInFlightRef.current = true
+ queuedDispatchSawResponseRef.current = false
+ pendingResponseModelsRef.current.push(
+ pendingSendSettingsRef.current?.model ?? selectedModelRef.current,
+ )
+ sendMessage({ text: pending.text })
+ window.setTimeout(() => {
+ truncateFromMessageIdRef.current = null
+ }, 0)
+ }, [messages.length, regenerationBaseLength, sendMessage, status])
+
const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === "Enter" && !e.shiftKey) {
+ if (e.key === "Enter" && !e.shiftKey && !isMobile) {
e.preventDefault()
handleSend()
}
}
+ // Keep the user message on stop so it isn't lost when generation is halted
+ // before any assistant response arrives (ENG-732).
+ const handleStop = useCallback(() => {
+ stop()
+ }, [stop])
+
const handleCopyMessage = useCallback((messageId: string, text: string) => {
analytics.chatMessageCopied({ message_id: messageId })
navigator.clipboard.writeText(text)
@@ -426,13 +1090,28 @@ export function ChatSidebar({
setThreadId(null)
setFallbackChatId(newChatId)
setInput("")
+ setAttachmentDrafts([])
+ for (const controller of abortControllersRef.current.values()) {
+ controller.abort()
+ }
+ abortControllersRef.current.clear()
+ discardedDraftIdsRef.current.clear()
+ uploadPromisesRef.current.clear()
+ setMessageQueue([])
+ pendingResponseModelsRef.current = []
+ seenAssistantMessageIdsRef.current = new Set()
+ setResponseModelByMessageId({})
+ queuedDispatchInFlightRef.current = false
+ queuedDispatchSawResponseRef.current = false
}, [setThreadId, setMessages])
const fetchThreads = useCallback(async () => {
setIsLoadingThreads(true)
try {
+ const params = new URLSearchParams({ projectId: chatProject })
+ if (historyScope === "all") params.set("scope", "all")
const response = await fetch(
- `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?projectId=${chatProject}`,
+ `${chatApiBase}/chat/threads?${params.toString()}`,
{ credentials: "include" },
)
if (response.ok) {
@@ -444,7 +1123,7 @@ export function ChatSidebar({
} finally {
setIsLoadingThreads(false)
}
- }, [chatProject])
+ }, [chatApiBase, chatProject, historyScope])
useEffect(() => {
if (!isHistoryOpen) return
@@ -455,10 +1134,9 @@ export function ChatSidebar({
const loadThread = useCallback(
async (id: string) => {
try {
- const response = await fetch(
- `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${id}`,
- { credentials: "include" },
- )
+ const response = await fetch(`${chatApiBase}/chat/threads/${id}`, {
+ credentials: "include",
+ })
if (response.ok) {
const data = await response.json()
const uiMessages = data.messages.map(
@@ -467,20 +1145,31 @@ export function ChatSidebar({
role: string
parts: Array<{ type: string }>
createdAt: string
+ metadata?: Record
}) => ({
id: m.id,
role: m.role,
- // Strip tool parts — persisted format doesn't round-trip through
- // convertToModelMessages correctly and causes tool_use/tool_result
- // mismatch errors. Text history is sufficient for context.
+ // Keep chat tool outputs that are meaningful to render after thread reload.
parts: (m.parts || []).filter(
- (p) => p.type === "text" || p.type === "reasoning",
+ (p) =>
+ p.type === "text" ||
+ p.type === "reasoning" ||
+ p.type === "source-url" ||
+ p.type === "source-document" ||
+ isChatToolDisplayPartType(p.type),
),
+ metadata: m.metadata,
createdAt: new Date(m.createdAt),
}),
)
+ pendingResponseModelsRef.current = []
+ seenAssistantMessageIdsRef.current = new Set()
+ setResponseModelByMessageId({})
setThreadId(id)
setPendingThreadLoad({ id, messages: uiMessages })
+ setMessageQueue([])
+ queuedDispatchInFlightRef.current = false
+ queuedDispatchSawResponseRef.current = false
analytics.chatThreadLoaded({ thread_id: id })
setIsHistoryOpen(false)
setConfirmingDeleteId(null)
@@ -489,7 +1178,7 @@ export function ChatSidebar({
console.error("Failed to load thread:", error)
}
},
- [setThreadId],
+ [chatApiBase, setThreadId],
)
// Auto-restore thread from URL on mount (e.g. reload or direct link)
@@ -507,7 +1196,7 @@ export function ChatSidebar({
async (threadId: string) => {
try {
const response = await fetch(
- `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`,
+ `${chatApiBase}/chat/threads/${threadId}`,
{ method: "DELETE", credentials: "include" },
)
if (response.ok) {
@@ -523,7 +1212,7 @@ export function ChatSidebar({
setConfirmingDeleteId(null)
}
},
- [currentChatId, handleNewChat],
+ [chatApiBase, currentChatId, handleNewChat],
)
const formatRelativeTime = (isoString: string): string => {
@@ -569,10 +1258,18 @@ export function ChatSidebar({
setSelectedModel(initialSelectedModel)
return
}
+ if (
+ initialReasoningEffort &&
+ reasoningEffort !== initialReasoningEffort
+ ) {
+ setReasoningEffort(initialReasoningEffort)
+ return
+ }
sentQueuedMessageRef.current = queuedMessage
- analytics.chatMessageSent({ source: queuedMessageSource })
if (queuedHighlightContent) {
+ analytics.chatMessageSent({ source: queuedMessageSource })
+ truncateFromMessageIdRef.current = null
// Start a fresh thread for highlight-based chats to avoid overwriting existing conversations
const newChatId = generateId()
chatIdRef.current = newChatId
@@ -603,8 +1300,23 @@ export function ChatSidebar({
},
]
} else {
- if (!threadId) setThreadId(fallbackChatId)
- sendMessage({ text: queuedMessage })
+ if (queuedAttachments?.length) {
+ setAttachmentDrafts(queuedAttachments)
+ }
+ void submitChatMessage(
+ queuedMessage,
+ queuedMessageSource,
+ queuedAttachments ?? [],
+ ).then((sent) => {
+ if (!sent) {
+ setInput(queuedMessage)
+ if (queuedAttachments?.length) {
+ setAttachmentDrafts(queuedAttachments)
+ }
+ }
+ onConsumeQueuedMessage?.()
+ })
+ return
}
onConsumeQueuedMessage?.()
}
@@ -613,14 +1325,15 @@ export function ChatSidebar({
queuedMessage,
queuedHighlightContent,
queuedMessageSource,
+ queuedAttachments,
initialSelectedModel,
+ initialReasoningEffort,
selectedModel,
+ reasoningEffort,
status,
- sendMessage,
onConsumeQueuedMessage,
- fallbackChatId,
setThreadId,
- threadId,
+ submitChatMessage,
])
// Inject the pending highlight assistant message once the new Chat instance is ready.
@@ -654,6 +1367,10 @@ export function ChatSidebar({
awaitingHighlightInjectionRef.current = false
const reply = pendingHighlightReplyRef.current
pendingHighlightReplyRef.current = null
+ truncateFromMessageIdRef.current = null
+ queuedDispatchInFlightRef.current = true
+ queuedDispatchSawResponseRef.current = false
+ pendingResponseModelsRef.current.push(selectedModelRef.current)
sendMessage({ text: reply })
}
}, [messages, sendMessage, status])
@@ -665,6 +1382,66 @@ export function ChatSidebar({
}
}, [queuedMessage])
+ useEffect(() => {
+ const isRespondingNow = status === "submitted" || status === "streaming"
+ if (isRespondingNow) {
+ if (queuedDispatchInFlightRef.current) {
+ queuedDispatchSawResponseRef.current = true
+ }
+ return
+ }
+
+ if (status !== "ready") {
+ queuedDispatchInFlightRef.current = false
+ queuedDispatchSawResponseRef.current = false
+ return
+ }
+
+ if (queuedDispatchInFlightRef.current) {
+ if (!queuedDispatchSawResponseRef.current) return
+ queuedDispatchInFlightRef.current = false
+ queuedDispatchSawResponseRef.current = false
+ }
+
+ const nextMessage = messageQueue[0]
+ if (!nextMessage) return
+
+ queuedDispatchInFlightRef.current = true
+ queuedDispatchSawResponseRef.current = false
+ truncateFromMessageIdRef.current = null
+ pendingSendSettingsRef.current = {
+ model: nextMessage.model,
+ reasoningEffort: nextMessage.reasoningEffort,
+ }
+ pendingResponseModelsRef.current.push(nextMessage.model)
+ if (!threadId) setThreadId(fallbackChatId)
+ setMessageQueue((prev) =>
+ prev[0]?.id === nextMessage.id
+ ? prev.slice(1)
+ : prev.filter((item) => item.id !== nextMessage.id),
+ )
+ pendingRequestAttachmentsRef.current = nextMessage.attachments ?? []
+ void sendMessage({
+ text: nextMessage.text,
+ metadata:
+ nextMessage.attachments && nextMessage.attachments.length > 0
+ ? { attachments: nextMessage.attachments }
+ : undefined,
+ }).finally(() => {
+ pendingRequestAttachmentsRef.current = []
+ })
+ userJustSentRef.current = true
+ scrollToBottom()
+ }, [
+ fallbackChatId,
+ messageQueue,
+ scrollToBottom,
+ sendMessage,
+ setThreadId,
+ status,
+ threadId,
+ ])
+
// Scroll to bottom when a new user message is added or a thread is loaded
useEffect(() => {
const lastMessageId = messages[messages.length - 1]?.id ?? null
@@ -781,8 +1558,68 @@ export function ChatSidebar({
const isStackedInput = layout === "page"
const showHeaderRow = !isPageDesktop || isMobile || !isStackedInput
const isResponding = status === "submitted" || status === "streaming"
+ const isWebSearching =
+ isResponding &&
+ (() => {
+ const last = messages[messages.length - 1]
+ if (!last || last.role !== "assistant") return false
+ const parts = last.parts ?? []
+ const searching = parts.some(
+ (p) =>
+ (p.type === "dynamic-tool" &&
+ isWebSearchToolName((p as { toolName?: string }).toolName ?? "")) ||
+ (p.type.startsWith("tool-") &&
+ isWebSearchToolName(p.type.slice("tool-".length))),
+ )
+ const hasText = parts.some(
+ (p) => p.type === "text" && (p as { text?: string }).text?.trim(),
+ )
+ return searching && !hasText
+ })()
+ const hasBusyAttachment = attachmentDrafts.some(
+ (attachment) =>
+ attachment.status === "uploading" || attachment.status === "queued",
+ )
+ const hasErroredAttachment = attachmentDrafts.some(
+ (attachment) => attachment.status === "error",
+ )
+ const canSendMessage =
+ (input.trim().length > 0 || attachmentDrafts.length > 0) &&
+ !hasBusyAttachment &&
+ !hasErroredAttachment
const showInputStatusStrip =
!isStackedInput || isResponding || messages.length > 0
+ const isQueueFull = messageQueue.length >= CHAT_QUEUE_LIMIT
+
+ const threadGroups = useMemo(() => {
+ const q = historySearch.trim().toLowerCase()
+ const filtered = q
+ ? threads.filter((t) => (t.title || "").toLowerCase().includes(q))
+ : threads
+ const now = new Date()
+ const startOfToday = new Date(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate(),
+ ).getTime()
+ const day = 86_400_000
+ const buckets: Array<{ label: string; items: typeof threads }> = [
+ { label: "Today", items: [] },
+ { label: "Yesterday", items: [] },
+ { label: "Previous 7 days", items: [] },
+ { label: "Previous 30 days", items: [] },
+ { label: "Older", items: [] },
+ ]
+ for (const t of filtered) {
+ const ts = new Date(t.updatedAt).getTime()
+ if (ts >= startOfToday) buckets[0].items.push(t)
+ else if (ts >= startOfToday - day) buckets[1].items.push(t)
+ else if (ts >= startOfToday - 7 * day) buckets[2].items.push(t)
+ else if (ts >= startOfToday - 30 * day) buckets[3].items.push(t)
+ else buckets[4].items.push(t)
+ }
+ return buckets.filter((b) => b.items.length > 0)
+ }, [threads, historySearch])
const chatHistorySheet = (
button]:text-[#FAFAFA]",
dmSansClassName(),
)}
>
-
- Chat History
-
- Space: {chatSpaceLabel}
+
+
+ Chat History
+
+ {historyScope === "all" ? "All spaces" : chatSpaceLabel}
+
+
+
+ {historyScope === "all"
+ ? "All conversations across your spaces"
+ : `Conversations in ${chatSpaceLabel}`}
+
+
+
+ setHistorySearch(e.target.value)}
+ placeholder="Search conversations…"
+ className="h-9 w-full rounded-lg border border-[#161F2C] bg-[#0F141B] pr-3 pl-9 text-sm text-white placeholder:text-[#737373] focus:border-[#267BF1]/50 focus:outline-none"
+ />
+
+
+ {(["current", "all"] as const).map((scope) => (
+ setHistoryScope(scope)}
+ className={cn(
+ "rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
+ historyScope === scope
+ ? "bg-[#267BF1]/15 text-[#FAFAFA]"
+ : "text-[#737373] hover:text-[#FAFAFA]",
+ )}
+ >
+ {scope === "current" ? "Current" : "All"}
+
+ ))}
+
+
-
-
+
+
{isLoadingThreads ? (
@@ -818,79 +1692,103 @@ export function ChatSidebar({
No conversations yet
+ ) : threadGroups.length === 0 ? (
+
+ No conversations match “{historySearch.trim()}”
+
) : (
-
- {threads.map((thread) => {
- const isActive = thread.id === currentChatId
- return (
-
loadThread(thread.id)}
- className={cn(
- "flex w-full items-center justify-between rounded-md px-3 py-2 text-left transition-colors",
- isActive ? "bg-[#267BF1]/10" : "hover:bg-[#17181A]",
- )}
- >
-
-
- {thread.title || "Untitled Chat"}
-
-
- {formatRelativeTime(thread.updatedAt)}
-
-
- {confirmingDeleteId === thread.id ? (
-
- {
- e.stopPropagation()
- deleteThread(thread.id)
- }}
- className="size-7 bg-red-500 text-white hover:bg-red-600"
- >
-
-
- {
- e.stopPropagation()
- setConfirmingDeleteId(null)
- }}
- className="size-7"
- >
-
-
-
- ) : (
-
+ {threadGroups.map((group) => (
+
+
+ {group.label}
+
+ {group.items.map((thread) => {
+ const isActive = thread.id === currentChatId
+ const isConfirming = confirmingDeleteId === thread.id
+ return (
+
{
- e.stopPropagation()
- setConfirmingDeleteId(thread.id)
- }}
- className="ml-2 size-7"
+ onClick={() => loadThread(thread.id)}
+ className={cn(
+ "group flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors",
+ isActive ? "bg-[#267BF1]/10" : "hover:bg-[#17181A]",
+ )}
>
-
-
- )}
-
- )
- })}
+
+
+ {thread.title || "Untitled Chat"}
+
+
+
+ {formatRelativeTime(thread.updatedAt)}
+
+ {historyScope === "all" && thread.space ? (
+ <>
+ ·
+
+ {thread.space.emoji
+ ? `${thread.space.emoji} `
+ : ""}
+ {thread.space.name}
+
+ >
+ ) : null}
+
+
+ {isConfirming ? (
+
+ {
+ e.stopPropagation()
+ deleteThread(thread.id)
+ }}
+ className="size-7 bg-red-500 text-white hover:bg-red-600"
+ >
+
+
+ {
+ e.stopPropagation()
+ setConfirmingDeleteId(null)
+ }}
+ className="size-7"
+ >
+
+
+
+ ) : (
+
{
+ e.stopPropagation()
+ setConfirmingDeleteId(thread.id)
+ }}
+ className="size-7 shrink-0 opacity-0 transition-opacity focus:opacity-100 group-hover:opacity-100 max-sm:opacity-100"
+ >
+
+
+ )}
+
+ )
+ })}
+
+ ))}
)}
-
+
{
handleNewChat()
setIsHistoryOpen(false)
@@ -942,6 +1840,27 @@ export function ChatSidebar({
) : null
+ const chatDropOverlay = isChatDraggingFiles ? (
+
+
+ Drop files to attach
+
+
+ ) : null
+ const chatDropTargetProps = {
+ onDragEnter: handleChatDragEnter,
+ onDragOver: handleChatDragOver,
+ onDragLeave: handleChatDragLeave,
+ onDrop: handleChatDrop,
+ onDragEnd: resetChatFileDrag,
+ }
+
const shell = (
<>
{showHeaderRow ? (
@@ -950,24 +1869,22 @@ export function ChatSidebar({
"flex items-center justify-between px-0 z-10",
isPageDesktop
? "relative shrink-0 pt-2 pb-1"
- : "absolute top-0 right-0 left-0 pt-4 px-4",
+ : isMobile
+ ? "relative shrink-0 px-4 pt-4 pb-2"
+ : "absolute top-0 right-0 left-0 pt-4 px-4",
!isMobile && !isPageDesktop && "rounded-t-2xl",
)}
>
- {layout === "page" && isMobile && (
-
setIsChatOpen(false)}
- aria-label="Back to memories"
+ onClick={() => _setIsChatOpen(false)}
+ aria-label="Back"
+ className="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-[#161F2C] bg-black text-[#FAFAFA] transition-colors hover:bg-[#161F2C]"
>
-
-
+
+
)}
{!isStackedInput && (
<>
@@ -975,6 +1892,10 @@ export function ChatSidebar({
selectedModel={selectedModel}
onModelChange={handleModelChange}
/>
+
0
? cn(
- "flex flex-col space-y-3 min-h-full justify-end",
- isPageDesktop ? "pt-2" : "pt-14",
+ "flex flex-col space-y-3 min-h-full justify-end pb-4",
+ isPageDesktop || isMobile ? "pt-2" : "pt-14",
)
: ""
}
@@ -1044,7 +1965,10 @@ export function ChatSidebar({
) : (
))}
- {(status === "submitted" || status === "streaming") && (
-
-
-
- )}
@@ -1090,8 +2010,7 @@ export function ChatSidebar({
@@ -1142,23 +2061,39 @@ export function ChatSidebar({
className={cn(
"shrink-0",
isStackedInput &&
- "pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6",
+ (isMobile
+ ? "px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))]"
+ : "px-4 pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6"),
)}
>
setInput(e.target.value)}
onSend={handleSend}
- onStop={stop}
+ onStop={handleStop}
onKeyDown={handleKeyDown}
isResponding={isResponding}
+ attachments={attachmentDrafts}
+ onAddAttachmentFiles={handleAddAttachmentFiles}
+ onRemoveAttachment={handleRemoveAttachment}
+ onRetryAttachment={handleRetryAttachment}
+ canSend={canSendMessage}
+ attachmentAccept={CHAT_ATTACHMENT_ACCEPT}
+ disableFileDropZone
+ sendDisabled={isResponding && isQueueFull}
+ sendDisabledTooltip={`Queue is full (${CHAT_QUEUE_LIMIT} max)`}
activeStatus={
- status === "submitted"
- ? "Thinking…"
- : status === "streaming"
- ? "Structuring response…"
- : "Waiting for input…"
+ isResponding && isQueueFull
+ ? `Queue full (${CHAT_QUEUE_LIMIT} max)`
+ : isWebSearching
+ ? "Searching the web…"
+ : status === "submitted"
+ ? "Thinking…"
+ : status === "streaming"
+ ? "Thinking…"
+ : "Waiting for input…"
}
+ queuedMessages={messageQueue}
showStatusStrip={showInputStatusStrip}
onExpandedChange={setIsInputExpanded}
chainOfThoughtComponent={
@@ -1166,21 +2101,31 @@ export function ChatSidebar({
}
stackedToolbar={
isStackedInput ? (
- <>
-
-
- >
+
+ ) : undefined
+ }
+ toolbarTrailing={
+ isStackedInput ? (
+
+ ) : undefined
+ }
+ toolbarEnd={
+ isStackedInput ? (
+
) : undefined
}
/>
@@ -1194,38 +2139,30 @@ export function ChatSidebar({
className={cn(
"relative flex flex-col backdrop-blur-md",
isMobile
- ? "fixed inset-0 z-50 m-0 h-dvh w-full rounded-none pb-safe"
+ ? "flex h-full min-h-0 w-full flex-1 flex-col rounded-none"
: isPageDesktop
? "flex h-full min-h-0 w-full min-w-0 flex-1 flex-col basis-0 rounded-none border-x-0"
: "m-4 mt-2 w-[min(450px,calc(100vw-2rem))] md:w-[380px] lg:w-[450px] rounded-2xl",
dmSansClassName(),
)}
style={
- isMobile
+ isMobile || isPageDesktop
? undefined
- : isPageDesktop
- ? undefined
- : {
- height: `calc(100vh - ${heightOffset}px)`,
- }
+ : {
+ height: `calc(100vh - ${heightOffset}px)`,
+ }
}
initial={
- isMobile
- ? { y: "100%", opacity: 0 }
- : layout === "page"
- ? { opacity: 0, y: 20 }
- : { x: "100px", opacity: 0 }
+ layout === "page" ? { opacity: 0, y: 20 } : { x: "100px", opacity: 0 }
}
animate={{ x: 0, y: 0, opacity: 1 }}
exit={
- isMobile
- ? { y: "100%", opacity: 0 }
- : layout === "page"
- ? { opacity: 0, y: 12 }
- : { x: "100px", opacity: 0 }
+ layout === "page" ? { opacity: 0, y: 12 } : { x: "100px", opacity: 0 }
}
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
+ {...(!isPageDesktop ? chatDropTargetProps : {})}
>
+ {!isPageDesktop && chatDropOverlay}
{chatHistorySheet}
{isPageDesktop ? (
@@ -1235,7 +2172,11 @@ export function ChatSidebar({
chatProject === AUTO_CHAT_SPACE_ID ? null : [chatProject]
}
/>
-
+
+ {chatDropOverlay}
{pageDesktopToolbarRow}
{shell}
diff --git a/apps/web/components/chat/input/actions.tsx b/apps/web/components/chat/input/actions.tsx
index f8122f8e..10a1c914 100644
--- a/apps/web/components/chat/input/actions.tsx
+++ b/apps/web/components/chat/input/actions.tsx
@@ -1,14 +1,17 @@
import { cn } from "@lib/utils"
import { SquareIcon } from "lucide-react"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
export function SendButton({
onClick,
disabled,
+ disabledTooltip = "Type a message to send",
}: {
onClick: () => void
disabled: boolean
+ disabledTooltip?: string
}) {
- return (
+ const button = (
Send Icon
)
+
+ if (disabled) {
+ return (
+
+
+ {button}
+
+ {disabledTooltip}
+
+ )
+ }
+
+ return button
}
export function StopButton({ onClick }: { onClick: () => void }) {
diff --git a/apps/web/components/chat/input/index.tsx b/apps/web/components/chat/input/index.tsx
index d24276d0..0926af53 100644
--- a/apps/web/components/chat/input/index.tsx
+++ b/apps/web/components/chat/input/index.tsx
@@ -1,12 +1,36 @@
"use client"
-import { ChevronUpIcon } from "lucide-react"
+import {
+ BrainIcon,
+ CheckIcon,
+ ChevronUpIcon,
+ Loader2Icon,
+ PaperclipIcon,
+ RotateCcwIcon,
+ XIcon,
+ ZapIcon,
+} from "lucide-react"
+import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import NovaOrb from "@/components/nova/nova-orb"
+import { SuperLoader } from "@/components/superloader"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { type ReactNode, useEffect, useRef, useState } from "react"
-import { motion } from "motion/react"
+import { AnimatePresence, motion } from "motion/react"
import { SendButton, StopButton } from "./actions"
+import {
+ CHAT_ATTACHMENT_ACCEPT,
+ type ChatAttachmentDraft,
+ formatAttachmentSize,
+} from "../attachments"
+import { type ModelId, modelNames, type ReasoningEffort } from "@/lib/models"
+
+export interface QueuedChatMessagePreview {
+ id: string
+ text: string
+ model: ModelId
+ reasoningEffort: ReasoningEffort
+}
interface ChatInputProps {
value: string
@@ -15,13 +39,27 @@ interface ChatInputProps {
onStop: () => void
onKeyDown?: (e: React.KeyboardEvent) => void
isResponding?: boolean
+ sendDisabled?: boolean
+ sendDisabledTooltip?: string
activeStatus?: string
+ queuedMessages?: QueuedChatMessagePreview[]
chainOfThoughtComponent?: React.ReactNode
onExpandedChange?: (expanded: boolean) => void
/** Model + space controls on one row with send; textarea full-width above */
stackedToolbar?: ReactNode
+ /** Trailing controls rendered after the attach button (e.g. reasoning) */
+ toolbarTrailing?: ReactNode
+ /** Controls rendered just left of the send button (e.g. space selector) */
+ toolbarEnd?: ReactNode
/** Nova status row + chain-of-thought toggle (off for e.g. home composer) */
showStatusStrip?: boolean
+ attachments?: ChatAttachmentDraft[]
+ onAddAttachmentFiles?: (files: FileList | File[]) => void
+ onRemoveAttachment?: (id: string) => void
+ onRetryAttachment?: (id: string) => void
+ canSend?: boolean
+ attachmentAccept?: string
+ disableFileDropZone?: boolean
}
export default function ChatInput({
@@ -31,15 +69,35 @@ export default function ChatInput({
onStop,
onKeyDown,
isResponding = false,
+ sendDisabled = false,
+ sendDisabledTooltip,
activeStatus,
+ queuedMessages = [],
chainOfThoughtComponent,
onExpandedChange,
stackedToolbar,
+ toolbarTrailing,
+ toolbarEnd,
showStatusStrip = true,
+ attachments = [],
+ onAddAttachmentFiles,
+ onRemoveAttachment,
+ onRetryAttachment,
+ canSend,
+ attachmentAccept = CHAT_ATTACHMENT_ACCEPT,
+ disableFileDropZone = false,
}: ChatInputProps) {
const [isMultiline, setIsMultiline] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
+ const [isDraggingFiles, setIsDraggingFiles] = useState(false)
const textareaRef = useRef
(null)
+ const fileInputRef = useRef(null)
+ const dragDepthRef = useRef(0)
+ const canSubmit = canSend ?? value.trim().length > 0
+ const isSendDisabled = !canSubmit || sendDisabled
+ const hasQueuedPreview = queuedMessages.length > 0
+ const resolvedSendDisabledTooltip =
+ sendDisabled && canSubmit ? sendDisabledTooltip : "Type a message to send"
useEffect(() => {
if (!showStatusStrip && isExpanded) {
@@ -61,12 +119,110 @@ export default function ChatInput({
setIsMultiline(textarea.scrollHeight > 52)
}
+ const handleFileSelect = (e: React.ChangeEvent) => {
+ const files = e.target.files
+ if (files?.length) onAddAttachmentFiles?.(files)
+ e.target.value = ""
+ }
+
+ const canAttachFiles = Boolean(onAddAttachmentFiles) && !isResponding
+ const hasDraggedFiles = (e: React.DragEvent) =>
+ Array.from(e.dataTransfer.types).includes("Files")
+
+ const handleDragEnter = (e: React.DragEvent) => {
+ if (!hasDraggedFiles(e)) return
+ e.preventDefault()
+ e.stopPropagation()
+ dragDepthRef.current += 1
+ if (canAttachFiles) setIsDraggingFiles(true)
+ }
+
+ const handleDragOver = (e: React.DragEvent) => {
+ if (!hasDraggedFiles(e)) return
+ e.preventDefault()
+ e.stopPropagation()
+ e.dataTransfer.dropEffect = canAttachFiles ? "copy" : "none"
+ }
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ if (!hasDraggedFiles(e)) return
+ e.preventDefault()
+ e.stopPropagation()
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
+ if (dragDepthRef.current === 0) setIsDraggingFiles(false)
+ }
+
+ const handleDrop = (e: React.DragEvent) => {
+ if (!hasDraggedFiles(e)) return
+ e.preventDefault()
+ e.stopPropagation()
+ dragDepthRef.current = 0
+ setIsDraggingFiles(false)
+ const files = e.dataTransfer.files
+ if (canAttachFiles && files.length) onAddAttachmentFiles?.(files)
+ }
+
+ const showAttachments = attachments.length > 0
+
+ const attachmentTray = showAttachments ? (
+
+ {attachments.map((attachment) => {
+ return (
+
+ )
+ })}
+
+ ) : null
+
+ const attachmentButton = onAddAttachmentFiles ? (
+ <>
+
+ fileInputRef.current?.click()}
+ disabled={isResponding}
+ className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-white/45 transition-colors hover:bg-white/5 hover:text-white/80 disabled:cursor-not-allowed disabled:opacity-50"
+ aria-label="Attach files"
+ title="Attach files"
+ >
+
+
+ >
+ ) : null
+
+ const dropOverlay =
+ !disableFileDropZone && isDraggingFiles ? (
+
+ Drop files to attach
+
+ ) : null
+ const dropZoneProps = disableFileDropZone
+ ? {}
+ : {
+ onDragEnter: handleDragEnter,
+ onDragOver: handleDragOver,
+ onDragLeave: handleDragLeave,
+ onDrop: handleDrop,
+ }
+
return (
{
@@ -112,10 +269,16 @@ export default function ChatInput({
disabled={!chainOfThoughtComponent}
>
-
-
- {activeStatus || "Waiting for input..."}
-
+ {isResponding ? (
+
+ ) : (
+ <>
+
+
+ {activeStatus || "Waiting for input..."}
+
+ >
+ )}
{chainOfThoughtComponent && (
)}
+ {hasQueuedPreview && (
+
+
+ {queuedMessages.map((queued) => {
+ const model = modelNames[queued.model]
+ const ReasoningIcon =
+ queued.reasoningEffort === "thinking" ? BrainIcon : ZapIcon
+ return (
+
+
+
+ {queued.text}
+
+
+
+ {model.name} {model.version}
+
+ ·
+
+
+
+
+ )
+ })}
+
+
+ )}
>
) : null}
{stackedToolbar ? (
-
+
+ {dropOverlay}
+ {attachmentTray}
-
+
{stackedToolbar}
+ {attachmentButton}
+ {toolbarTrailing}
-
- {isResponding ? (
-
- ) : (
-
+
+ {toolbarEnd}
+ {isResponding && }
+ {(!isResponding || canSubmit) && (
+
)}
-
+
) : (
-
+ {dropOverlay}
+ {attachmentTray}
-
- {isResponding ? (
-
- ) : (
-
+
+ {attachmentButton}
+ {isResponding && }
+ {(!isResponding || canSubmit) && (
+
)}
-
+
)}
)
}
+
+function AttachmentPreviewChip({
+ attachment,
+ onRemove,
+ onRetry,
+}: {
+ attachment: ChatAttachmentDraft
+ onRemove?: (id: string) => void
+ onRetry?: (id: string) => void
+}) {
+ const [objectUrl, setObjectUrl] = useState
(null)
+ const [isPreviewOpen, setIsPreviewOpen] = useState(false)
+ const isUploading = attachment.status === "uploading"
+ const isUploaded = attachment.status === "uploaded"
+ const isError = attachment.status === "error"
+ const isImage = attachment.file.type.startsWith("image/")
+ const extension = getAttachmentExtension(attachment.file)
+
+ useEffect(() => {
+ if (!isImage) {
+ setObjectUrl(null)
+ return
+ }
+
+ const url = URL.createObjectURL(attachment.file)
+ setObjectUrl(url)
+ return () => URL.revokeObjectURL(url)
+ }, [attachment.file, isImage])
+
+ const statusLabel = isError
+ ? attachment.errorMessage || "Upload failed"
+ : isUploading
+ ? "Uploading..."
+ : isUploaded
+ ? "Uploaded"
+ : formatAttachmentSize(attachment.file.size)
+
+ return (
+ <>
+
+
isImage && objectUrl && setIsPreviewOpen(true)}
+ disabled={!isImage || !objectUrl}
+ className={cn(
+ "relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-[#242832] bg-black",
+ isImage && objectUrl && "cursor-pointer hover:border-[#4B5563]",
+ (!isImage || !objectUrl) && "cursor-default",
+ )}
+ aria-label={
+ isImage ? `Preview ${attachment.file.name}` : attachment.file.name
+ }
+ >
+ {isImage && objectUrl ? (
+
+ ) : (
+
+ )}
+ {isUploading || isUploaded ? (
+
+ {isUploading ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+
+
+
+ {attachment.file.name}
+
+
+ {statusLabel}
+
+
+
+ {isError ? (
+ {
+ event.stopPropagation()
+ onRetry?.(attachment.id)
+ }}
+ className="flex size-7 shrink-0 items-center justify-center rounded-md border border-[#242832] bg-black text-fg-faint transition-colors hover:border-[#3A4049] hover:bg-[#111418] hover:text-fg-primary"
+ aria-label={`Retry ${attachment.file.name}`}
+ title={statusLabel}
+ >
+
+
+ ) : null}
+
+
{
+ event.stopPropagation()
+ onRemove?.(attachment.id)
+ }}
+ disabled={isUploading}
+ className="flex size-7 shrink-0 items-center justify-center rounded-md text-fg-faint transition-colors hover:bg-[#111418] hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-50"
+ aria-label={`Remove ${attachment.file.name}`}
+ >
+
+
+
+
+
+
+ Preview {attachment.file.name}
+
+
+
+
+
+ {attachment.file.name}
+
+
+ {formatAttachmentSize(attachment.file.size)}
+
+
+
setIsPreviewOpen(false)}
+ className="flex size-8 items-center justify-center rounded-md text-fg-faint transition-colors hover:bg-[#111418] hover:text-fg-primary focus:outline-none focus:ring-2 focus:ring-[#3374FF]/40"
+ >
+
+ Close preview
+
+
+
+ {objectUrl ? (
+
+ ) : null}
+
+
+
+
+ >
+ )
+}
+
+function DocumentFileGlyph({ label }: { label: string }) {
+ return (
+
+ )
+}
+
+function getAttachmentExtension(file: File): string {
+ const name = file.name
+ const dotIndex = name.lastIndexOf(".")
+ if (dotIndex > -1 && dotIndex < name.length - 1) {
+ const extension = name.slice(dotIndex + 1).toLowerCase()
+ if (extension === "md" || extension === "markdown") return "MD"
+ if (extension === "pdf") return "PDF"
+ return extension.slice(0, 3)
+ }
+ if (file.type === "text/markdown") return "MD"
+ if (file.type.includes("pdf")) return "PDF"
+ if (file.type.startsWith("text/")) return "TXT"
+ return "FILE"
+}
diff --git a/apps/web/components/chat/message/agent-message.tsx b/apps/web/components/chat/message/agent-message.tsx
index 63fb84f5..eac0f7b9 100644
--- a/apps/web/components/chat/message/agent-message.tsx
+++ b/apps/web/components/chat/message/agent-message.tsx
@@ -1,7 +1,8 @@
"use client"
-import { useState } from "react"
+import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"
import type { UIMessage } from "@ai-sdk/react"
+import { useQuery } from "@tanstack/react-query"
import { Streamdown } from "streamdown"
import {
BookOpenIcon,
@@ -19,17 +20,37 @@ import {
} from "lucide-react"
import { cn } from "@lib/utils"
import { isWebSearchToolName } from "@/lib/chat-web-search-tools"
+import {
+ buildCitationIndex,
+ fetchDocumentsByIds,
+ getDocumentSourceUrl,
+ isMemoryToolOutputReady,
+ mapDocumentsByKnownIds,
+ type CitationTarget,
+ type DocumentWithMemories,
+ extractMemoryToolOutputs,
+} from "@/lib/chat-memory-tools"
+import {
+ parseSourceAnnotatedMarkdown,
+ stripSourceMarkup,
+} from "@/lib/source-annotations"
+import { modelNames, type ModelId } from "@/lib/models"
import { RelatedMemories } from "./related-memories"
import { MessageActions } from "./message-actions"
const TOOL_META: Record = {
bash: { label: "Memory", icon: TerminalIcon },
+ recallContext: { label: "Recall Memories", icon: BookOpenIcon },
+ discoverSpaces: { label: "Discover Spaces", icon: SearchIcon },
web_search: { label: "Web search", icon: GlobeIcon },
google_search: { label: "Google search", icon: GlobeIcon },
// legacy tool names kept for existing persisted messages
searchMemories: { label: "Search Memories", icon: SearchIcon },
addMemory: { label: "Add Memory", icon: PlusIcon },
fetchMemory: { label: "Fetch Memory", icon: BookOpenIcon },
+ forgetMemory: { label: "Forget Memory", icon: XCircleIcon },
+ updateMemory: { label: "Update Memory", icon: BookOpenIcon },
+ forgetDocument: { label: "Forget Document", icon: XCircleIcon },
scheduleTask: { label: "Schedule Task", icon: ClockIcon },
listSchedules: { label: "List Schedules", icon: ListIcon },
cancelSchedule: { label: "Cancel Schedule", icon: XCircleIcon },
@@ -37,7 +58,7 @@ const TOOL_META: Record = {
type ToolCallDisplayPart = {
type: string
- state: string
+ state?: string
input?: unknown
output?: unknown
toolCallId?: string
@@ -51,48 +72,370 @@ type SourceUrlPart = {
title?: string
}
-function WebSourcesGroup({ sources }: { sources: SourceUrlPart[] }) {
- const [expanded, setExpanded] = useState(false)
- if (sources.length === 0) return null
+function sourceHost(url: string): string {
+ try {
+ return new URL(url).hostname.replace(/^www\./, "")
+ } catch {
+ return url
+ }
+}
+
+function faviconUrl(host: string): string {
+ return `https://www.google.com/s2/favicons?sz=64&domain=${host}`
+}
+
+function safeExternalUrl(url: string | null | undefined): string | null {
+ if (!url) return null
+ if (url.startsWith("/") && !url.startsWith("//")) return url
+ if (url.startsWith("#") && !url.startsWith("#sm-source:")) return url
+ try {
+ const parsed = new URL(url)
+ return parsed.protocol === "http:" || parsed.protocol === "https:"
+ ? url
+ : null
+ } catch {
+ return null
+ }
+}
+
+function isWebSearchPart(part: { type: string; toolName?: string }): boolean {
+ if (part.type === "dynamic-tool") {
+ return isWebSearchToolName(part.toolName ?? "")
+ }
+ if (part.type.startsWith("tool-")) {
+ return isWebSearchToolName(part.type.slice("tool-".length))
+ }
+ return false
+}
+
+function isMemoryRetrievalToolName(toolName: string): boolean {
+ return (
+ toolName === "searchMemories" ||
+ toolName === "recallContext" ||
+ toolName === "discoverSpaces"
+ )
+}
+
+export function isChatToolDisplayPartType(type: string): boolean {
+ return (
+ type === "tool-searchMemories" ||
+ type === "tool-recallContext" ||
+ type === "tool-discoverSpaces" ||
+ type === "tool-forgetMemory" ||
+ type === "tool-updateMemory" ||
+ type === "tool-forgetDocument"
+ )
+}
+
+function CitationLink({
+ href,
+ label,
+ source,
+}: {
+ href: string
+ label: string
+ source?: SourceUrlPart
+}) {
+ const url = safeExternalUrl(source?.url ?? href) ?? ""
+ if (!url) return <>{label}>
+ const host = sourceHost(url)
+ const rawTitle = source?.title?.trim()
+ const hasTitle =
+ !!rawTitle && rawTitle !== host && !/^https?:\/\//i.test(rawTitle)
+ let path = ""
+ try {
+ path = new URL(url).pathname.replace(/\/$/, "")
+ } catch {}
+ return (
+
+
+ {label}
+
+
+
+
+
+
+ {host}
+
+
+ {hasTitle ? (
+
+ {rawTitle}
+
+ ) : path ? (
+
+ {path}
+
+ ) : null}
+
+
+
+ )
+}
+
+function sourceTitle(
+ target: CitationTarget,
+ document?: DocumentWithMemories,
+): string {
+ return (
+ document?.title?.trim() ||
+ target.title?.trim() ||
+ document?.customId ||
+ target.customId ||
+ target.documentId ||
+ target.sourceId
+ )
+}
+
+function sourceSummary(
+ target: CitationTarget,
+ document?: DocumentWithMemories,
+): string | null {
+ const summary =
+ document?.summary ||
+ target.summary ||
+ (document as { content?: string } | undefined)?.content ||
+ null
+ return summary ? summary.trim() : null
+}
+
+function sourceKind(
+ target: CitationTarget,
+ document?: DocumentWithMemories,
+): string {
+ return (document?.type || target.type || "memory").replaceAll("_", " ")
+}
+
+function SourceCitationLink({
+ sourceId,
+ children,
+ citationIndex,
+ documentByKnownId,
+}: {
+ sourceId: string
+ children: ReactNode
+ citationIndex: Map
+ documentByKnownId: Map
+}) {
+ const target = citationIndex.get(sourceId)
+ if (!target) return <>{children}>
+
+ const document =
+ (target.documentId
+ ? documentByKnownId.get(target.documentId)
+ : undefined) ??
+ (target.customId ? documentByKnownId.get(target.customId) : undefined)
+ const url = safeExternalUrl(
+ document ? getDocumentSourceUrl(document) : target.url,
+ )
+ const title = sourceTitle(target, document)
+ const summary = sourceSummary(target, document)
return (
-
+
+ {url ? (
+
+ {children}
+
+ ) : (
+
+ {children}
+
+ )}
+
+ {sourceId}
+
+
+
+
+
+ {title}
+
+
+ {sourceKind(target, document)}
+
+
+ {summary ? (
+
+ {summary}
+
+ ) : null}
+ {url ? (
+
+ Open source
+
+ ) : null}
+
+
+
+ )
+}
+
+function makeMarkdownComponents(
+ sources: SourceUrlPart[],
+ citationIndex: Map
,
+ documentByKnownId: Map,
+) {
+ return {
+ a: ({ href, children }: { href?: string; children?: ReactNode }) => {
+ if (href?.startsWith("#sm-source:")) {
+ const sourceId = (() => {
+ try {
+ return decodeURIComponent(href.slice("#sm-source:".length))
+ } catch {
+ return null
+ }
+ })()
+ if (!sourceId) return <>{children}>
+ return (
+
+ {children}
+
+ )
+ }
+ const label =
+ typeof children === "string"
+ ? children
+ : Array.isArray(children)
+ ? children.join("")
+ : ""
+ const match = label.match(/^\[?(\d+)\]?$/)
+ const safeHref = safeExternalUrl(href)
+ if (match && safeHref) {
+ const n = Number(match[1])
+ const source = sources.find((s) => s.url === safeHref) ?? sources[n - 1]
+ return
+ }
+ if (!safeHref) return <>{children}>
+ return (
+
+ {children}
+
+ )
+ },
+ }
+}
+
+function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) {
+ const [expanded, setExpanded] = useState(false)
+ const ref = useRef(null)
+
+ useEffect(() => {
+ if (!expanded) return
+ const onDown = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) {
+ setExpanded(false)
+ }
+ }
+ document.addEventListener("mousedown", onDown)
+ return () => document.removeEventListener("mousedown", onDown)
+ }, [expanded])
+
+ if (sources.length === 0) return null
+
+ const faviconHosts: string[] = []
+ for (const s of sources) {
+ const host = sourceHost(s.url)
+ if (!faviconHosts.includes(host)) faviconHosts.push(host)
+ if (faviconHosts.length >= 3) break
+ }
+ const count = sources.length
+
+ return (
+
setExpanded(!expanded)}
- className={cn(
- "flex items-center gap-2 w-full px-3 py-2 cursor-pointer hover:bg-[#141922] transition-colors",
- expanded && "border-b border-[#1E2128]",
- )}
+ onClick={() => setExpanded((v) => !v)}
+ className="flex cursor-pointer items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] py-1 pr-2.5 pl-1.5 text-xs text-white/65 transition-colors hover:bg-white/[0.08] hover:text-white/80"
+ aria-expanded={expanded}
+ aria-label={`${count} web ${count === 1 ? "source" : "sources"}`}
>
-
-
- Web sources
-
- ({sources.length})
-
+
+ {faviconHosts.length > 0 ? (
+ faviconHosts.map((host) => (
+
+ ))
+ ) : (
+
+ )}
- {expanded ? (
-
- ) : (
-
- )}
+ {count === 1 ? "1 source" : `${count} sources`}
{expanded && (
-
+
)}
)
@@ -209,6 +552,23 @@ function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
if (toolName === "bash") {
return
}
+ if (isWebSearchToolName(toolName)) {
+ if (part.state === "output-available") return null
+ if (part.state === "error" || part.state === "output-error") {
+ return (
+
+
+ Web search failed
+
+ )
+ }
+ return (
+
+
+ Searching the web…
+
+ )
+ }
const meta =
TOOL_META[toolName] ??
(isWebSearchToolName(toolName)
@@ -222,6 +582,9 @@ function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
const isDone = part.state === "output-available"
const isError = part.state === "error" || part.state === "output-error"
const errorText = part.errorText
+ if (isMemoryRetrievalToolName(toolName) && isMemoryToolOutputReady(part)) {
+ return null
+ }
return (
@@ -313,6 +676,7 @@ interface AgentMessageProps {
copiedMessageId: string | null
messageFeedback: Record
expandedMemories: string | null
+ responseModel: ModelId | null
onCopy: (messageId: string, text: string) => void
onLike: (messageId: string) => void
onDislike: (messageId: string) => void
@@ -327,6 +691,7 @@ export function AgentMessage({
copiedMessageId,
messageFeedback,
expandedMemories,
+ responseModel,
onCopy,
onLike,
onDislike,
@@ -339,6 +704,59 @@ export function AgentMessage({
.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ")
+ const copyText = stripSourceMarkup(messageText)
+ const memoryOutputs = useMemo(
+ () => extractMemoryToolOutputs(message),
+ [message],
+ )
+ const citationIndex = useMemo(
+ () => buildCitationIndex(memoryOutputs),
+ [memoryOutputs],
+ )
+ const allowedSourceIds = useMemo(
+ () => new Set(citationIndex.keys()),
+ [citationIndex],
+ )
+ const sourceDocumentIds = useMemo(() => {
+ const ids = new Set()
+ for (const target of citationIndex.values()) {
+ if (target.documentId) ids.add(target.documentId)
+ if (target.customId) ids.add(target.customId)
+ }
+ return [...ids].sort()
+ }, [citationIndex])
+ const { data: sourceDocuments = [] } = useQuery({
+ queryKey: ["chat-source-documents", sourceDocumentIds],
+ queryFn: () => fetchDocumentsByIds(sourceDocumentIds),
+ enabled: sourceDocumentIds.length > 0,
+ staleTime: 5 * 60 * 1000,
+ })
+ const documentByKnownId = useMemo(
+ () => mapDocumentsByKnownIds(sourceDocuments),
+ [sourceDocuments],
+ )
+ const webSources = useMemo(() => {
+ const seen = new Set()
+ const out: SourceUrlPart[] = []
+ for (const part of message.parts) {
+ if (part.type !== "source-url") continue
+ const source = part as SourceUrlPart
+ if (seen.has(source.url)) continue
+ seen.add(source.url)
+ out.push(source)
+ }
+ return out
+ }, [message.parts])
+ const hasAssistantText = message.parts.some(
+ (p) => p.type === "text" && (p as { text?: string }).text?.trim(),
+ )
+ const markdownComponents = useMemo(
+ () => makeMarkdownComponents(webSources, citationIndex, documentByKnownId),
+ [webSources, citationIndex, documentByKnownId],
+ )
+ const responseModelLabel = responseModel
+ ? `${modelNames[responseModel].name} ${modelNames[responseModel].version}`
+ : null
return (
@@ -352,24 +770,10 @@ export function AgentMessage({
{message.parts.map((part, partIndex) => {
if (part.type === "source-url") {
- if (
- partIndex > 0 &&
- message.parts[partIndex - 1]?.type === "source-url"
- ) {
- return null
- }
- const sources: SourceUrlPart[] = []
- for (let j = partIndex; j < message.parts.length; j++) {
- const p = message.parts[j]
- if (!p || p.type !== "source-url") break
- sources.push(p as SourceUrlPart)
- }
- return (
-
- )
+ return null
+ }
+ if (isWebSearchPart(part)) {
+ return null
}
if (part.type === "source-document") {
const doc = part as {
@@ -394,12 +798,33 @@ export function AgentMessage({
)
}
if (part.type === "text") {
+ // Skip fragments mid-run — source-url citations split one answer into
+ // many text parts; rendering each separately tears markdown (lists etc.).
+ let prev = partIndex - 1
+ while (prev >= 0 && message.parts[prev]?.type === "source-url") {
+ prev--
+ }
+ if (prev >= 0 && message.parts[prev]?.type === "text") {
+ return null
+ }
+ let runText = ""
+ for (let j = partIndex; j < message.parts.length; j++) {
+ const p = message.parts[j]
+ if (p?.type === "text") runText += p.text
+ else if (p?.type === "source-url") continue
+ else break
+ }
return (
- {part.text}
+
+ {
+ parseSourceAnnotatedMarkdown(runText, allowedSourceIds)
+ .markdown
+ }
+
)
}
@@ -408,7 +833,7 @@ export function AgentMessage({
type: "dynamic-tool"
toolName: string
toolCallId: string
- state: string
+ state?: string
input?: unknown
output?: unknown
errorText?: string
@@ -442,17 +867,41 @@ export function AgentMessage({
})}
-
+ {hasAssistantText && (
+
+
+ {webSources.length > 0 && (
+
+
+
+ )}
+ {responseModelLabel && (
+
+ {responseModelLabel}
+
+ )}
+
+ )}
)
}
diff --git a/apps/web/components/chat/message/message-actions.tsx b/apps/web/components/chat/message/message-actions.tsx
index bbb1f384..26430c7b 100644
--- a/apps/web/components/chat/message/message-actions.tsx
+++ b/apps/web/components/chat/message/message-actions.tsx
@@ -1,4 +1,4 @@
-import { Copy, Check, ThumbsUp, ThumbsDown } from "lucide-react"
+import { Check, Copy, ThumbsDown, ThumbsUp } from "lucide-react"
import { cn } from "@lib/utils"
interface MessageActionsProps {
diff --git a/apps/web/components/chat/message/user-message.tsx b/apps/web/components/chat/message/user-message.tsx
index 0427f348..9fcb1606 100644
--- a/apps/web/components/chat/message/user-message.tsx
+++ b/apps/web/components/chat/message/user-message.tsx
@@ -1,42 +1,193 @@
"use client"
-import { memo } from "react"
-import { Copy, Check } from "lucide-react"
+import { memo, useEffect, useRef, useState } from "react"
+import { AnimatePresence, motion } from "motion/react"
+import { Check, Copy, FileIcon, PencilIcon, PencilOffIcon } from "lucide-react"
import type { UIMessage } from "@ai-sdk/react"
+import ChatModelSelector from "../model-selector"
+import { ReasoningSelector } from "../reasoning-selector"
+import { SendButton } from "../input/actions"
+import {
+ getDefaultReasoningEffort,
+ type ModelId,
+ type ReasoningEffort,
+} from "@/lib/models"
+import { formatAttachmentSize, getChatMessageAttachments } from "../attachments"
interface UserMessageProps {
message: UIMessage
copiedMessageId: string | null
+ selectedModel: ModelId
+ reasoningEffort: ReasoningEffort
onCopy: (messageId: string, text: string) => void
+ onRegenerate: (
+ messageId: string,
+ text: string,
+ model: ModelId,
+ reasoningEffort: ReasoningEffort,
+ ) => void
}
export const UserMessage = memo(function UserMessage({
message,
copiedMessageId,
+ selectedModel,
+ reasoningEffort,
onCopy,
+ onRegenerate,
}: UserMessageProps) {
+ const [isEditing, setIsEditing] = useState(false)
+ const [draft, setDraft] = useState("")
+ const [editModel, setEditModel] = useState(selectedModel)
+ const [editReasoningEffort, setEditReasoningEffort] =
+ useState(reasoningEffort)
+ const textareaRef = useRef(null)
const text = message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ")
+ const attachments = getChatMessageAttachments(message.metadata)
+
+ const startEditing = () => {
+ setDraft(text)
+ setEditModel(selectedModel)
+ setEditReasoningEffort(reasoningEffort)
+ setIsEditing(true)
+ }
+
+ const submitEdit = () => {
+ const nextText = draft.trim()
+ if (!nextText) return
+ setIsEditing(false)
+ onRegenerate(message.id, nextText, editModel, editReasoningEffort)
+ }
+
+ const handleEditModelChange = (model: ModelId) => {
+ setEditModel(model)
+ setEditReasoningEffort(getDefaultReasoningEffort(model))
+ }
+
+ useEffect(() => {
+ if (!isEditing) return
+ textareaRef.current?.focus()
+ }, [isEditing])
return (
-
-
-
onCopy(message.id, text)}
- className="p-1.5 hover:bg-[#293952]/40 rounded transition-colors mt-1"
- title="Copy message"
- >
- {copiedMessageId === message.id ? (
-
+
+ {attachments.length > 0 ? (
+
+ {attachments.map((attachment) => (
+
+
+
+
+
+
+ {attachment.filename}
+
+
+ {formatAttachmentSize(attachment.size)}
+ {" | "}
+ {attachment.saveToMemory ? "Saved" : "Chat only"}
+
+
+
+ ))}
+
+ ) : null}
+
+ {isEditing ? (
+
+
) : (
-
+
+ {text ? (
+ {text}
+ ) : null}
+
)}
-
+
+
+ onCopy(message.id, text)}
+ className="p-1.5 hover:bg-[#293952]/40 rounded transition-colors"
+ title="Copy message"
+ >
+ {copiedMessageId === message.id ? (
+
+ ) : (
+
+ )}
+
+ setIsEditing(false) : startEditing}
+ className="p-1.5 hover:bg-[#293952]/40 rounded transition-colors"
+ title={isEditing ? "Cancel edit" : "Edit message"}
+ >
+ {isEditing ? (
+
+ ) : (
+
+ )}
+
+
)
})
diff --git a/apps/web/components/chat/model-selector.tsx b/apps/web/components/chat/model-selector.tsx
index 07465e7b..ca9935ee 100644
--- a/apps/web/components/chat/model-selector.tsx
+++ b/apps/web/components/chat/model-selector.tsx
@@ -1,10 +1,11 @@
"use client"
import { useEffect, useRef, useState } from "react"
+import { AnimatePresence, motion } from "motion/react"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { dmSansClassName } from "@/lib/fonts"
-import { ChevronDownIcon } from "lucide-react"
+import { CheckIcon, ChevronDownIcon } from "lucide-react"
import { models, type ModelId, modelNames } from "@/lib/models"
import { analytics } from "@/lib/analytics"
@@ -13,12 +14,14 @@ interface ChatModelSelectorProps {
onModelChange?: (model: ModelId) => void
/** Compact pill matching inline send control. */
minimal?: boolean
+ dropdownDirection?: "up" | "down"
}
export default function ChatModelSelector({
selectedModel: selectedModelProp,
onModelChange,
minimal = false,
+ dropdownDirection = "up",
}: ChatModelSelectorProps = {}) {
const [internalModel, setInternalModel] =
useState("claude-sonnet-4.6")
@@ -41,6 +44,8 @@ export default function ChatModelSelector({
const selectedModel = selectedModelProp ?? internalModel
const currentModelData = modelNames[selectedModel]
+ const selectedModelLabel = `${currentModelData.name} ${currentModelData.version}`
+ const selectedItemClass = "bg-white/[0.06] text-white"
const handleModelSelect = (modelId: ModelId) => {
if (onModelChange) {
@@ -56,77 +61,134 @@ export default function ChatModelSelector({
setIsOpen(!isOpen)}
+ aria-expanded={isOpen}
+ aria-label={`Model: ${selectedModelLabel}`}
>
-
+
{currentModelData.name}{" "}
- {currentModelData.version}
+ {currentModelData.version}
-
+
) : (
setIsOpen(!isOpen)}
+ aria-expanded={isOpen}
+ aria-label={`Model: ${selectedModelLabel}`}
>
{currentModelData.name}{" "}
- {currentModelData.version}
+ {currentModelData.version}
-
+
)
return (
{trigger}
- {isOpen && (
-
-
- {models.map((model) => {
- const modelData = modelNames[model.id]
- return (
-
handleModelSelect(model.id)}
- onKeyDown={(e) =>
- e.key === "Enter" && handleModelSelect(model.id)
- }
- >
-
- {modelData.name}{" "}
- {modelData.version}
-
-
- {model.description}
-
-
- )
- })}
-
-
- )}
+
+ {isOpen && (
+
+
+ {models.map((model) => {
+ const modelData = modelNames[model.id]
+ const isSelected = selectedModel === model.id
+ return (
+
handleModelSelect(model.id)}
+ onKeyDown={(e) =>
+ e.key === "Enter" && handleModelSelect(model.id)
+ }
+ >
+
+
+ {modelData.name}{" "}
+
+ {modelData.version}
+
+
+
+ {model.description}
+
+
+ {isSelected && (
+
+ )}
+
+ )
+ })}
+
+
+ )}
+
)
}
diff --git a/apps/web/components/chat/reasoning-selector.tsx b/apps/web/components/chat/reasoning-selector.tsx
new file mode 100644
index 00000000..c0ceb227
--- /dev/null
+++ b/apps/web/components/chat/reasoning-selector.tsx
@@ -0,0 +1,73 @@
+"use client"
+
+import { AnimatePresence, motion } from "motion/react"
+import { BrainIcon, ZapIcon } from "lucide-react"
+import { cn } from "@lib/utils"
+import { dmSansClassName } from "@/lib/fonts"
+import { reasoningOptions, type ReasoningEffort } from "@/lib/models"
+
+interface ReasoningSelectorProps {
+ value: ReasoningEffort
+ onChange: (value: ReasoningEffort) => void
+ variant?: "pill" | "icon"
+ disabled?: boolean
+ dropdownDirection?: "up" | "down"
+}
+
+const iconVariants = {
+ initial: (toThinking: boolean) => ({
+ y: toThinking ? "-110%" : "110%",
+ opacity: 0,
+ }),
+ animate: { y: "0%", opacity: 1 },
+ exit: (toThinking: boolean) => ({
+ y: toThinking ? "110%" : "-110%",
+ opacity: 0,
+ }),
+}
+
+export function ReasoningSelector({
+ value,
+ onChange,
+ disabled = false,
+}: ReasoningSelectorProps) {
+ const toThinking = value === "thinking"
+ const Icon = toThinking ? BrainIcon : ZapIcon
+ const selectedLabel =
+ reasoningOptions.find((option) => option.id === value)?.label ?? "Reasoning"
+ const nextValue: ReasoningEffort = toThinking ? "instant" : "thinking"
+
+ return (
+ onChange(nextValue)}
+ className={cn(
+ "group flex shrink-0 cursor-pointer items-center rounded-md px-1.5 py-1 text-xs text-white/80 transition-colors hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-50",
+ dmSansClassName(),
+ )}
+ title={`Reasoning: ${selectedLabel} — click to switch`}
+ aria-label={`Reasoning: ${selectedLabel}. Click to switch.`}
+ >
+
+
+
+
+
+
+
+
+ {selectedLabel}
+
+
+ )
+}
diff --git a/apps/web/components/company-brain-header.tsx b/apps/web/components/company-brain-header.tsx
new file mode 100644
index 00000000..41ccb1c4
--- /dev/null
+++ b/apps/web/components/company-brain-header.tsx
@@ -0,0 +1,604 @@
+"use client"
+
+import { useAuth } from "@lib/auth-context"
+import { cn } from "@lib/utils"
+import { Button } from "@ui/components/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@ui/components/dropdown-menu"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
+import { useIsMobile } from "@hooks/use-mobile"
+import { useQuery } from "@tanstack/react-query"
+import {
+ Building2,
+ Code2,
+ ExternalLink,
+ Home,
+ LifeBuoy,
+ Link2,
+ LayoutGrid,
+ MenuIcon,
+ SearchIcon,
+ Settings,
+ UserPlus,
+ ChevronRight,
+ Sun,
+} from "lucide-react"
+import Link from "next/link"
+import { useQueryState } from "nuqs"
+import { useCallback } from "react"
+import { DomainLogo } from "@/components/onboarding-brain/step-about"
+import { FeedbackModal } from "@/components/feedback-modal"
+import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
+import { SlackMark } from "@/components/brain-connector-icons"
+import { GraphIcon, IntegrationsIcon } from "@/components/integration-icons"
+import { SpaceSelector } from "@/components/space-selector"
+import { UserProfileMenu } from "@/components/user-profile-menu"
+import { useTokenUsage } from "@/hooks/use-token-usage"
+import { useOrgSummaries } from "@/hooks/use-org-summaries"
+import { getBrainWorkspaceDomain } from "@/lib/billing-utils"
+import { dmSansClassName } from "@/lib/fonts"
+import { useViewMode } from "@/lib/view-mode-context"
+import { feedbackParam } from "@/lib/search-params"
+import { useSettingsModal } from "@/components/settings/settings-modal"
+import { useProject } from "@/stores"
+import { useCustomer } from "autumn-js/react"
+
+const BACKEND =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+
+type SlackStatus = { connected: boolean; teamName: string | null }
+
+interface CompanyBrainHeaderProps {
+ onOpenSearch?: () => void
+}
+
+const brainItemClass = (active: boolean) =>
+ cn(
+ "gap-2.5 rounded-lg px-2 py-1.5 text-sm font-medium cursor-pointer transition-colors",
+ "hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white",
+ active ? "bg-white/[0.06] text-white" : "text-white/85",
+ )
+
+const brainTileClass = (active: boolean) =>
+ cn(
+ "flex size-7 shrink-0 items-center justify-center rounded-lg border text-[11px] font-semibold",
+ active
+ ? "border-[#2261CA66] bg-[#0B2A57] text-[#7EB0FF]"
+ : "border-white/[0.08] bg-white/[0.04] text-white/70",
+ )
+
+const menuItemClass =
+ "gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
+
+const circleNavClass = (active: boolean) =>
+ cn(
+ "flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
+ active
+ ? "border-[#2261CA33] bg-[#00173C] text-white"
+ : "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5",
+ dmSansClassName(),
+ )
+
+const tabClass = (active: boolean) =>
+ cn(
+ "inline-flex h-[calc(100%-1px)] min-h-0 cursor-pointer snap-start items-center justify-center gap-1 rounded-full border border-transparent px-2.5 text-xs font-medium whitespace-nowrap transition-colors sm:gap-1.5 sm:px-3 sm:text-sm",
+ active
+ ? "border-[#2261CA33] bg-[#00173C] text-white"
+ : "text-foreground hover:bg-white/5",
+ dmSansClassName(),
+ )
+
+function useSlackStatus() {
+ return useQuery({
+ queryKey: ["brain-slack-status"],
+ queryFn: async (): Promise => {
+ const res = await fetch(`${BACKEND}/brain/slack/status`, {
+ credentials: "include",
+ })
+ if (!res.ok) return { connected: false, teamName: null }
+ return (await res.json()) as SlackStatus
+ },
+ staleTime: 30_000,
+ })
+}
+
+export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
+ const { user, org, organizations, setActiveOrg } = useAuth()
+ const autumn = useCustomer()
+ const { currentPlan } = useTokenUsage(autumn)
+ const { data: orgSummaries } = useOrgSummaries()
+ const { viewMode, setViewMode } = useViewMode()
+ const { selectedProjects, setSelectedProjects } = useProject()
+ const { openSettings } = useSettingsModal()
+ const isMobile = useIsMobile()
+ const [feedbackOpen, setFeedbackOpen] = useQueryState(
+ "feedback",
+ feedbackParam,
+ )
+ const [, setInvite] = useQueryState("invite")
+ const [settingsTab] = useQueryState("settings")
+ const { data: slackStatus } = useSlackStatus()
+
+ const planByOrgId = new Map(
+ (orgSummaries ?? []).map((s) => [s.orgId, s.plan] as const),
+ )
+
+ const orgLabel = org?.name.replace(/\s*organizations?\s*$/i, "").trim()
+ const brandLabel = orgLabel || "Workspace"
+ const domain = getBrainWorkspaceDomain(
+ org?.metadata as Record | string | null | undefined,
+ )
+ const hasOrgs = (organizations?.length ?? 0) > 0
+
+ const memberRole = org?.members
+ ?.find((m) => m.userId === user?.id)
+ ?.role?.toLowerCase()
+ const canInvite = memberRole === "owner" || memberRole === "admin"
+
+ const isOverview = viewMode === "dashboard" && settingsTab !== "company-brain"
+ const isGraph = viewMode === "graph"
+ const isMemories = viewMode === "list"
+ const isConnections = settingsTab === "company-brain"
+ const slackConnected = slackStatus?.connected ?? false
+
+ const selectOrg = useCallback(
+ (slug: string, isActive: boolean) => {
+ if (isActive) return
+ void setActiveOrg(slug).then(() => window.location.reload())
+ },
+ [setActiveOrg],
+ )
+
+ const goOverview = useCallback(() => {
+ void setViewMode("dashboard")
+ }, [setViewMode])
+
+ const goGraph = useCallback(() => {
+ void setViewMode("graph")
+ }, [setViewMode])
+
+ const goMemories = useCallback(() => {
+ void setViewMode("list")
+ }, [setViewMode])
+
+ const goConnections = useCallback(() => {
+ openSettings("company-brain")
+ }, [openSettings])
+
+ const goIntegrations = useCallback(() => {
+ void setViewMode("integrations")
+ }, [setViewMode])
+
+ const handleInvite = useCallback(() => {
+ setInvite("1")
+ openSettings("account")
+ }, [openSettings, setInvite])
+
+ const handleFeedback = useCallback(() => {
+ void setFeedbackOpen(true)
+ }, [setFeedbackOpen])
+
+ return (
+
+
+
+
+
+
+ {domain ? (
+
+ ) : (
+
+ )}
+
+
+
+ Company Brain
+
+
+ {brandLabel}
+
+
+
+
+
+ {hasOrgs && (
+ <>
+
+ Switch brain
+
+
+ {organizations?.map((o) => {
+ const active = org?.id === o.id
+ const plan = resolveOrgPlan(
+ o.id,
+ active,
+ currentPlan,
+ planByOrgId,
+ )
+ return (
+ selectOrg(o.slug, active)}
+ className={cn(brainItemClass(active))}
+ >
+
+ {o.name?.trim().charAt(0).toUpperCase() || "?"}
+
+ {o.name}
+
+
+ )
+ })}
+
+
+ >
+ )}
+
+
+
+ Home
+
+
+
+
+ Connections
+
+
+
+ Integrations
+
+ openSettings()}
+ className={menuItemClass}
+ >
+
+ Settings
+
+
+
+
+
+ Developer console
+
+
+
+
+
+ supermemory.ai
+
+
+
+
+ {!isMobile && (
+ <>
+
+
+ >
+ )}
+
+
+ {!isMobile && (
+
+
+
+
+
+
+
+
+ Overview
+
+
+
+
+
+ Graph
+
+
+
+ Memories
+
+
+
+ Connections
+
+
+
+
+ )}
+
+
+ {isMobile ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+ Overview
+
+
+
+ Graph
+
+
+
+ Memories
+
+
+
+ Connections
+
+
+
+ Integrations
+
+ {slackConnected ? (
+
+
+ Slack connected
+
+ ) : (
+
+
+
+ Add to Slack
+
+
+ )}
+
+ {canInvite && (
+
+
+ Invite teammates
+
+ )}
+
+
+ Search
+
+
+
+
+ Feedback
+
+ openSettings()}
+ className={menuItemClass}
+ >
+
+ Settings
+
+
+
+ >
+ ) : (
+ <>
+ {canInvite && (
+
+
+
+
+ Invite
+
+
+
+ Invite teammates
+
+
+ )}
+
+
+
+
+
+
+
+ Search (⌘K)
+
+
+ >
+ )}
+
+
+
+
setFeedbackOpen(false)}
+ />
+
+ )
+}
+
+function SlackNavButton({
+ connected,
+ teamName,
+ active,
+ onManage,
+}: {
+ connected: boolean
+ teamName: string | null
+ active: boolean
+ onManage: () => void
+}) {
+ const label = connected
+ ? `Slack${teamName ? ` · ${teamName}` : ""}`
+ : "Add to Slack"
+
+ if (!connected) {
+ return (
+
+
+
+
+
+
+
+ Add to Slack
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {label}
+
+
+ )
+}
diff --git a/apps/web/components/connect-ai-modal.tsx b/apps/web/components/connect-ai-modal.tsx
index 9a46a045..f4da2de1 100644
--- a/apps/web/components/connect-ai-modal.tsx
+++ b/apps/web/components/connect-ai-modal.tsx
@@ -49,6 +49,7 @@ import type { Project } from "@lib/types"
import { motion, AnimatePresence } from "motion/react"
const clients = {
+ antigravity: "Antigravity",
chatgpt: "ChatGPT",
codex: "Codex",
cursor: "Cursor",
@@ -399,7 +400,9 @@ export function ConnectAIModal({
src={
key === "mcp-url"
? "/mcp-icon.svg"
- : `/mcp-supported-tools/${key === "claude-code" ? "claude" : key}.png`
+ : key === "antigravity"
+ ? "/mcp-supported-tools/antigravity.png"
+ : `/mcp-supported-tools/${key === "claude-code" ? "claude" : key}.png`
}
width={20}
/>
diff --git a/apps/web/components/dashboard-view.tsx b/apps/web/components/dashboard-view.tsx
index ad615e73..37355282 100644
--- a/apps/web/components/dashboard-view.tsx
+++ b/apps/web/components/dashboard-view.tsx
@@ -31,6 +31,7 @@ import {
import { StaticGraphPreview } from "@/components/memory-graph/graph-card"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
import { ChromeIcon, RaycastIcon } from "@/components/integration-icons"
+import { SlackConnectCard } from "@/components/slack-connect-card"
import { GoogleDrive, Notion, MCPIcon } from "@ui/assets/icons"
import { analytics } from "@/lib/analytics"
import type { IntegrationParamValue } from "@/lib/search-params"
@@ -40,6 +41,9 @@ import {
type Profession,
} from "@/hooks/use-personalization"
import { normalizePluginClientId } from "@/lib/plugin-catalog"
+import { detectPluginSpace } from "@/lib/plugin-space"
+import { useDigests } from "@/hooks/use-digests"
+import { ReviewMemoriesCard } from "@/components/review-memories-card"
type DocumentsResponse = z.infer
type DocumentWithMemories = DocumentsResponse["documents"][0]
@@ -443,6 +447,23 @@ function hasClaudeCodeContainer(document: DocumentWithMemories): boolean {
)
}
+function getPluginClientFromSpace(
+ document: DocumentWithMemories,
+): string | null {
+ const containerTags =
+ (document as { containerTags?: string[] }).containerTags ?? []
+ const memorySpaceTags = (document.memoryEntries ?? [])
+ .map((entry) => entry.spaceContainerTag)
+ .filter((tag): tag is string => !!tag)
+
+ for (const tag of [...containerTags, ...memorySpaceTags]) {
+ const plugin = detectPluginSpace(tag)
+ if (plugin) return normalizePluginClientId(plugin.pluginId)
+ }
+
+ return null
+}
+
function getPluginClientFromDocument(
document: DocumentWithMemories,
): string | null {
@@ -462,6 +483,9 @@ function getPluginClientFromDocument(
if (hasClaudeCodeContainer(document)) return "claude_code"
+ const pluginClientFromSpace = getPluginClientFromSpace(document)
+ if (pluginClientFromSpace) return pluginClientFromSpace
+
const content = getDocumentText(document)
const title = document.title ?? ""
if (
@@ -689,6 +713,26 @@ function parseToolUsage(
}
// Attach latest document info to plugin items where available from MCP documents
+ for (const [pluginId, docInfo] of latestDocPerPlugin) {
+ const itemKey = `plugin_${pluginId}`
+ if (toolMap.has(itemKey)) continue
+
+ const catalog = PLUGIN_DISPLAY_CATALOG[pluginId]
+ toolMap.set(itemKey, {
+ id: itemKey,
+ name: catalog?.name ?? docInfo.title,
+ type: "Plugin",
+ icon: catalog?.icon ?? null,
+ lastUsedAt: docInfo.at,
+ hasBeenUsed: true,
+ connectedAt: null,
+ lastDocumentTitle: null,
+ lastDocumentId: null,
+ lastDocumentPreview: null,
+ lastDocument: null,
+ })
+ }
+
for (const [, item] of toolMap) {
if (item.type === "Plugin" && !item.lastDocumentTitle) {
const pluginId = item.id.replace(/^plugin_/, "")
@@ -1112,6 +1156,7 @@ export function DashboardView({
onHighlightsChat,
onHighlightsShowRelated,
onResetHighlights,
+ onOpenDigests,
memoryOfDay,
}: {
spaceLabel: string
@@ -1132,10 +1177,12 @@ export function DashboardView({
onHighlightsChat: (highlightContent: string, userReply: string) => void
onHighlightsShowRelated: (query: string) => void
onResetHighlights: () => void
+ onOpenDigests: () => void
memoryOfDay: MemoryOfDay | null
}) {
const { user, org } = useAuth()
const { effectiveContainerTags } = useProject()
+ const primaryContainerTag = effectiveContainerTags?.[0]
const _router = useRouter()
const { data: recentsData, isPending: isRecentsLoading } = useQuery({
queryKey: ["dashboard-recents", effectiveContainerTags],
@@ -1238,6 +1285,9 @@ export function DashboardView({
setProfession,
} = usePersonalization()
+ const { data: digestList } = useDigests()
+ const latestDigest = digestList?.[0]
+
const recents = recentsData?.documents ?? []
const recentToolUsageItems = toolUsageItems
.filter((item) => item.type === "Plugin" && item.lastDocument)
@@ -1284,6 +1334,7 @@ export function DashboardView({
)}
>
+
{headerNotice ?
{headerNotice}
: null}
{/* Header */}
@@ -1361,6 +1412,7 @@ export function DashboardView({
onChat={onHighlightsChat}
onShowRelated={onHighlightsShowRelated}
isLoading={isLoadingHighlights}
+ onAddMemory={() => onAddMemory("note")}
/>
@@ -1430,6 +1482,43 @@ export function DashboardView({
+ {/* Weekly digest preview */}
+ {latestDigest && (
+
+
+
+
+
+
+ Weekly digest
+
+
+ {latestDigest.title || "Your week in Supermemory"} ·{" "}
+ {latestDigest.memoryCount} memories
+
+
+
+ View
+
+
+
+ )}
+
{/* Recently saved + Suggested for you */}
-
+
+
Suggested for you
-
+
+
= {
+ connections: "feat-router.png",
+ chat: "feat-memory.png",
+ extension: "feat-retrieval.png",
+ plugins: "feat-profiles.png",
+ mcp: "feat-router.png",
+ search: "feat-retrieval.png",
+}
+const BRAIN_IMG = "https://supermemory.ai/images/brain-head.png"
+
+function formatIsoWeek(isoWeek: string): string {
+ const match = isoWeek.match(/^(\d{4})-W(\d{2})$/)
+ if (!match) return isoWeek
+ const year = Number.parseInt(match[1] as string, 10)
+ const week = Number.parseInt(match[2] as string, 10)
+ const jan4 = new Date(year, 0, 4)
+ const dow = jan4.getDay() || 7
+ const start = new Date(jan4)
+ start.setDate(jan4.getDate() - dow + 1 + (week - 1) * 7)
+ const end = new Date(start)
+ end.setDate(start.getDate() + 6)
+ const months = [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec",
+ ]
+ const sm = months[start.getMonth()] ?? ""
+ const em = months[end.getMonth()] ?? ""
+ return `${sm} ${start.getDate()}–${sm === em ? "" : `${em} `}${end.getDate()}, ${year}`
+}
+
+function isoWeekLabel(isoWeek: string): string {
+ const m = isoWeek.match(/-W(\d{2})$/)
+ return m ? `W${Number.parseInt(m[1] as string, 10)}` : isoWeek
+}
+
+// ─── Left list row ────────────────────────────────────────────────────────────
+function DigestRow({
+ digest,
+ featured,
+ selected,
+ onSelect,
+}: {
+ digest: DigestSummary
+ featured: boolean
+ selected: boolean
+ onSelect: () => void
+}) {
+ return (
+
+
+
+ {isoWeekLabel(digest.isoWeek)}
+
+ {featured && (
+
+ Latest
+
+ )}
+
+
+
+ {digest.title || "Your week in Supermemory"}
+
+
+
+
+
+ {digest.memoryCount}
+
+
+ {digest.memoryCount === 1 ? "memory" : "memories"}
+
+
+
+ {formatIsoWeek(digest.isoWeek)}
+
+
+
+
+ )
+}
+
+// ─── Right pane: a single digest, faithful to the email layout ─────────────────
+function DigestContent({
+ digestId,
+ isoWeek,
+}: {
+ digestId: string
+ isoWeek: string
+}) {
+ const { data: digest, isLoading } = useDigest(digestId)
+ const [rating, setRating] = useState<"up" | "down" | null>(null)
+ const [showInput, setShowInput] = useState(false)
+ const [message, setMessage] = useState("")
+
+ // reset feedback state when switching digests
+ useEffect(() => {
+ setRating(null)
+ setShowInput(false)
+ setMessage("")
+ analytics.digestViewed({ digest_id: digestId, iso_week: isoWeek })
+ }, [digestId, isoWeek])
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+ if (!digest) {
+ return (
+
+ Digest not found.
+
+ )
+ }
+
+ const { digestData } = digest
+
+ const rate = (r: "up" | "down") => {
+ setRating(r)
+ analytics.digestFeedback({
+ digest_id: digestId,
+ iso_week: isoWeek,
+ rating: r,
+ })
+ }
+ const submitDetail = () => {
+ if (!message.trim()) return
+ analytics.digestFeedbackDetail({
+ digest_id: digestId,
+ iso_week: isoWeek,
+ rating,
+ message: message.trim(),
+ })
+ setMessage("")
+ setShowInput(false)
+ toast.success("Thanks for the feedback!")
+ }
+
+ return (
+
+ {/* Header */}
+
+ Weekly digest · {formatIsoWeek(digest.isoWeek)}
+
+
+ {digestData.title || "Your week in Supermemory"}
+
+
+ {/* Greeting + intro, brain floated right */}
+
+
+
+ {digestData.intro}
+
+
+
+
+
+
+ {/* Highlights — numbered, no boxes */}
+ {digestData.highlights.length > 0 && (
+ <>
+
+ This week's highlights
+
+
+ {digestData.highlights.map((h, i) => (
+
+
+ {String(i + 1).padStart(2, "0")}
+
+
+
+ {h.title}
+
+
+ {h.content}
+
+
+
+ ))}
+
+ >
+ )}
+
+ {/* Worth trying — the one place we use boxes (subtle) */}
+ {digestData.featureRecommendations.length > 0 && (
+
+ )}
+
+ {/* Feedback */}
+
+
+
+ Was this digest useful?
+
+
+ rate("up")}
+ className={cn(
+ "flex size-8 items-center justify-center rounded-lg border transition-colors",
+ rating === "up"
+ ? "border-[#4BA0FA]/50 bg-[#4BA0FA]/15 text-[#4BA0FA]"
+ : "border-white/[0.08] bg-white/[0.02] text-[#A1A1AA] hover:bg-white/[0.05]",
+ )}
+ >
+
+
+ rate("down")}
+ className={cn(
+ "flex size-8 items-center justify-center rounded-lg border transition-colors",
+ rating === "down"
+ ? "border-[#4BA0FA]/50 bg-[#4BA0FA]/15 text-[#4BA0FA]"
+ : "border-white/[0.08] bg-white/[0.02] text-[#A1A1AA] hover:bg-white/[0.05]",
+ )}
+ >
+
+
+ setShowInput((v) => !v)}
+ className={cn(
+ "flex size-8 items-center justify-center rounded-lg border transition-colors",
+ showInput
+ ? "border-[#4BA0FA]/50 bg-[#4BA0FA]/15 text-[#4BA0FA]"
+ : "border-white/[0.08] bg-white/[0.02] text-[#A1A1AA] hover:bg-white/[0.05]",
+ )}
+ >
+
+
+
+
+
+ {showInput && (
+
+ )}
+
+
+ )
+}
+
+// ─── Master-detail ─────────────────────────────────────────────────────────────
+export function DigestsView({ initialDigestId }: DigestsViewProps) {
+ const { data: digests, isLoading } = useDigests()
+ const [selectedId, setSelectedId] = useState(
+ initialDigestId ?? null,
+ )
+
+ // default to the most recent digest
+ const effectiveId = selectedId ?? digests?.[0]?.id ?? null
+ const selected = digests?.find((d) => d.id === effectiveId) ?? digests?.[0]
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (!digests || digests.length === 0) {
+ return (
+
+
+
+
+
No digests yet
+
+ Your first weekly digest arrives Monday. It'll show your highlights
+ and suggestions right here.
+
+
+ )
+ }
+
+ return (
+
+
+ {/* Left: digest list (scrolls independently) */}
+
+
+
+ Weekly Digests
+
+
+ {digests.length} {digests.length === 1 ? "digest" : "digests"} ·
+ new every Monday
+
+
+
+ {digests.map((d, i) => (
+ setSelectedId(d.id)}
+ />
+ ))}
+
+
+
+ {/* Right: selected digest in a document-viewer surface (not a modal) */}
+
+
+
+ {selected && (
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/apps/web/components/document-cards/file-preview.tsx b/apps/web/components/document-cards/file-preview.tsx
index 2f0015fa..f7d31d24 100644
--- a/apps/web/components/document-cards/file-preview.tsx
+++ b/apps/web/components/document-cards/file-preview.tsx
@@ -11,36 +11,37 @@ type DocumentsResponse = z.infer
type DocumentWithMemories = DocumentsResponse["documents"][0]
function getFileTypeInfo(document: DocumentWithMemories): {
+ fileName?: string
extension: string
color?: string
} {
const type = document.type?.toLowerCase()
- const mimeType = document.metadata?.mimeType as string | undefined
+ const mimeType = (document.metadata?.mimeType ??
+ document.metadata?.sm_internal_fileType) as string | undefined
+ const fileName = (document.metadata?.sm_internal_fileName ??
+ document.metadata?.fileName) as string | undefined
+ const nameExt = fileName?.includes(".")
+ ? fileName.slice(fileName.lastIndexOf(".")).toLowerCase()
+ : undefined
- if (mimeType) {
- if (mimeType === "application/pdf") {
- return { extension: ".pdf", color: "#FF7673" }
- }
- if (mimeType.startsWith("image/")) {
- const ext = mimeType.split("/")[1] || "jpg"
- return { extension: `.${ext}` }
- }
- if (mimeType.startsWith("video/")) {
- const ext = mimeType.split("/")[1] || "mp4"
- return { extension: `.${ext}` }
- }
+ if (nameExt === ".pdf" || mimeType === "application/pdf" || type === "pdf") {
+ return { fileName, extension: ".pdf", color: "#FF7673" }
}
-
- switch (type) {
- case "pdf":
- return { extension: ".pdf", color: "#FF7673" }
- case "image":
- return { extension: ".jpg" }
- case "video":
- return { extension: ".mp4" }
- default:
- return { extension: ".file" }
+ if (mimeType?.startsWith("image/") || type === "image") {
+ const ext = nameExt || `.${mimeType?.split("/")[1] || "jpg"}`
+ return { fileName, extension: ext }
}
+ if (mimeType?.startsWith("video/") || type === "video") {
+ const ext = nameExt || `.${mimeType?.split("/")[1] || "mp4"}`
+ return { fileName, extension: ext }
+ }
+ if (nameExt === ".html" || nameExt === ".htm" || mimeType === "text/html") {
+ return { fileName, extension: nameExt || ".html", color: "#FF8A4C" }
+ }
+ if (nameExt) {
+ return { fileName, extension: nameExt }
+ }
+ return { fileName, extension: ".file" }
}
export const FilePreview = memo(function FilePreview({
@@ -50,10 +51,11 @@ export const FilePreview = memo(function FilePreview({
}) {
const [imageError, setImageError] = useState(false)
const [retryKey, setRetryKey] = useState(0)
- const { extension, color } = getFileTypeInfo(document)
+ const { fileName, extension, color } = getFileTypeInfo(document)
const type = document.type?.toLowerCase()
- const mimeType = document.metadata?.mimeType as string | undefined
+ const mimeType = (document.metadata?.mimeType ??
+ document.metadata?.sm_internal_fileType) as string | undefined
const isImage =
(mimeType?.startsWith("image/") || type === "image") &&
document.url &&
@@ -102,17 +104,23 @@ export const FilePreview = memo(function FilePreview({
) : (
-
+
- {extension}
+ {fileName || extension}
{document.content && (
diff --git a/apps/web/components/document-cards/plugin-preview.tsx b/apps/web/components/document-cards/plugin-preview.tsx
index e4b90419..bd40e76c 100644
--- a/apps/web/components/document-cards/plugin-preview.tsx
+++ b/apps/web/components/document-cards/plugin-preview.tsx
@@ -28,15 +28,7 @@ export function PluginPreview({ parsed }: { parsed: ParsedPluginDocument }) {
)}
{parsed.pluginLabel}
-
- {parsed.formatLabel}
-
- {parsed.identifierValue && (
-
- {parsed.identifierValue}
-
- )}
+ const user = candidate.user
+ if (
+ typeof user !== "object" ||
+ user === null ||
+ Array.isArray(user) ||
+ typeof (user as { screen_name?: unknown }).screen_name !== "string"
+ ) {
+ return false
+ }
+ if (typeof candidate.text !== "string") return false
+ if (!Array.isArray(candidate.display_text_range)) return false
+ const entities = candidate.entities
+ if (
+ typeof entities !== "object" ||
+ entities === null ||
+ Array.isArray(entities)
+ ) {
+ return false
+ }
+ return true
+}
+
+function ensureArray(value: unknown): T[] {
+ return Array.isArray(value) ? (value as T[]) : []
+}
+
+// enrichTweet iterates entities.hashtags, .user_mentions, .urls and .symbols
+// with `for...of`, so any of them being absent throws "not iterable". A real
+// syndication payload always carries the four arrays (often empty), so default
+// them before the tweet reaches enrichTweet. `media` is intentionally left
+// untouched: enrichTweet only reads it when truthy and indexes media[0], so an
+// empty array would crash — the enrich-time guard below covers that case.
+function normalizeTweet(tweet: Tweet): Tweet {
+ const entities = tweet.entities as unknown as Record
+ return {
+ ...tweet,
+ entities: {
+ ...tweet.entities,
+ hashtags: ensureArray(entities.hashtags),
+ user_mentions: ensureArray(entities.user_mentions),
+ urls: ensureArray(entities.urls),
+ symbols: ensureArray(entities.symbols),
+ },
+ }
+}
+
+function parseTweetData(data: Tweet | string): Tweet | null {
+ if (!data) return null
+ let value: unknown = data
+ if (typeof data === "string") {
+ try {
+ value = JSON.parse(data)
+ } catch (error) {
+ console.warn("TweetPreview: failed to parse tweet data", error)
+ return null
+ }
+ }
+ if (!isTweetLike(value)) {
+ console.warn("TweetPreview: parsed value did not match Tweet shape")
+ return null
+ }
+ return normalizeTweet(value)
+}
+
+function TweetPreviewFallback({ noBgColor }: { noBgColor?: boolean }) {
+ return (
+
+ Tweet preview unavailable
+
+ )
+}
+
export function TweetPreview({
data,
noBgColor,
}: {
- data: Tweet
+ data: Tweet | string
noBgColor?: boolean
}) {
- const parsedTweet = typeof data === "string" ? JSON.parse(data) : data
- const tweet = enrichTweet(parsedTweet)
+ const tweet = useMemo(() => {
+ const parsed = parseTweetData(data)
+ if (!parsed) return null
+ try {
+ return enrichTweet(parsed)
+ } catch (error) {
+ // enrichTweet still walks quoted tweets, media entities and per-entity
+ // indices, so a partially-malformed payload can throw here even after
+ // the shape check. Degrade to the fallback instead of crashing render.
+ console.warn("TweetPreview: failed to enrich tweet data", error)
+ return null
+ }
+ }, [data])
+
+ if (!tweet) return
return (
+ }
+ switch (ext) {
+ case ".pdf":
+ return
+ case ".doc":
+ case ".docx":
+ return (
+
+
+
+ )
+ case ".xls":
+ case ".xlsx":
+ case ".csv":
+ return (
+
+
+
+ )
+ case ".ppt":
+ case ".pptx":
+ return (
+
+
+
+ )
+ case ".md":
+ case ".mdx":
+ case ".txt":
+ case ".json":
+ return
+ default:
+ return null
+ }
+}
+
export function DocumentIcon({
type,
source,
url,
+ fileName,
+ mimeType,
className,
}: DocumentIconProps) {
const iconClassName = cn("size-4", className)
@@ -159,10 +207,26 @@ export function DocumentIcon({
return
}
- if (url?.includes("youtube.com") || url?.includes("youtu.be")) {
+ if (isYouTubeUrl(url)) {
return
}
+ // Uploaded files get a type icon, never the URL favicon of their storage host
+ if (fileName || mimeType) {
+ const lower = fileName?.toLowerCase()
+ const ext = lower?.includes(".")
+ ? lower.slice(lower.lastIndexOf("."))
+ : undefined
+ const fileIcon = fileExtensionIcon(ext, mimeType, iconClassName)
+ if (fileIcon) return fileIcon
+ if (mimeType?.startsWith("image/")) {
+ return
+ }
+ if (!type || type === "unknown" || type === "text") {
+ return
+ }
+ }
+
if (
type === "webpage" ||
type === "url" ||
diff --git a/apps/web/components/document-modal/content/image-preview.tsx b/apps/web/components/document-modal/content/image-preview.tsx
index 9fe54474..7d9aac6b 100644
--- a/apps/web/components/document-modal/content/image-preview.tsx
+++ b/apps/web/components/document-modal/content/image-preview.tsx
@@ -1,31 +1,60 @@
"use client"
-import { useCallback, useState } from "react"
+import { useCallback, useEffect, useRef, useState } from "react"
import { cn } from "@lib/utils"
+import { getCachedFileBlob } from "@/lib/file-cache"
interface ImagePreviewProps {
url: string
title?: string | null
+ documentId?: string | null
}
-export function ImagePreview({ url, title }: ImagePreviewProps) {
+export function ImagePreview({ url, title, documentId }: ImagePreviewProps) {
const [imageError, setImageError] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [retryKey, setRetryKey] = useState(0)
+ const [activeSrc, setActiveSrc] = useState(url)
+ const objectUrlRef = useRef(null)
+
+ useEffect(() => {
+ return () => {
+ if (objectUrlRef.current) {
+ URL.revokeObjectURL(objectUrlRef.current)
+ objectUrlRef.current = null
+ }
+ }
+ }, [])
- // On first failure, wait briefly then force a re-render with a new key to
- // retry the fetch (covers transient R2 timing issues).
- // On second failure, give up and show the error state.
const handleImageError = useCallback(() => {
if (retryKey === 0) {
setTimeout(() => setRetryKey(1), 500)
return
}
+
+ if (retryKey === 1 && documentId) {
+ getCachedFileBlob(documentId).then((blob) => {
+ if (blob) {
+ if (objectUrlRef.current) {
+ URL.revokeObjectURL(objectUrlRef.current)
+ }
+ const objUrl = URL.createObjectURL(blob)
+ objectUrlRef.current = objUrl
+ setActiveSrc(objUrl)
+ setRetryKey(2)
+ } else {
+ setImageError(true)
+ setIsLoading(false)
+ }
+ })
+ return
+ }
+
setImageError(true)
setIsLoading(false)
- }, [retryKey])
+ }, [retryKey, documentId])
- if (imageError || !url) {
+ if (imageError || !activeSrc) {
return (
Failed to load image
@@ -43,7 +72,7 @@ export function ImagePreview({ url, title }: ImagePreviewProps) {
+ return (
+
+ )
case "tweet":
return (
diff --git a/apps/web/components/document-modal/content/pdf.tsx b/apps/web/components/document-modal/content/pdf.tsx
index e633db86..ca8d6f90 100644
--- a/apps/web/components/document-modal/content/pdf.tsx
+++ b/apps/web/components/document-modal/content/pdf.tsx
@@ -1,23 +1,55 @@
"use client"
import { Document, Page, pdfjs } from "react-pdf"
-import { useCallback, useMemo, useState } from "react"
+import { useEffect, useMemo, useRef, useState } from "react"
import "react-pdf/dist/Page/AnnotationLayer.css"
import "react-pdf/dist/Page/TextLayer.css"
+import { getCachedFileBlob } from "@/lib/file-cache"
-// Configure PDF.js worker to use local package
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString()
+type FileSource = string | { url: string; withCredentials: boolean } | null
+
interface PdfViewerProps {
url: string | null | undefined
documentId?: string | null
}
export function PdfViewer({ url, documentId }: PdfViewerProps) {
- const fileSource = useMemo(() => {
+ const [cachedUrl, setCachedUrl] = useState
(null)
+ const [cacheChecked, setCacheChecked] = useState(false)
+ const objectUrlRef = useRef(null)
+
+ useEffect(() => {
+ let revoked = false
+ if (!documentId) {
+ setCacheChecked(true)
+ return
+ }
+
+ getCachedFileBlob(documentId).then((blob) => {
+ if (revoked) return
+ if (blob) {
+ const objUrl = URL.createObjectURL(blob)
+ objectUrlRef.current = objUrl
+ setCachedUrl(objUrl)
+ }
+ setCacheChecked(true)
+ })
+
+ return () => {
+ revoked = true
+ if (objectUrlRef.current) {
+ URL.revokeObjectURL(objectUrlRef.current)
+ objectUrlRef.current = null
+ }
+ }
+ }, [documentId])
+
+ const remoteFileSource: FileSource = useMemo(() => {
if (!url) return null
try {
if (new URL(url).hostname === "www.googleapis.com" && documentId) {
@@ -32,12 +64,34 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
return url
}, [url, documentId])
+ const backendProxySource: FileSource = useMemo(() => {
+ if (!documentId) return null
+ const base =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+ return { url: `${base}/v3/file-proxy/${documentId}`, withCredentials: true }
+ }, [documentId])
+
const [numPages, setNumPages] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
- const [retryKey, setRetryKey] = useState(0)
+ const [failedSources, setFailedSources] = useState(0)
- if (!url) {
+ const fileSource = useMemo((): FileSource => {
+ if (cachedUrl) return cachedUrl
+ if (failedSources === 0) return remoteFileSource
+ if (failedSources === 1 && backendProxySource) return backendProxySource
+ return null
+ }, [cachedUrl, failedSources, remoteFileSource, backendProxySource])
+
+ if (!cacheChecked) {
+ return (
+
+ Loading PDF…
+
+ )
+ }
+
+ if (!url && !cachedUrl) {
return (
No PDF URL provided
@@ -51,24 +105,26 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
setError(null)
}
- // On first failure, wait briefly then force a re-mount of the Document
- // component to retry (covers transient R2 timing issues).
- // On second failure, give up and show the error state.
- const onDocumentLoadError = useCallback(
- (err: Error) => {
- if (retryKey === 0) {
- setTimeout(() => {
- setRetryKey(1)
- setLoading(true)
- setError(null)
- }, 500)
- return
- }
+ function onDocumentLoadError(err: Error) {
+ if (cachedUrl) {
setError(err.message || "Failed to load PDF")
setLoading(false)
- },
- [retryKey],
- )
+ return
+ }
+
+ const nextFailed = failedSources + 1
+ const hasMoreSources =
+ (nextFailed === 1 && backendProxySource !== null) || nextFailed < 1
+
+ if (hasMoreSources) {
+ setFailedSources(nextFailed)
+ setLoading(true)
+ setError(null)
+ } else {
+ setError(err.message || "Failed to load PDF")
+ setLoading(false)
+ }
+ }
return (
@@ -82,35 +138,33 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
Error: {error}
)}
-
-
- {numPages && (
-
- {Array.from(new Array(numPages), (_, index) => (
-
- ))}
-
- )}
-
-
+ {fileSource && (
+
+
+ {numPages && (
+
+ {Array.from(new Array(numPages), (_, index) => (
+
+ ))}
+
+ )}
+
+
+ )}
)
}
diff --git a/apps/web/components/document-modal/content/plugin-content.tsx b/apps/web/components/document-modal/content/plugin-content.tsx
index ef1fdce4..5b4fd6d0 100644
--- a/apps/web/components/document-modal/content/plugin-content.tsx
+++ b/apps/web/components/document-modal/content/plugin-content.tsx
@@ -47,24 +47,6 @@ function PluginHeader({ parsed }: { parsed: ParsedPluginDocument }) {
>
{parsed.pluginLabel}
-
- {parsed.formatLabel}
-
- {parsed.identifierLabel && parsed.identifierValue && (
-
- {parsed.identifierLabel}: {parsed.identifierValue}
-
- )}
>(
@@ -193,7 +195,10 @@ export function GraphListMemories({
return (
type DocumentWithMemories = DocumentsResponse["documents"][0]
@@ -197,16 +199,25 @@ export function DocumentModal({
const isMobile = useIsMobile()
const { updateMutation, deleteMutation } = useDocumentMutations({ onClose })
+ const fullContent = useFullDocumentContent(_document?.id, isOpen)
+ const effectiveDocument = useMemo(
+ () =>
+ _document && fullContent !== null
+ ? { ..._document, content: fullContent }
+ : _document,
+ [_document, fullContent],
+ )
+
const { initialEditorContent, initialEditorString } = useMemo(() => {
- const content = _document?.content as string | null | undefined
+ const content = effectiveDocument?.content as string | null | undefined
return {
initialEditorContent: content ?? undefined,
initialEditorString: content ?? "",
}
- }, [_document?.content])
+ }, [effectiveDocument?.content])
const pluginDocument = useMemo(
- () => parsePluginDocument(_document),
- [_document],
+ () => parsePluginDocument(effectiveDocument),
+ [effectiveDocument],
)
const [draftContentString, setDraftContentString] =
@@ -268,60 +279,108 @@ export function DocumentModal({
],
)
- return (
-
!open && onClose()}>
-
+ const hasDocumentInsights = Boolean(
+ _document?.summary ||
+ pluginDocument?.summary ||
+ (_document?.memoryEntries && _document.memoryEntries.length > 0),
+ )
+
+ const documentPreview = (
+
+
+
+ )
+
+ const documentInsights = (
+
+ {_document && (_document.summary || pluginDocument?.summary) && (
+
+ )}
+ {_document?.memoryEntries && _document.memoryEntries.length > 0 && (
+
+ )}
+
+ )
+
+ const modalContent = (
+ <>
+ {isMobile ? (
+
+ {_document?.title} - Document
+
+ ) : (
{_document?.title} - Document
-
-
-
-
-
- {pluginDocument?.kind === "claude-code-doc" &&
- _document?.customId && (
-
- )}
-
- {_document?.url && (
-
- {!isMobile && (
- Visit source
- )}
-
-
+ )}
+
+
+
+
+
+ {pluginDocument?.kind === "claude-code-doc" &&
+ _document?.customId && (
+
)}
+
+ {_document?.url && (
+
+ {!isMobile && Visit source }
+
+
+ )}
+ {isMobile ? (
+
+
+ Close
+
+ ) : (
Close
-
+ )}
-
-
+ {isMobile && hasDocumentInsights ? (
+
+
+
+ Content
+
+
+ Insights
+
+
+
+ {documentPreview}
+
+
-
-
-
- {pluginDocument &&
- pluginDocument.kind !== "claude-code-doc" &&
- pluginDocument.kind !== "openclaw-session" && (
-
- )}
- {_document && (_document.summary || pluginDocument?.summary) && (
-
- )}
- {_document?.memoryEntries && _document.memoryEntries.length > 0 && (
-
- )}
-
+ {documentInsights}
+
+
+ ) : isMobile ? (
+
{documentPreview}
+ ) : (
+
+ {documentPreview}
+ {documentInsights}
+ )}
+ >
+ )
+
+ if (isMobile) {
+ return (
+
!open && onClose()}
+ shouldScaleBackground
+ >
+ div:first-child]:bg-[#3A4252] [&>div:first-child]:h-1 [&>div:first-child]:w-9 [&>div:first-child]:mt-2.5 [&>div:first-child]:mb-1",
+ dmSansClassName(),
+ )}
+ style={{
+ boxShadow:
+ "0 -12px 40px rgba(0, 0, 0, 0.45), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
+ }}
+ >
+
+ {modalContent}
+
+
+
+ )
+ }
+
+ return (
+
!open && onClose()}>
+
+ {modalContent}
)
diff --git a/apps/web/components/document-modal/plugin-details.tsx b/apps/web/components/document-modal/plugin-details.tsx
deleted file mode 100644
index 29e5630d..00000000
--- a/apps/web/components/document-modal/plugin-details.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import Image from "next/image"
-import { cn } from "@lib/utils"
-import { dmSansClassName } from "@/lib/fonts"
-import type { ParsedPluginDocument } from "@/lib/plugin-document"
-
-function DetailPill({ label, value }: { label: string; value: string }) {
- return (
-
-
- {label}
-
-
{value}
-
- )
-}
-
-export function PluginDetails({ parsed }: { parsed: ParsedPluginDocument }) {
- return (
-
-
-
- Details
-
-
- {parsed.pluginIconSrc && (
-
- )}
- {parsed.pluginLabel}
-
-
-
-
- {parsed.identifierLabel && parsed.identifierValue && (
-
- )}
- {parsed.clientLabel && parsed.clientValue && (
-
- )}
-
- {parsed.artifacts.length > 0 && (
-
-
- Outputs
-
-
- {parsed.artifacts.map((artifact, index) => (
-
- ))}
-
-
- )}
-
- )
-}
diff --git a/apps/web/components/documents-command-palette.tsx b/apps/web/components/documents-command-palette.tsx
index 11b9151e..6e711b29 100644
--- a/apps/web/components/documents-command-palette.tsx
+++ b/apps/web/components/documents-command-palette.tsx
@@ -14,6 +14,7 @@ import { useIsMobile } from "@hooks/use-mobile"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { SearchIcon, Settings, Home, Plus, Code2, Loader2 } from "lucide-react"
import { DocumentIcon } from "@/components/document-icon"
+import { useSettingsModal } from "@/components/settings/settings-modal"
import { $fetch } from "@lib/api"
type DocumentsResponse = z.infer
@@ -52,6 +53,7 @@ export function DocumentsCommandPalette({
}: DocumentsCommandPaletteProps) {
const isMobile = useIsMobile()
const router = useRouter()
+ const { openSettings } = useSettingsModal()
const queryClient = useQueryClient()
const [search, setSearch] = useState("")
const [selectedIndex, setSelectedIndex] = useState(0)
@@ -86,7 +88,7 @@ export function DocumentsCommandPalette({
id: "settings",
label: "Go to Settings",
icon: ,
- action: () => close(() => router.push("/settings")),
+ action: () => close(() => openSettings()),
},
...(onAddMemory
? [
diff --git a/apps/web/components/edit-space-modal.tsx b/apps/web/components/edit-space-modal.tsx
new file mode 100644
index 00000000..434c2c1a
--- /dev/null
+++ b/apps/web/components/edit-space-modal.tsx
@@ -0,0 +1,206 @@
+"use client"
+
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { Loader2, XIcon } from "lucide-react"
+import { useEffect, useState } from "react"
+import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
+import { useSpaceContext, useUpdateSpace } from "@/hooks/use-space-context"
+import { CONTEXT_PRESETS } from "@/components/add-space-modal"
+import { DEFAULT_PROJECT_ID } from "@lib/constants"
+import { cn } from "@lib/utils"
+import { Button } from "@ui/components/button"
+import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
+
+const INPUT_SHADOW =
+ "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)"
+
+type EditSpaceModalProps = {
+ containerTag: string
+ currentName: string
+ currentEmoji?: string
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function EditSpaceModal({
+ containerTag,
+ currentName,
+ currentEmoji = "📁",
+ open,
+ onOpenChange,
+}: EditSpaceModalProps) {
+ const isDefault = !containerTag || containerTag === DEFAULT_PROJECT_ID
+ const { data, isLoading } = useSpaceContext(containerTag, open)
+ const update = useUpdateSpace()
+ const [name, setName] = useState(currentName)
+ const [context, setContext] = useState("")
+
+ useEffect(() => {
+ if (!open) return
+ setName(data?.name ?? currentName ?? "")
+ setContext(data?.entityContext ?? "")
+ }, [open, data?.name, data?.entityContext, currentName])
+
+ const handleSave = () => {
+ update.mutate(
+ {
+ containerTag,
+ name: isDefault ? undefined : name.trim() || undefined,
+ entityContext: context.trim() ? context.trim() : null,
+ },
+ { onSuccess: () => onOpenChange(false) },
+ )
+ }
+
+ return (
+
+
+
+
+
+
+ Edit space
+
+
+ Rename this space and tell Nova what to remember in it.
+
+
+
+
+ Close
+
+
+
+
+
+
+ {currentEmoji}
+
+
setName(e.target.value)}
+ disabled={isDefault || update.isPending}
+ placeholder="Space name"
+ maxLength={120}
+ className={cn(
+ "flex-1 bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-4 py-3 rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(115,115,115,0.3)] disabled:opacity-60",
+ dmSansClassName(),
+ )}
+ style={{ boxShadow: INPUT_SHADOW }}
+ />
+
+ {isDefault && (
+
+ The default space can't be renamed.
+
+ )}
+
+
+
+
+
+ What to remember
+
+
+ Optional
+
+
+
+
+
+ onOpenChange(false)}
+ disabled={update.isPending}
+ className={cn(
+ "text-[#737373] font-medium text-[14px] cursor-pointer transition-colors hover:text-[#999]",
+ dmSansClassName(),
+ )}
+ >
+ Cancel
+
+
+ {update.isPending ? (
+ <>
+
+ Saving…
+ >
+ ) : (
+ "Save"
+ )}
+
+
+
+
+
+ )
+}
diff --git a/apps/web/components/ensure-workspace.tsx b/apps/web/components/ensure-workspace.tsx
index 7c6aed8b..675edff4 100644
--- a/apps/web/components/ensure-workspace.tsx
+++ b/apps/web/components/ensure-workspace.tsx
@@ -3,17 +3,35 @@
import { useEffect } from "react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useAuth } from "@lib/auth-context"
+import { Loader2 } from "lucide-react"
+
+function WorkspaceLoadingShell() {
+ return (
+
+
+
+ )
+}
export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const searchParams = useSearchParams()
- const { session, organizations, isRestoring } = useAuth()
+ const { session, organizations, isRestoring, isSessionPending } = useAuth()
- const isMcpPublicPage = searchParams.get("view") === "mcp"
+ const isPublicAppPage =
+ pathname === "/integrations" ||
+ pathname === "/integrations/mcp" ||
+ (pathname === "/" &&
+ ["integrations", "mcp"].includes(searchParams.get("view") ?? ""))
+ const isGuestPublicAppPage = isPublicAppPage && !session && !isSessionPending
+ const isOnboarding = pathname.startsWith("/onboarding")
useEffect(() => {
- if (isMcpPublicPage) return
+ if (isGuestPublicAppPage) return
if (isRestoring) return
if (!session) {
router.replace(
@@ -23,9 +41,30 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
}
if (organizations === null) return
if (organizations.length > 0) return
- if (pathname.startsWith("/onboarding")) return
+ if (isOnboarding) return
router.replace("/onboarding")
- }, [session, organizations, isRestoring, pathname, router, isMcpPublicPage])
+ }, [
+ session,
+ organizations,
+ isRestoring,
+ isOnboarding,
+ router,
+ isGuestPublicAppPage,
+ ])
+
+ const showLoading =
+ !isGuestPublicAppPage &&
+ (isRestoring ||
+ (!session && !isRestoring) ||
+ (session && organizations === null) ||
+ (session &&
+ organizations !== null &&
+ organizations.length === 0 &&
+ !isOnboarding))
+
+ if (showLoading) {
+ return
+ }
return children
}
diff --git a/apps/web/components/granola-connect-modal.tsx b/apps/web/components/granola-connect-modal.tsx
new file mode 100644
index 00000000..451b4de2
--- /dev/null
+++ b/apps/web/components/granola-connect-modal.tsx
@@ -0,0 +1,221 @@
+"use client"
+
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { Loader2, X } from "lucide-react"
+import { useEffect, useState } from "react"
+import { toast } from "sonner"
+import { $fetch } from "@lib/api"
+import { cn } from "@lib/utils"
+import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
+import { Granola } from "@ui/assets/icons"
+import { dmSans125ClassName } from "@/lib/fonts"
+import { INSET } from "./integrations/install-steps"
+
+function GranolaIconBox() {
+ return (
+
+
+
+ )
+}
+
+export function GranolaConnectModal({
+ open,
+ onOpenChange,
+ containerTags,
+ onSuccess,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ containerTags?: string[]
+ onSuccess?: () => void
+}) {
+ const queryClient = useQueryClient()
+ const [apiKey, setApiKey] = useState("")
+ const [errorMessage, setErrorMessage] = useState(null)
+
+ // Reset form whenever the modal opens.
+ useEffect(() => {
+ if (open) {
+ setApiKey("")
+ setErrorMessage(null)
+ }
+ }, [open])
+
+ const connectMutation = useMutation({
+ mutationFn: async (key: string) => {
+ const response = await $fetch("@post/connections/:provider", {
+ params: { provider: "granola" },
+ body: {
+ containerTags,
+ metadata: { apiKey: key },
+ },
+ })
+ if (response.error) {
+ const msg =
+ (response.error as { message?: string })?.message ||
+ "Failed to connect"
+ throw new Error(msg)
+ }
+ return response.data
+ },
+ onSuccess: () => {
+ toast.success("Granola connected")
+ queryClient.invalidateQueries({ queryKey: ["connections"] })
+ onSuccess?.()
+ onOpenChange(false)
+ },
+ onError: (error) => {
+ setErrorMessage(
+ error instanceof Error ? error.message : "Failed to connect",
+ )
+ },
+ })
+
+ const trimmedKey = apiKey.trim()
+ const canConnect = trimmedKey.length > 0 && !connectMutation.isPending
+
+ const handleConnect = () => {
+ setErrorMessage(null)
+ connectMutation.mutate(trimmedKey)
+ }
+
+ return (
+
+
+ Connect Granola
+
+
+
+
+
+ Connect Granola
+
+
+ Paste your API key to sync meeting notes.
+
+
+
+
+
+
+
+
+
+ Granola API Key
+
+
{
+ setApiKey(e.target.value)
+ if (errorMessage) setErrorMessage(null)
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && canConnect) handleConnect()
+ }}
+ placeholder="grn_..."
+ className={cn(
+ dmSans125ClassName(),
+ "w-full rounded-[10px] bg-[#0D121A] px-3 py-2.5 text-[13px] text-[#FAFAFA] placeholder:text-[#52525B] outline-none border border-white/[0.06] focus:border-white/[0.16]",
+ )}
+ />
+
+ Create one in Granola → Settings → Connectors → API keys. Requires a
+ Business or Enterprise plan.
+
+ {errorMessage && (
+
+ {errorMessage}
+
+ )}
+
+
+
+ onOpenChange(false)}
+ className={cn(
+ dmSans125ClassName(),
+ "flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#A1A1AA] transition-opacity hover:opacity-80",
+ INSET,
+ )}
+ >
+ Cancel
+
+
+ {connectMutation.isPending && (
+
+ )}
+ Connect
+
+
+
+
+ )
+}
diff --git a/apps/web/components/graph-layout-view.tsx b/apps/web/components/graph-layout-view.tsx
index c1fc0311..0e9f5d67 100644
--- a/apps/web/components/graph-layout-view.tsx
+++ b/apps/web/components/graph-layout-view.tsx
@@ -35,15 +35,14 @@ export const GraphLayoutView = memo(function GraphLayoutView({
}, [setIsShareModalOpen])
return (
-
+
{/* Full-width graph */}
-
+
diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx
index 6847e5cd..c30f84c4 100644
--- a/apps/web/components/header.tsx
+++ b/apps/web/components/header.tsx
@@ -19,6 +19,7 @@ import {
import { Button } from "@ui/components/button"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
+import { getBillingSettingsUrl } from "@/lib/url-helpers"
import { GraphIcon, IntegrationsIcon } from "@/components/integration-icons"
import {
DropdownMenu,
@@ -29,7 +30,6 @@ import {
} from "@ui/components/dropdown-menu"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
import { useProject } from "@/stores"
-import { useRouter } from "next/navigation"
import Link from "next/link"
import { SpaceSelector } from "./space-selector"
import { useIsMobile } from "@hooks/use-mobile"
@@ -43,6 +43,9 @@ import { useCustomer } from "autumn-js/react"
import { useTokenUsage } from "@/hooks/use-token-usage"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
+import { useSettingsModal } from "@/components/settings/settings-modal"
+import { useHasCompanyBrain } from "@/hooks/use-company-brain"
+import { CompanyBrainHeader } from "@/components/company-brain-header"
interface HeaderProps {
onAddMemory?: () => void
@@ -64,7 +67,15 @@ const brainTileClass = (active: boolean) =>
: "border-white/[0.08] bg-white/[0.04] text-white/70",
)
-export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
+export function Header(props: HeaderProps) {
+ const hasCompanyBrain = useHasCompanyBrain()
+ if (hasCompanyBrain) {
+ return
+ }
+ return
+}
+
+function PersonalBrainHeader({ onAddMemory, onOpenSearch }: HeaderProps) {
const { user, isRestoring, org, organizations, setActiveOrg } = useAuth()
const autumn = useCustomer()
const { currentPlan } = useTokenUsage(autumn)
@@ -73,13 +84,14 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
(orgSummaries ?? []).map((s) => [s.orgId, s.plan] as const),
)
const { selectedProjects, setSelectedProjects } = useProject()
- const router = useRouter()
+ const { openSettings } = useSettingsModal()
const isMobile = useIsMobile()
const [feedbackOpen, setFeedbackOpen] = useQueryState(
"feedback",
feedbackParam,
)
const { viewMode, setViewMode } = useViewMode()
+ const billingSettingsUrl = getBillingSettingsUrl()
const handleFeedback = () => setFeedbackOpen(true)
@@ -205,6 +217,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
selectedProjects={selectedProjects}
onValueChange={setSelectedProjects}
enableDelete
+ enableEdit
/>
>
)}
@@ -352,6 +365,15 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
+
+
+
+ Upgrade
+
+
router.push("/settings")}
+ onClick={() => openSettings()}
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
@@ -421,6 +443,27 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
>
) : (
<>
+
+
+
+
+
+ Upgrade
+
+
+
+
+ Upgrade
+
+
+
+
+
+ supermemory
+
+
+
+
+
+
+ Log in with Supermemory
+
+
+
+ )
+ }
+
return (
-
+
void
onShowRelated: (query: string) => void
isLoading?: boolean
+ onAddMemory?: () => void
}
function renderContent(content: string, format: HighlightFormat) {
@@ -69,10 +76,14 @@ export function HighlightsCard({
onChat,
onShowRelated,
isLoading = false,
+ onAddMemory,
}: HighlightsCardProps) {
const [activeIndex, setActiveIndex] = useState(0)
const [isReplyOpen, setIsReplyOpen] = useState(false)
const [replyText, setReplyText] = useState("")
+ const [isExpanded, setIsExpanded] = useState(false)
+ const [isClamped, setIsClamped] = useState(false)
+ const contentRef = useRef(null)
const replyInputRef = useRef(null)
const currentItem = items[activeIndex]
@@ -85,18 +96,28 @@ export function HighlightsCard({
useEffect(() => {
setIsReplyOpen(false)
setReplyText("")
+ setIsExpanded(false)
}, [items])
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-run when item or expansion changes to detect clamping
+ useLayoutEffect(() => {
+ const el = contentRef.current
+ if (!el) return
+ setIsClamped(el.scrollHeight > el.clientHeight)
+ }, [currentItem, isExpanded])
+
const handlePrev = useCallback(() => {
setActiveIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
setIsReplyOpen(false)
setReplyText("")
+ setIsExpanded(false)
}, [items.length])
const handleNext = useCallback(() => {
setActiveIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
setIsReplyOpen(false)
setReplyText("")
+ setIsExpanded(false)
}, [items.length])
const handleChatClick = useCallback(() => {
@@ -186,10 +207,19 @@ export function HighlightsCard({
-
+
Add some documents to see highlights here
+ {onAddMemory ? (
+
+ Add memory →
+
+ ) : null}
)
@@ -248,12 +278,27 @@ export function HighlightsCard({
-
+
{currentItem.title}
-
+
{renderContent(currentItem.content, currentItem.format)}
+ {(isClamped || isExpanded) && (
+
setIsExpanded((v) => !v)}
+ className="self-start text-[11px] text-fg-subtle hover:text-fg-primary transition-colors cursor-pointer"
+ >
+ {isExpanded ? "Show less" : "Show more"}
+
+ )}
{isReplyOpen && (
diff --git a/apps/web/components/initial-header.tsx b/apps/web/components/initial-header.tsx
index e7d3220f..95bc9628 100644
--- a/apps/web/components/initial-header.tsx
+++ b/apps/web/components/initial-header.tsx
@@ -4,8 +4,8 @@ import { Logo } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { useRouter } from "next/navigation"
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
-import { analytics } from "@/lib/analytics"
import { consumePendingConnectUrl } from "@/lib/constants"
+import { cn } from "@lib/utils"
export function InitialHeader({
showUserSupermemory,
@@ -22,16 +22,15 @@ export function InitialHeader({
const handleSkip = () => {
markOrgOnboarded()
- analytics.onboardingCompleted()
const pendingPath = consumePendingConnectUrl()
router.push(pendingPath ?? "/")
}
return (
-
-
-
- {showUserSupermemory && (
+
+
+
+ {showUserSupermemory ? (
{userName}
@@ -40,9 +39,13 @@ export function InitialHeader({
supermemory
+ ) : (
+
+ supermemory
+
)}
-
+
{showSkipOnboarding && !isLoading && (
+ window.open(
+ "https://console.supermemory.ai",
+ "_blank",
+ "noopener,noreferrer",
+ )
+ }
>
- Memory API ↗
+ API
+ Memory API {" "}
+ ↗
diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx
index bc9711c0..ed04a456 100644
--- a/apps/web/components/integrations-view.tsx
+++ b/apps/web/components/integrations-view.tsx
@@ -1,14 +1,16 @@
"use client"
-import { useQuery } from "@tanstack/react-query"
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useCustomer } from "autumn-js/react"
import { cn } from "@lib/utils"
-import { dmSansClassName } from "@/lib/fonts"
-import { hasActivePlan } from "@lib/queries"
+import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import { $fetch } from "@lib/api"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
-import type { ConnectionResponseSchema } from "@repo/validation/api"
+import type {
+ ConnectionResponseSchema,
+ DocumentsWithMemoriesResponseSchema,
+} from "@repo/validation/api"
import type { z } from "zod"
import { Button } from "@ui/components/button"
import {
@@ -16,127 +18,552 @@ import {
AppleShortcutsIcon,
RaycastIcon,
} from "@/components/integration-icons"
-import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
-import { ArrowLeft, Sun } from "lucide-react"
-import { CHROME_EXTENSION_URL } from "@repo/lib/constants"
+import {
+ GoogleDrive,
+ Notion,
+ OneDrive,
+ MCPIcon,
+ Granola,
+} from "@ui/assets/icons"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import {
+ ArrowLeft,
+ ArrowRight,
+ BookOpen,
+ Check,
+ ChevronDown,
+ ExternalLink,
+ FileText,
+ Globe,
+ Info,
+ Loader,
+ Plus,
+ Search,
+ X,
+ Zap,
+} from "lucide-react"
+import { formatRelativeTime } from "@/components/settings/sync-utils"
+import { useConnectorAccess } from "@/hooks/use-connector-access"
+import { useConnectionHealth } from "@/hooks/use-connection-health"
+import { useContainerTags } from "@/hooks/use-container-tags"
+import { DEFAULT_PROJECT_ID } from "@lib/constants"
+import { CHROME_EXTENSION_URL, POKE_RECIPE_URL } from "@lib/constants"
import { analytics } from "@/lib/analytics"
import Image from "next/image"
-import { IntegrationGridCard } from "@/components/integrations/integration-grid-card"
import { useViewMode } from "@/lib/view-mode-context"
-import { addDocumentParam, type ViewParamValue } from "@/lib/search-params"
-import { useQueryState } from "nuqs"
+import type { ViewParamValue } from "@/lib/search-params"
+import { parseAsString, parseAsStringEnum, useQueryState } from "nuqs"
+import { addDocumentParam, docParam } from "@/lib/search-params"
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react"
+import { AnimatePresence, motion } from "motion/react"
+import { toast } from "sonner"
+import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
+import {
+ PLUGIN_CATALOG,
+ FREE_TIER_PLUGIN_IDS,
+ isFreeTierPlugin,
+ normalizePluginClientId,
+ type InstallStep,
+} from "@/lib/plugin-catalog"
+import { INSET, InstallSteps, PillButton } from "./integrations/install-steps"
+import {
+ ShortcutsConnectButtons,
+ useShortcutsConnect,
+} from "./integrations/shortcuts-detail"
+import { MCPSteps } from "./mcp-modal/mcp-detail-view"
+import { GranolaConnectModal } from "./granola-connect-modal"
+import { detectPluginSpace, detectPluginSource } from "@/lib/plugin-space"
type Connection = z.infer
-type CardId =
- | "mcp"
- | "chrome"
- | "connections"
- | "shortcuts"
- | "raycast"
- | "import"
- | "plugins"
+type ConnectorProvider = "google-drive" | "notion" | "onedrive" | "granola"
-interface IntegrationCardDef {
- id: CardId
- title: string
- description: string
- icon: React.ReactNode
- pro?: boolean
- externalHref?: string
+interface ConnectedKey {
+ keyId: string
+ keyStart: string | null
+ pluginId: string
+ lastRequest?: string | null
+ createdAt?: string | null
}
-const cards: IntegrationCardDef[] = [
+interface ConnectedMcpKey {
+ keyId: string
+ keyStart: string | null
+ lastRequest?: string | null
+ createdAt?: string | null
+}
+
+function isMcpAuthMetadata(metadata: { sm_source?: string; sm_kind?: string }) {
+ return (
+ metadata.sm_source === "mcp" || metadata.sm_kind === "mcp_oauth_exchange"
+ )
+}
+
+function toIsoDate(value: string | Date | null | undefined): string | null {
+ if (!value) return null
+ const d = value instanceof Date ? value : new Date(value)
+ if (Number.isNaN(d.getTime())) return null
+ return d.toISOString()
+}
+
+function toMs(value: string | null | undefined): number {
+ if (!value) return 0
+ const t = new Date(value).getTime()
+ return Number.isNaN(t) ? 0 : t
+}
+
+function compactRelativeTime(value: number | string): string {
+ return formatRelativeTime(value).replace(/\s*ago$/i, "")
+}
+
+function parsePluginAuthKeys(
+ apiKeys: ListedApiKey[],
+ keyPrefix: (key: ListedApiKey) => string | null,
+): { active: ConnectedKey[]; setup: ConnectedKey[] } {
+ const active: ConnectedKey[] = []
+ const setup: ConnectedKey[] = []
+ for (const key of apiKeys) {
+ if (key.enabled === false) continue
+ if (!key.metadata) continue
+ try {
+ const metadata =
+ typeof key.metadata === "string"
+ ? (JSON.parse(key.metadata) as {
+ sm_type?: string
+ sm_client?: string
+ })
+ : (key.metadata as { sm_type?: string; sm_client?: string })
+ if (metadata.sm_type !== "plugin_auth" || !metadata.sm_client) continue
+ const entry: ConnectedKey = {
+ keyId: key.id,
+ keyStart: keyPrefix(key),
+ pluginId: normalizePluginClientId(metadata.sm_client),
+ lastRequest: toIsoDate(key.lastRequest),
+ createdAt: toIsoDate(key.createdAt),
+ }
+ if (key.lastRequest) active.push(entry)
+ else setup.push(entry)
+ } catch {}
+ }
+ return { active, setup }
+}
+
+function parseMcpAuthKeys(
+ apiKeys: ListedApiKey[],
+ keyPrefix: (key: ListedApiKey) => string | null,
+): ConnectedMcpKey[] {
+ const keys: ConnectedMcpKey[] = []
+ for (const key of apiKeys) {
+ if (key.enabled === false) continue
+ if (!key.metadata) continue
+ try {
+ const metadata =
+ typeof key.metadata === "string"
+ ? (JSON.parse(key.metadata) as {
+ sm_source?: string
+ sm_kind?: string
+ })
+ : (key.metadata as { sm_source?: string; sm_kind?: string })
+ if (!isMcpAuthMetadata(metadata)) continue
+ keys.push({
+ keyId: key.id,
+ keyStart: keyPrefix(key),
+ lastRequest: toIsoDate(key.lastRequest),
+ createdAt: toIsoDate(key.createdAt),
+ })
+ } catch {}
+ }
+ return keys
+}
+
+type ListedApiKey = {
+ id: string
+ name?: string | null
+ createdAt?: string | Date | null
+ enabled?: boolean
+ lastRequest?: string | Date | null
+ metadata: string | Record | null
+ start?: string | null
+}
+
+type ItemKind = "plugin" | "connector" | "client" | "mcp-client" | "import"
+
+type MCPClientKey =
+ | "antigravity"
+ | "chatgpt"
+ | "codex"
+ | "cursor"
+ | "claude"
+ | "vscode"
+ | "cline"
+ | "gemini-cli"
+ | "claude-code"
+ | "mcp-url"
+
+const MCP_CLIENTS: Array<{
+ key: MCPClientKey
+ name: string
+ tagline: string
+ simpleTitle?: string
+ dev?: boolean
+}> = [
{
- id: "plugins",
- title: "Plugins",
- description:
- "Hermes on every plan; Claude Code, Codex, OpenCode, OpenClaw, and more with Pro",
- icon: (
-
-
-
-
-
-
-
- ),
+ key: "antigravity",
+ name: "Antigravity",
+ tagline: "MCP config for Antigravity",
+ simpleTitle: "Bring your memory into Antigravity",
+ dev: true,
},
{
- id: "connections",
- title: "Connections",
- description: "Link Notion, Google Drive, or OneDrive to import your docs",
- pro: true,
- icon: (
-
-
-
-
-
- ),
+ key: "cursor",
+ name: "Cursor",
+ tagline: "One-click MCP install in Cursor",
+ simpleTitle: "Code with your saved knowledge nearby",
},
{
- id: "mcp",
- title: "Connect to AI",
- description: "Set up MCP to use your memory in Cursor, Claude, and more",
- icon: (
-
- ),
+ key: "claude",
+ name: "Claude Desktop",
+ tagline: "Connect supermemory in Claude Desktop",
+ simpleTitle: "Reference your notes during any Claude chat",
},
{
- id: "chrome",
- title: "Chrome Extension",
- description: "Save any webpage, import bookmarks, sync ChatGPT memories",
- icon: ,
- externalHref: CHROME_EXTENSION_URL,
+ key: "chatgpt",
+ name: "ChatGPT",
+ tagline: "Apps via ChatGPT developer mode",
+ simpleTitle: "Let ChatGPT recall what you've saved",
},
{
- id: "shortcuts",
- title: "Apple Shortcuts",
- description: "Add memories directly from iPhone, iPad or Mac",
- icon: ,
+ key: "vscode",
+ name: "VS Code",
+ tagline: "Native MCP support in VS Code",
+ simpleTitle: "Pull your knowledge into VS Code while coding",
+ dev: true,
},
{
- id: "raycast",
- title: "Raycast",
- description: "Add and search memories from Raycast on Mac",
- icon: ,
+ key: "cline",
+ name: "Cline",
+ tagline: "MCP via the Cline VS Code extension",
+ simpleTitle: "Cline can read and add to your memory",
+ dev: true,
},
{
- id: "import",
- title: "Import Bookmarks",
- description: "Bring in X/Twitter bookmarks and turn them into memories",
- icon: ,
+ key: "gemini-cli",
+ name: "Gemini CLI",
+ tagline: "Google Gemini terminal client",
+ simpleTitle: "Bring your memory into Gemini sessions",
+ dev: true,
+ },
+ {
+ key: "codex",
+ name: "Codex (MCP)",
+ tagline: "OpenAI Codex CLI via MCP config",
+ simpleTitle: "Codex with access to your saved context",
+ dev: true,
+ },
+ {
+ key: "claude-code",
+ name: "Claude Code (MCP)",
+ tagline: "Connect via Claude Code MCP config",
+ simpleTitle: "Claude Code with your project context",
+ dev: true,
+ },
+ {
+ key: "mcp-url",
+ name: "MCP URL",
+ tagline: "Use the URL in any custom MCP client",
+ simpleTitle: "Connect any MCP-capable app to supermemory",
+ dev: true,
+ },
+]
+
+function mcpClientIconSrc(key: MCPClientKey): string {
+ if (key === "mcp-url") return "/mcp-icon.svg"
+ if (key === "antigravity") return "/mcp-supported-tools/antigravity.png"
+ const file = key === "claude-code" ? "claude" : key
+ return `/mcp-supported-tools/${file}.png`
+}
+
+const PLUGIN_SIMPLE_TITLES: Record = {
+ claude_code: "Remembers your code conventions and decisions",
+ codex: "Codex sessions that remember your project",
+ cursor: "Cursor sessions with persistent project memory",
+ opencode: "OpenCode with persistent project memory",
+ openclaw: "Save chats from Telegram, Discord and Slack",
+ hermes: "Persistent memory for the Hermes agent",
+}
+
+type CategoryFilter =
+ | "all"
+ | "connected"
+ | "plugins"
+ | "knowledge-bases"
+ | "apps-extensions"
+ | "ai-clients"
+
+const CATEGORY_VALUES: readonly CategoryFilter[] = [
+ "all",
+ "connected",
+ "ai-clients",
+ "plugins",
+ "knowledge-bases",
+ "apps-extensions",
+] as const
+
+const catParam = parseAsStringEnum([
+ ...CATEGORY_VALUES,
+]).withDefault("all")
+
+const CATEGORY_LABEL: Record = {
+ all: "All",
+ connected: "Active",
+ plugins: "Plugins",
+ "knowledge-bases": "Knowledge bases",
+ "apps-extensions": "Apps & extensions",
+ "ai-clients": "MCP",
+}
+
+const SECTION_ORDER: Array> = [
+ "ai-clients",
+ "plugins",
+ "knowledge-bases",
+ "apps-extensions",
+]
+
+function itemCategory(
+ item: Item,
+): Exclude {
+ switch (item.kind) {
+ case "plugin":
+ return "plugins"
+ case "connector":
+ return "knowledge-bases"
+ case "mcp-client":
+ return "ai-clients"
+ case "client":
+ case "import":
+ return "apps-extensions"
+ }
+}
+
+interface BaseItem {
+ id: string
+ name: string
+ tagline: string
+ icon: ReactNode
+ docsUrl?: string
+ pro?: boolean
+ max?: boolean
+ kind: ItemKind
+ simpleTitle?: string
+ dev?: boolean
+ isNew?: boolean
+}
+
+interface PluginItem extends BaseItem {
+ kind: "plugin"
+ pluginId: string
+}
+
+interface ConnectorItem extends BaseItem {
+ kind: "connector"
+ provider: ConnectorProvider
+}
+
+interface ClientItem extends BaseItem {
+ kind: "client"
+ action:
+ | { type: "external"; href: string }
+ | { type: "view"; viewMode: ViewParamValue }
+}
+
+interface MCPClientItem extends BaseItem {
+ kind: "mcp-client"
+ clientKey: MCPClientKey
+}
+
+interface ImportItem extends BaseItem {
+ kind: "import"
+ viewMode: ViewParamValue
+}
+
+type Item = PluginItem | ConnectorItem | ClientItem | MCPClientItem | ImportItem
+
+const SECTIONS: Array<{
+ label: string
+ items: (plugin: typeof PLUGIN_CATALOG) => Item[]
+}> = [
+ {
+ label: "MCP",
+ items: () =>
+ MCP_CLIENTS.map((c) => ({
+ kind: "mcp-client",
+ clientKey: c.key,
+ id: `mcp-${c.key}`,
+ name: c.name,
+ tagline: c.tagline,
+ simpleTitle: c.simpleTitle,
+ dev: c.dev,
+ icon:
+ c.key === "mcp-url" ? (
+
+ ) : (
+
+ ),
+ docsUrl: "https://supermemory.ai/docs/supermemory-mcp/introduction",
+ })),
+ },
+ {
+ label: "Plugins",
+ items: (catalog) =>
+ Object.keys(catalog).map((id) => {
+ const plugin = catalog[id]
+ if (!plugin) throw new Error(`Missing plugin ${id}`)
+ return {
+ kind: "plugin",
+ id: `plugin-${id}`,
+ pluginId: id,
+ name: plugin.name,
+ tagline: plugin.tagline,
+ icon: (
+
+ ),
+ docsUrl: plugin.docsUrl,
+ pro: !FREE_TIER_PLUGIN_IDS.includes(id),
+ simpleTitle: PLUGIN_SIMPLE_TITLES[id],
+ dev: true,
+ }
+ }),
+ },
+ {
+ label: "Knowledge bases",
+ items: () => [
+ {
+ kind: "connector",
+ id: "google-drive",
+ provider: "google-drive",
+ name: "Google Drive",
+ tagline: "Sync Docs, Sheets and Slides into your memory",
+ simpleTitle: "Your Docs, Sheets and Slides, searchable",
+ icon: ,
+ pro: true,
+ docsUrl: "https://supermemory.ai/docs/connectors/google-drive",
+ },
+ {
+ kind: "connector",
+ id: "notion",
+ provider: "notion",
+ name: "Notion",
+ tagline: "Import Notion pages and databases",
+ simpleTitle: "All your Notion pages, in supermemory",
+ icon: ,
+ pro: true,
+ docsUrl: "https://supermemory.ai/docs/connectors/notion",
+ },
+ {
+ kind: "connector",
+ id: "onedrive",
+ provider: "onedrive",
+ name: "OneDrive",
+ tagline: "Bring in Office documents from OneDrive",
+ simpleTitle: "Your OneDrive files, ready to recall",
+ icon: ,
+ pro: true,
+ docsUrl: "https://supermemory.ai/docs/connectors/onedrive",
+ },
+ {
+ kind: "connector",
+ id: "granola",
+ provider: "granola",
+ name: "Granola",
+ tagline: "Sync AI meeting notes into your memory",
+ simpleTitle: "Your meeting notes, ready to recall",
+ icon: ,
+ pro: true,
+ docsUrl: "https://supermemory.ai/docs/connectors/granola",
+ },
+ ],
+ },
+ {
+ label: "Apps & extensions",
+ items: () => [
+ {
+ kind: "client",
+ id: "chrome",
+ name: "Chrome Extension",
+ tagline: "Save webpages, import bookmarks, sync ChatGPT memories",
+ simpleTitle: "Save any webpage with one click",
+ icon: ,
+ action: { type: "external", href: CHROME_EXTENSION_URL },
+ },
+ {
+ kind: "client",
+ id: "poke",
+ name: "Poke",
+ tagline: "Recall and save memories from Poke over text",
+ simpleTitle: "Text Poke to recall and save your memories",
+ isNew: true,
+ icon: (
+
+
+
+ ),
+ action: { type: "external", href: POKE_RECIPE_URL },
+ },
+ {
+ kind: "client",
+ id: "shortcuts",
+ name: "Apple Shortcuts",
+ tagline: "Add memories from iPhone, iPad or Mac",
+ simpleTitle: "Save anything from your phone or Mac",
+ icon: ,
+ action: { type: "view", viewMode: "shortcuts" as ViewParamValue },
+ },
+ {
+ kind: "client",
+ id: "raycast",
+ name: "Raycast",
+ tagline: "Add and search memories from Raycast on Mac",
+ simpleTitle: "Save and search from Raycast on Mac",
+ icon: ,
+ action: { type: "view", viewMode: "raycast" as ViewParamValue },
+ dev: true,
+ },
+ {
+ kind: "import",
+ id: "x-bookmarks",
+ name: "Import X bookmarks",
+ tagline: "Turn your X/Twitter bookmarks into memories",
+ simpleTitle: "Turn your X bookmarks into memory",
+ icon: ,
+ viewMode: "import" as ViewParamValue,
+ },
+ ],
},
]
@@ -145,7 +572,7 @@ export function DetailWrapper({
children,
}: {
onBack: () => void
- children: React.ReactNode
+ children: ReactNode
}) {
return (
@@ -164,22 +591,2009 @@ export function DetailWrapper({
)
}
-const CARD_GROUPS: Array<{ label: string; ids: CardId[] }> = [
- { label: "AI tools", ids: ["plugins", "mcp"] },
+function ProChip({ children = "Pro" }: { children?: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function NewChip() {
+ return (
+
+ New
+
+ )
+}
+
+function IconBox({
+ children,
+ size = "md",
+}: {
+ children: ReactNode
+ size?: "sm" | "md"
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+type InfoUseCase = {
+ title: string
+ description: string
+}
+
+type InfoModalCloseReason = Parameters<
+ typeof analytics.integrationInfoModalClosed
+>[0]["close_reason"]
+
+const MCP_INFO_USE_CASES: InfoUseCase[] = [
{
- label: "Apps & extensions",
- ids: ["connections", "chrome", "shortcuts", "raycast", "import"],
+ title: "Persistent assistant memory",
+ description:
+ "Store useful context during conversations and recall it later from this MCP client.",
+ },
+ {
+ title: "Shared context across tools",
+ description:
+ "Use the same Supermemory account across MCP-compatible clients so memory follows the user between sessions.",
+ },
+ {
+ title: "Profiles and project context",
+ description:
+ "Bring user profiles and project-scoped memories into supported AI clients when they need context.",
},
]
-export function IntegrationsView() {
- const { setViewMode } = useViewMode()
- const [, setAddDoc] = useQueryState("add", addDocumentParam)
- const { org } = useAuth()
- const autumn = useCustomer()
- const hasProProduct = hasActivePlan(autumn.data?.subscriptions, "api_pro")
+const ITEM_INFO_USE_CASES: Record
= {
+ "plugin-claude_code": [
+ {
+ title: "Session context injection",
+ description:
+ "Fetch relevant project memories, user preferences, and past interactions when Claude Code starts a session.",
+ },
+ {
+ title: "Automatic coding capture",
+ description:
+ "Save useful tool activity like edits, new files, shell commands, and spawned tasks for future sessions.",
+ },
+ ],
+ "plugin-codex": [
+ {
+ title: "Recall before each prompt",
+ description:
+ "Inject relevant memories and profile context into Codex before each prompt.",
+ },
+ {
+ title: "Capture after sessions",
+ description:
+ "Store conversation transcripts after a session, scoped to the current project and user.",
+ },
+ {
+ title: "Explicit memory skills",
+ description:
+ "Use supermemory-search, supermemory-save, and supermemory-forget when memory needs direct control.",
+ },
+ ],
+ "plugin-opencode": [
+ {
+ title: "Project memory in OpenCode",
+ description:
+ "Inject preferences, project knowledge, and past interactions at the start of OpenCode sessions.",
+ },
+ {
+ title: "Smart session capture",
+ description:
+ "Save memories from explicit phrases like remember or save this, and summarize long sessions during compaction.",
+ },
+ ],
+ "plugin-openclaw": [
+ {
+ title: "Memory across messaging channels",
+ description:
+ "Give OpenClaw memory across WhatsApp, Telegram, Discord, Slack, iMessage, and other channels.",
+ },
+ {
+ title: "Auto-recall and auto-capture",
+ description:
+ "Inject relevant memories before AI turns and store conversation exchanges after turns.",
+ },
+ {
+ title: "Direct memory tools",
+ description:
+ "Let the AI store, search, forget, and inspect profile memories during conversations.",
+ },
+ ],
+ "plugin-hermes": [
+ {
+ title: "Semantic memory for Hermes",
+ description:
+ "Add long-term memory, profile recall, search, and session-aware ingest to Hermes.",
+ },
+ {
+ title: "Turn and session memory",
+ description:
+ "Prefetch relevant context before turns, capture completed turns, and ingest full sessions for richer graph updates.",
+ },
+ {
+ title: "Organized containers",
+ description:
+ "Use profile-scoped memory and optional multi-container tags for work, personal, or project-specific context.",
+ },
+ ],
+ "google-drive": [
+ {
+ title: "Scoped Drive sync",
+ description:
+ "Sync selected Google Docs, Sheets, Slides, and PDFs after OAuth and the hosted file picker.",
+ },
+ {
+ title: "Fresh knowledge base",
+ description:
+ "Keep selected Drive files updated in Supermemory, with scheduled and manual import support.",
+ },
+ ],
+ notion: [
+ {
+ title: "Workspace knowledge sync",
+ description:
+ "Sync Notion pages, databases, and blocks into Supermemory from connected workspaces.",
+ },
+ {
+ title: "Rich Notion context",
+ description:
+ "Preserve rich formatting and database properties so Notion content remains useful for retrieval.",
+ },
+ ],
+ onedrive: [
+ {
+ title: "Microsoft 365 documents",
+ description:
+ "Sync Word documents, Excel spreadsheets, and PowerPoint presentations from OneDrive.",
+ },
+ {
+ title: "Personal and business accounts",
+ description:
+ "Connect personal or business OneDrive accounts and keep Office files updated through sync.",
+ },
+ ],
+ chrome: [
+ {
+ title: "Save from the browser",
+ description:
+ "Capture webpages into Supermemory while browsing instead of manually copying content.",
+ },
+ {
+ title: "Bring bookmarks into memory",
+ description:
+ "Import saved browser context so it can be searched and reused later.",
+ },
+ ],
+ shortcuts: [
+ {
+ title: "Quick mobile capture",
+ description:
+ "Add memories from iPhone, iPad, or Mac through Apple Shortcuts.",
+ },
+ {
+ title: "Save without opening the app",
+ description:
+ "Send useful snippets and links into Supermemory from native Apple workflows.",
+ },
+ ],
+ raycast: [
+ {
+ title: "Fast desktop capture",
+ description:
+ "Add memories from Raycast on Mac without leaving the launcher.",
+ },
+ {
+ title: "Search from Raycast",
+ description:
+ "Look up Supermemory content directly from your desktop command bar.",
+ },
+ ],
+ "x-bookmarks": [
+ {
+ title: "Import saved X posts",
+ description:
+ "Turn X/Twitter bookmarks into searchable Supermemory memories.",
+ },
+ {
+ title: "Reuse social research",
+ description:
+ "Bring bookmarked threads, references, and ideas into the same memory layer as your other tools.",
+ },
+ ],
+}
- const { data: connections = [] } = useQuery({
+function getInfoUseCases(id: string): InfoUseCase[] {
+ return ITEM_INFO_USE_CASES[id] ?? MCP_INFO_USE_CASES
+}
+
+function ItemInfoButton({
+ name,
+ onClick,
+}: {
+ name: string
+ onClick: () => void
+}) {
+ return (
+ {
+ e.stopPropagation()
+ onClick()
+ }}
+ className={cn(
+ "absolute top-4 right-4 flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] opacity-0 transition-all hover:text-[#FAFAFA] focus-visible:opacity-100 focus-visible:outline-none group-hover:opacity-100 sm:size-8",
+ "shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
+ )}
+ >
+
+
+ )
+}
+
+function ItemInfoDialog({
+ actionSlot,
+ docsUrl,
+ hideDismiss,
+ icon,
+ id,
+ kind,
+ name,
+ onOpenChange,
+ open,
+}: {
+ actionSlot: ReactNode
+ docsUrl?: string
+ hideDismiss?: boolean
+ icon: ReactNode
+ id: string
+ kind: ItemKind
+ name: string
+ onOpenChange: (open: boolean) => void
+ open: boolean
+}) {
+ const useCases = getInfoUseCases(id)
+ const closeWithReason = (closeReason: InfoModalCloseReason) => {
+ analytics.integrationInfoModalClosed({
+ kind,
+ id,
+ name,
+ close_reason: closeReason,
+ })
+ onOpenChange(false)
+ }
+ return (
+ {
+ if (nextOpen) {
+ onOpenChange(true)
+ return
+ }
+ closeWithReason("dismiss")
+ }}
+ >
+
+ e.stopPropagation()}
+ onClick={(e) => {
+ e.stopPropagation()
+ closeWithReason("dismiss")
+ }}
+ className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-[4px]"
+ />
+ e.stopPropagation()}
+ onInteractOutside={(e) => e.preventDefault()}
+ style={{
+ boxShadow:
+ "0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset",
+ }}
+ className={cn(
+ dmSans125ClassName(),
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] duration-100",
+ "flex max-h-[88dvh] flex-col gap-3 overflow-hidden border border-white/[0.12] bg-[#1B1F24] p-0 px-3 pt-3 pb-4 text-[#FAFAFA] rounded-2xl md:px-4 sm:max-w-[560px] sm:rounded-[22px]",
+ )}
+ >
+
+ {name} use cases and docs
+
+
+
{icon}
+
+
+ {name}
+
+
+ Use cases that apply to this Supermemory connection.
+
+
+
+ {docsUrl && (
+
+ Docs
+
+ )}
+
closeWithReason("close_button")}
+ className={cn(
+ "flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
+ INSET,
+ )}
+ >
+
+
+
+
+
+
+
+ {useCases.map((useCase, index) => (
+
+
+
+ {index + 1}
+
+ {index < useCases.length - 1 && (
+
+ )}
+
+
+
+ {useCase.title}
+
+
+ {useCase.description}
+
+
+
+ ))}
+
+
+
+
+ {!hideDismiss && (
+
closeWithReason("im_good")}
+ className={cn(
+ "px-3 py-2 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]",
+ dmSansClassName(),
+ )}
+ >
+ I'm good
+
+ )}
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: closes the info dialog after the nested action button runs. */}
+ {/* biome-ignore lint/a11y/useKeyWithClickEvents: keyboard handling stays on the nested real button. */}
+
closeWithReason("action")}>{actionSlot}
+
+
+
+
+ )
+}
+
+function DisconnectButton({ onConfirm }: { onConfirm: () => void }) {
+ const [confirming, setConfirming] = useState(false)
+ useEffect(() => {
+ if (!confirming) return
+ const t = setTimeout(() => setConfirming(false), 3000)
+ return () => clearTimeout(t)
+ }, [confirming])
+ return (
+ (confirming ? onConfirm() : setConfirming(true))}
+ className={cn(
+ dmSans125ClassName(),
+ "shrink-0 rounded-md px-2 py-1 text-[11px] font-medium transition-colors",
+ confirming
+ ? "bg-red-500/15 text-red-400"
+ : "text-[#737373] hover:bg-white/5 hover:text-red-400",
+ )}
+ >
+ {confirming ? "Confirm" : "Disconnect"}
+
+ )
+}
+
+function ActiveButton({
+ count,
+ lastActive,
+ onClick,
+}: {
+ count: number
+ lastActive?: string | null
+ onClick: () => void
+}) {
+ return (
+
+
+
+ {count > 1 ? `${count} active` : "Active"}
+
+ {count <= 1 && lastActive && (
+
+ · {formatRelativeTime(lastActive)}
+
+ )}
+
+ )
+}
+
+function McpConnectedPill({
+ connectedAt,
+ lastActive,
+}: {
+ connectedAt?: string | null
+ lastActive?: string | null
+}) {
+ return (
+
+
+ Connected
+ {(lastActive ?? connectedAt) && (
+
+ · {formatRelativeTime(lastActive ?? connectedAt)}
+
+ )}
+
+ )
+}
+
+function FinishSetupButton({ onClick }: { onClick: () => void }) {
+ return (
+
+
+ Finish setup
+
+ )
+}
+
+function ConnectionsCountPill({ count }: { count: number }) {
+ return (
+
+
+ {count > 1 ? `${count} connected` : "Connected"}
+
+ )
+}
+
+function ImportedPill({
+ lastImportedAt,
+}: {
+ lastImportedAt: string | number | Date | null
+}) {
+ return (
+
+
+ Imported
+ {lastImportedAt && (
+
+ · {formatRelativeTime(lastImportedAt)}
+
+ )}
+
+ )
+}
+
+const CONNECTOR_META: Record<
+ ConnectorProvider,
+ { name: string; icon: ReactNode; documentLabel: string }
+> = {
+ "google-drive": {
+ name: "Google Drive",
+ icon: ,
+ documentLabel: "documents",
+ },
+ notion: {
+ name: "Notion",
+ icon: ,
+ documentLabel: "pages",
+ },
+ onedrive: {
+ name: "OneDrive",
+ icon: ,
+ documentLabel: "documents",
+ },
+ granola: {
+ name: "Granola",
+ icon: ,
+ documentLabel: "notes",
+ },
+}
+
+interface PluginEntry {
+ kind: "plugin"
+ id: string
+ name: string
+ icon: ReactNode
+ pro: boolean
+ agentCount: number
+ createdAt: string | null
+ lastActive: string | null
+ onManage: () => void
+}
+
+interface ConnectorEntry {
+ kind: "connector"
+ id: string
+ name: string
+ documentLabel: string
+ icon: ReactNode
+ pro: boolean
+ provider: ConnectorProvider
+ connection: Connection
+ connectionCount: number
+ email: string | null
+ spaceName: string | null
+ createdAt: string | null
+ onManage: () => void
+ onReconnect: () => void
+}
+
+interface McpEntry {
+ kind: "mcp"
+ id: string
+ name: string
+ icon: ReactNode
+ connectionCount: number
+ createdAt: string | null
+ lastActive: string | null
+ onManage: () => void
+}
+
+type RailEntry = PluginEntry | ConnectorEntry | McpEntry
+
+function railConnectionMeta(connection: Connection) {
+ const m = connection.metadata as Record | undefined
+ return {
+ syncInProgress: m?.syncInProgress === true,
+ lastSyncedAt:
+ typeof m?.lastSyncedAt === "number" ? m.lastSyncedAt : undefined,
+ documentCount: typeof m?.documentCount === "number" ? m.documentCount : 0,
+ }
+}
+
+function RailDetail({ label, value }: { label: string; value: ReactNode }) {
+ return (
+
+ {label}
+
+ {value}
+
+
+ )
+}
+
+function RailAction({
+ label,
+ onClick,
+ danger,
+}: {
+ label: string
+ onClick: () => void
+ danger?: boolean
+}) {
+ return (
+ {
+ e.stopPropagation()
+ onClick()
+ }}
+ className={cn(
+ dmSans125ClassName(),
+ "rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors",
+ danger
+ ? "bg-[#EF4444]/15 text-[#EF4444] hover:bg-[#EF4444]/25"
+ : "bg-[#0D121A] text-[#A1A1AA] hover:text-[#FAFAFA]",
+ )}
+ >
+ {label}
+
+ )
+}
+
+function RailRow({
+ icon,
+ name,
+ statusLine,
+ expanded,
+ onToggle,
+ children,
+}: {
+ icon: ReactNode
+ name: string
+ statusLine: ReactNode
+ expanded: boolean
+ onToggle: () => void
+ children: ReactNode
+}) {
+ return (
+
+
+ {icon}
+
+
+ {name}
+
+
{statusLine}
+
+
+
+
+ {expanded && (
+
+
+ {children}
+
+
+ )}
+
+
+ )
+}
+
+function ActiveStatusDot() {
+ return (
+ <>
+
+
+ Active
+
+ >
+ )
+}
+
+function PluginRailRow({ entry }: { entry: PluginEntry }) {
+ const [expanded, setExpanded] = useState(false)
+ const lastTime = entry.lastActive ?? entry.createdAt
+ const suffix = [
+ entry.agentCount > 1 ? `${entry.agentCount} agents` : null,
+ lastTime ? formatRelativeTime(lastTime) : null,
+ ]
+ .filter(Boolean)
+ .join(" · ")
+ return (
+ setExpanded((v) => !v)}
+ statusLine={
+
+
+ {suffix && (
+
+ · {suffix}
+
+ )}
+
+ }
+ >
+ {entry.createdAt && (
+
+ )}
+ {entry.lastActive && (
+
+ )}
+
+
+
+
+
+ )
+}
+
+const CONNECTOR_STATUS = {
+ expired: { color: "#EF4444", label: "Expired" },
+ syncing: { color: "#4BA0FA", label: "Syncing" },
+ synced: { color: "#00AC3F", label: "Synced" },
+ idle: { color: "#737373", label: "Connected" },
+} as const
+
+function ConnectorRailRow({ entry }: { entry: ConnectorEntry }) {
+ const [expanded, setExpanded] = useState(false)
+ const { needsReauth } = useConnectionHealth(entry.connection.id)
+ const meta = railConnectionMeta(entry.connection)
+ const status: keyof typeof CONNECTOR_STATUS = needsReauth
+ ? "expired"
+ : meta.syncInProgress
+ ? "syncing"
+ : meta.lastSyncedAt
+ ? "synced"
+ : "idle"
+ const { color, label } = CONNECTOR_STATUS[status]
+ const statusParts = [
+ status !== "syncing" && meta.documentCount > 0
+ ? String(meta.documentCount)
+ : null,
+ status === "synced" && meta.lastSyncedAt
+ ? compactRelativeTime(meta.lastSyncedAt)
+ : null,
+ ].filter(Boolean)
+ return (
+ setExpanded((v) => !v)}
+ statusLine={
+
+
+
+ {label}
+
+ {statusParts.length > 0 && (
+
+ · {statusParts.join(" · ")}
+
+ )}
+
+ }
+ >
+ {entry.email && }
+ {entry.createdAt && (
+
+ )}
+ {meta.lastSyncedAt && (
+
+ )}
+ {meta.documentCount > 0 && (
+
+ )}
+ {entry.spaceName && }
+ {needsReauth && (
+ Reconnect needed}
+ />
+ )}
+
+ {needsReauth && (
+
+ )}
+
+
+
+ )
+}
+
+function McpRailRow({ entry }: { entry: McpEntry }) {
+ const [expanded, setExpanded] = useState(false)
+ const lastTime = entry.lastActive ?? entry.createdAt
+ const suffix = [
+ entry.connectionCount > 1 ? `${entry.connectionCount} connections` : null,
+ lastTime ? formatRelativeTime(lastTime) : null,
+ ]
+ .filter(Boolean)
+ .join(" · ")
+ return (
+ setExpanded((v) => !v)}
+ statusLine={
+
+
+ {suffix && (
+
+ · {suffix}
+
+ )}
+
+ }
+ >
+ {entry.createdAt && (
+
+ )}
+ {entry.lastActive && (
+
+ )}
+
+
+
+
+
+ )
+}
+
+const SKELETON_KEYS = ["s1", "s2", "s3", "s4", "s5"]
+
+function RailSkeleton({ rows }: { rows: number }) {
+ return (
+
+ {SKELETON_KEYS.slice(0, rows).map((k) => (
+
+ ))}
+
+ )
+}
+
+function RailEmpty({
+ icon,
+ title,
+ hint,
+}: {
+ icon: ReactNode
+ title: string
+ hint: string
+}) {
+ return (
+
+
+ {icon}
+
+
+ {title}
+
+
+ {hint}
+
+
+ )
+}
+
+function ActiveConnectionsRail({
+ entries,
+ loading,
+ className,
+}: {
+ entries: RailEntry[]
+ loading?: boolean
+ className?: string
+}) {
+ return (
+
+
+
+ Active connections
+
+
+ {loading && entries.length > 0 && (
+
+ )}
+ {entries.length > 0 && (
+
+
+ {entries.length}
+
+ )}
+
+
+ {entries.length > 0 ? (
+
+ {entries.map((entry) =>
+ entry.kind === "plugin" ? (
+
+ ) : entry.kind === "mcp" ? (
+
+ ) : (
+
+ ),
+ )}
+
+ ) : loading ? (
+
+ ) : (
+ }
+ title="No active connections yet"
+ hint="Connect a tool below to see it here"
+ />
+ )}
+
+ )
+}
+
+type RecentDoc = z.infer<
+ typeof DocumentsWithMemoriesResponseSchema
+>["documents"][number]
+
+function hostnameOf(url: string | null | undefined): string | null {
+ if (!url) return null
+ try {
+ return new URL(url).hostname.replace(/^www\./, "")
+ } catch {
+ return null
+ }
+}
+
+const CONNECTOR_SMALL_ICON: Record = {
+ "google-drive": ,
+ notion: ,
+ onedrive: ,
+ granola: ,
+}
+
+function pluginIconNode(iconSrc: string | null): ReactNode {
+ if (!iconSrc) return
+ return (
+
+ )
+}
+
+function resolveDocSource(
+ doc: RecentDoc,
+ connectionSource: Map,
+): { label: string; icon: ReactNode } {
+ const tags = (doc as { containerTags?: unknown }).containerTags
+ if (Array.isArray(tags)) {
+ for (const tag of tags) {
+ if (typeof tag !== "string") continue
+ const space = detectPluginSpace(tag)
+ if (space) {
+ return { label: space.label, icon: pluginIconNode(space.iconSrc) }
+ }
+ }
+ }
+ if (doc.connectionId) {
+ const provider = connectionSource.get(doc.connectionId)
+ if (provider) {
+ return {
+ label: CONNECTOR_META[provider].name,
+ icon: CONNECTOR_SMALL_ICON[provider],
+ }
+ }
+ }
+ const cc = detectPluginSource(
+ doc.metadata as Record | null | undefined,
+ doc.source,
+ )
+ if (cc) {
+ return { label: cc.label, icon: pluginIconNode(cc.iconSrc) }
+ }
+ if (doc.source === "mcp") {
+ return { label: "MCP", icon: }
+ }
+ const type = (doc.type ?? "").toLowerCase()
+ if (type.includes("notion")) {
+ return { label: "Notion", icon: }
+ }
+ if (
+ type.includes("google") ||
+ type.includes("gdrive") ||
+ type.includes("drive")
+ ) {
+ return { label: "Google Drive", icon: }
+ }
+ if (type.includes("onedrive") || type.includes("microsoft")) {
+ return { label: "OneDrive", icon: }
+ }
+ if (type.includes("granola")) {
+ return { label: "Granola", icon: }
+ }
+ const host = hostnameOf(doc.url)
+ if (host) {
+ return { label: host, icon: }
+ }
+ return {
+ label: "Note",
+ icon: ,
+ }
+}
+
+function docDisplayTitle(doc: RecentDoc, sourceLabel: string): string {
+ const t = doc.title?.trim()
+ if (t && !/^untitled/i.test(t)) return t
+ const summary = typeof doc.summary === "string" ? doc.summary.trim() : ""
+ if (summary) {
+ const line = summary
+ .split("\n")
+ .find((l) => l.trim())
+ ?.trim()
+ if (line) return line.length > 80 ? `${line.slice(0, 79)}…` : line
+ }
+ return `${sourceLabel} session`
+}
+
+function RecentDocRow({
+ doc,
+ connectionSource,
+ onOpen,
+}: {
+ doc: RecentDoc
+ connectionSource: Map
+ onOpen: () => void
+}) {
+ const { label, icon } = resolveDocSource(doc, connectionSource)
+ const title = docDisplayTitle(doc, label)
+ return (
+
+
+ {icon}
+
+
+
+ {title}
+
+
+ {label}
+ ·
+ {formatRelativeTime(doc.createdAt)}
+
+
+ {doc.url ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+function RecentlyAddedCard({
+ docs,
+ connectionSource,
+ loading,
+ onOpenDoc,
+ onViewAll,
+ className,
+}: {
+ docs: RecentDoc[]
+ connectionSource: Map
+ loading?: boolean
+ onOpenDoc: (doc: RecentDoc) => void
+ onViewAll: () => void
+ className?: string
+}) {
+ return (
+
+
+
+ Recently added
+
+ {docs.length > 0 && (
+
+ View all
+
+
+ )}
+
+ {docs.length > 0 ? (
+
+ {docs.map((doc) => (
+ onOpenDoc(doc)}
+ />
+ ))}
+
+ ) : loading ? (
+
+ ) : (
+ }
+ title="No memories yet"
+ hint="Anything you save will show up here"
+ />
+ )}
+
+ )
+}
+
+type MobileActivityTab = "active" | "recent"
+
+function MobileActivityPanel({
+ entries,
+ docs,
+ connectionSource,
+ activeLoading,
+ recentsLoading,
+ onOpenDoc,
+ onViewAll,
+}: {
+ entries: RailEntry[]
+ docs: RecentDoc[]
+ connectionSource: Map
+ activeLoading?: boolean
+ recentsLoading?: boolean
+ onOpenDoc: (doc: RecentDoc) => void
+ onViewAll: () => void
+}) {
+ const [tab, setTab] = useState("active")
+ const hasActiveTab = activeLoading || entries.length > 0
+ const hasRecentTab = recentsLoading || docs.length > 0
+ const showTabs = hasActiveTab && hasRecentTab
+ const activeTab: MobileActivityTab =
+ hasActiveTab && (tab === "active" || !hasRecentTab) ? "active" : "recent"
+
+ useEffect(() => {
+ if (!hasActiveTab && hasRecentTab && tab === "active") {
+ setTab("recent")
+ }
+ if (!hasRecentTab && hasActiveTab && tab === "recent") {
+ setTab("active")
+ }
+ }, [hasActiveTab, hasRecentTab, tab])
+
+ const tabClass = (value: MobileActivityTab) =>
+ cn(
+ dmSans125ClassName(),
+ "flex h-8 flex-1 items-center justify-center gap-1.5 rounded-full px-3 text-[12px] font-medium transition-colors",
+ activeTab === value
+ ? "bg-white/[0.10] text-[#FAFAFA]"
+ : "text-[#A1A1AA] hover:text-[#FAFAFA]",
+ )
+
+ return (
+
+
+
+ Activity
+
+ {activeTab === "recent" && docs.length > 0 && (
+
+ View all
+
+
+ )}
+
+ {showTabs && (
+
+ setTab("active")}
+ className={tabClass("active")}
+ >
+ Active
+ {entries.length > 0 && (
+
+ {entries.length}
+
+ )}
+
+ setTab("recent")}
+ className={tabClass("recent")}
+ >
+ Recent
+ {docs.length > 0 && (
+
+ {docs.length}
+
+ )}
+
+
+ )}
+
+ {activeTab === "active" ? (
+ entries.length > 0 ? (
+
+ {entries.map((entry) =>
+ entry.kind === "plugin" ? (
+
+ ) : entry.kind === "mcp" ? (
+
+ ) : (
+
+ ),
+ )}
+
+ ) : activeLoading ? (
+
+ ) : (
+
}
+ title="No active connections yet"
+ hint="Connect a tool below to see it here"
+ />
+ )
+ ) : docs.length > 0 ? (
+
+ {docs.map((doc) => (
+ onOpenDoc(doc)}
+ />
+ ))}
+
+ ) : recentsLoading ? (
+
+ ) : (
+
}
+ title="No memories yet"
+ hint="Anything you save will show up here"
+ />
+ )}
+
+
+ )
+}
+
+function ItemCard({
+ actionSlot,
+ infoActionSlot,
+ icon,
+ id,
+ kind,
+ name,
+ tagline,
+ pro,
+ max,
+ isNew,
+ docsUrl,
+ leftIndicator,
+ statusSlot,
+}: {
+ actionSlot: ReactNode
+ infoActionSlot?: ReactNode
+ icon: ReactNode
+ id: string
+ kind: ItemKind
+ name: string
+ tagline: string
+ pro?: boolean
+ max?: boolean
+ isNew?: boolean
+ docsUrl?: string
+ leftIndicator?: ReactNode
+ statusSlot?: ReactNode
+}) {
+ const [infoOpen, setInfoOpen] = useState(false)
+ return (
+ // biome-ignore lint/a11y/useSemanticElements: the card contains nested action buttons, so it cannot be a native button.
+ setInfoOpen(true)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault()
+ setInfoOpen(true)
+ }
+ }}
+ className={cn(
+ "group relative flex h-full cursor-pointer flex-row items-center gap-2.5 rounded-[10px] bg-[#14161A] px-2.5 py-2 transition-colors hover:bg-[#16181D] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/45 sm:flex-col sm:items-stretch sm:gap-4 sm:rounded-[12px] sm:p-4",
+ "shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
+ )}
+ >
+
setInfoOpen(true)} />
+
+
+ {icon}
+
+
+
+
+ {leftIndicator}
+
+ {name}
+
+ {isNew &&
}
+ {max ?
Max : pro &&
}
+
+
+ {tagline}
+
+
+
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the status action. */}
+
e.stopPropagation()}
+ onKeyDown={(e) => e.stopPropagation()}
+ >
+ {statusSlot}
+
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the primary action. */}
+
e.stopPropagation()}
+ onKeyDown={(e) => e.stopPropagation()}
+ >
+ {actionSlot}
+
+
+
+
+ )
+}
+
+interface FeaturedPick {
+ id: string
+ name: string
+ emoji?: string
+ headline: string
+ support: string
+ tagline: string
+ icon: ReactNode
+ backdrop?: ReactNode
+ docsUrl?: string
+ ctaLabel: string
+ onCta: () => void
+}
+
+const FEATURED_ROTATE_MS = 7000
+
+function FeaturedHero({ picks }: { picks: FeaturedPick[] }) {
+ const [index, setIndex] = useState(0)
+ const [paused, setPaused] = useState(false)
+ const [manualOverride, setManualOverride] = useState(false)
+
+ useEffect(() => {
+ if (picks.length <= 1) return
+ if (paused || manualOverride) return
+ const t = setInterval(() => {
+ setIndex((i) => (i + 1) % picks.length)
+ }, FEATURED_ROTATE_MS)
+ return () => clearInterval(t)
+ }, [picks.length, paused, manualOverride])
+
+ if (picks.length === 0) return null
+ const pick = picks[index] ?? picks[0]
+ if (!pick) return null
+
+ return (
+ // biome-ignore lint/a11y/useSemanticElements: card wraps nested dot buttons, so it can't be a
+ {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault()
+ pick.onCta()
+ }
+ }}
+ onMouseEnter={() => setPaused(true)}
+ onMouseLeave={() => setPaused(false)}
+ onFocus={() => setPaused(true)}
+ onBlur={() => setPaused(false)}
+ className={cn(
+ "group relative flex w-full flex-col items-start justify-center overflow-hidden rounded-[14px] px-5 py-4 text-left cursor-pointer sm:px-6 sm:py-6",
+ "bg-[#191D24]",
+ "shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
+ "min-h-[150px] sm:min-h-[160px]",
+ )}
+ >
+ {pick.backdrop && (
+
+
+
+ {pick.backdrop}
+
+
+
+
+
+ )}
+
+
+
+ Featured
+
+ {picks.length > 1 && (
+
+ {picks.map((p, i) => {
+ const active = i === index
+ return (
+ {
+ e.stopPropagation()
+ setIndex(i)
+ setManualOverride(true)
+ }}
+ className={cn(
+ "rounded-full transition-all cursor-pointer",
+ active
+ ? "w-3 h-1 bg-[#4BA0FA]"
+ : "size-1 bg-[#2A3040] hover:bg-[#3A4455]",
+ )}
+ />
+ )
+ })}
+
+ )}
+
+
+
+
+ {pick.headline}
+
+
+
+ {pick.emoji ? `${pick.emoji} ` : ""}
+ {pick.name}
+ {" "}
+ · {pick.support}
+
+
+
+
+ )
+}
+
+function SearchToggle({
+ value,
+ onChange,
+ expanded,
+ setExpanded,
+}: {
+ value: string
+ onChange: (next: string) => void
+ expanded: boolean
+ setExpanded: (next: boolean) => void
+}) {
+ const inputRef = useRef(null)
+
+ const open = () => {
+ setExpanded(true)
+ requestAnimationFrame(() => inputRef.current?.focus())
+ }
+
+ const close = () => {
+ onChange("")
+ setExpanded(false)
+ }
+
+ if (!expanded && !value) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+ onChange(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") close()
+ }}
+ onBlur={() => {
+ if (!value) setExpanded(false)
+ }}
+ placeholder="Search integrations"
+ className={cn(
+ dmSans125ClassName(),
+ "min-w-0 flex-1 bg-transparent text-[12px] text-[#FAFAFA] placeholder:text-[#525D6E] focus:outline-none",
+ )}
+ />
+ {value && (
+
+
+
+ )}
+
+ )
+}
+
+function CategoryFilterToggle({
+ value,
+ onChange,
+ counts,
+ compact,
+}: {
+ value: CategoryFilter
+ onChange: (next: CategoryFilter) => void
+ counts: Record
+ compact?: boolean
+}) {
+ const visible = compact
+ ? [value]
+ : CATEGORY_VALUES.filter((v) => v === "all" || counts[v] > 0)
+ return (
+
+ {visible.map((v) => {
+ const active = value === v
+ return (
+ onChange(v)}
+ className={cn(
+ dmSans125ClassName(),
+ "flex h-7 shrink-0 items-center gap-1.5 rounded-full px-3 text-[12px] font-medium transition-colors",
+ active
+ ? "bg-white/[0.10] text-[#FAFAFA]"
+ : "text-[#A1A1AA] hover:text-[#FAFAFA]",
+ )}
+ >
+ {CATEGORY_LABEL[v]}
+ {v !== "all" && (
+
+ {counts[v]}
+
+ )}
+
+ )
+ })}
+
+ )
+}
+
+function SectionRail({
+ label,
+ children,
+ headerSlot,
+}: {
+ label: string
+ children: ReactNode
+ headerSlot?: ReactNode
+}) {
+ const scrollRef = useRef(null)
+ const [canScrollLeft, setCanScrollLeft] = useState(false)
+ const [canScrollRight, setCanScrollRight] = useState(false)
+
+ const update = useCallback(() => {
+ const el = scrollRef.current
+ if (!el) return
+ setCanScrollLeft(el.scrollLeft > 4)
+ setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4)
+ }, [])
+
+ useEffect(() => {
+ update()
+ const el = scrollRef.current
+ if (!el) return
+ el.addEventListener("scroll", update, { passive: true })
+ el.addEventListener("scrollend", update)
+ const ro = new ResizeObserver(update)
+ ro.observe(el)
+ return () => {
+ el.removeEventListener("scroll", update)
+ el.removeEventListener("scrollend", update)
+ ro.disconnect()
+ }
+ }, [update])
+
+ const scrollBy = (dir: 1 | -1) => {
+ scrollRef.current?.scrollBy({ left: 292 * dir, behavior: "smooth" })
+ setTimeout(update, 450)
+ }
+
+ const arrowClass = cn(
+ "flex size-7 items-center justify-center rounded-full bg-[#0D121A] text-[#FAFAFA] transition-opacity",
+ "shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]",
+ "hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-30",
+ )
+
+ return (
+
+
+
+ {label}
+
+
+ {headerSlot}
+
scrollBy(-1)}
+ className={arrowClass}
+ >
+
+
+
scrollBy(1)}
+ className={arrowClass}
+ >
+
+
+
+
+
+ {children}
+
+
+ )
+}
+
+export function IntegrationsView({
+ publicMode = false,
+ onOpenDocument,
+}: {
+ publicMode?: boolean
+ onOpenDocument?: (doc: RecentDoc) => void
+}) {
+ const { setViewMode } = useViewMode()
+ const queryClient = useQueryClient()
+ const { org } = useAuth()
+ const { allProjects } = useContainerTags()
+ const shortcutsConnect = useShortcutsConnect()
+ const autumn = useCustomer({ queryOptions: { enabled: !publicMode } })
+ // connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins
+ // stay on hasProProduct. See useConnectorAccess.
+ const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({
+ enabled: !publicMode,
+ })
+ const isAutumnLoading = !publicMode && autumn.isLoading
+
+ const [connectingPlugin, setConnectingPlugin] = useState(null)
+ const [connectingProvider, setConnectingProvider] =
+ useState(null)
+ const [granolaModalOpen, setGranolaModalOpen] = useState(false)
+ const [newKey, setNewKey] = useState<{
+ open: boolean
+ key: string
+ pluginId: string | null
+ loading: boolean
+ }>({ open: false, key: "", pluginId: null, loading: false })
+ const [connectedPluginId, setConnectedPluginId] = useState(
+ null,
+ )
+ const [finishSetupPluginId, setFinishSetupPluginId] = useState(
+ null,
+ )
+
+ const { data: pluginsData } = useQuery({
+ queryFn: async () => {
+ const API_URL =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+ const res = await fetch(`${API_URL}/v3/auth/plugins`, {
+ credentials: "include",
+ })
+ if (!res.ok) throw new Error("Failed to fetch plugins")
+ return (await res.json()) as { plugins: string[] }
+ },
+ enabled: !publicMode,
+ queryKey: ["plugins"],
+ })
+
+ const { data: connections = [], isLoading: connectionsLoading } = useQuery({
queryKey: ["connections"],
queryFn: async () => {
const response = await $fetch("@post/connections/list", {
@@ -190,130 +2604,1672 @@ export function IntegrationsView() {
return response.data as Connection[]
},
staleTime: 30 * 1000,
- enabled: hasProProduct,
+ enabled: !publicMode && connectorAccess,
})
- const { data: facetsData } = useQuery({
- queryKey: ["document-facets", []],
- queryFn: async () => {
- const response = await $fetch("@post/documents/documents/facets", {
- body: { containerTags: [] },
- disableValidation: true,
- })
- if (response.error)
- throw new Error(response.error?.message || "Failed to fetch facets")
- return response.data as {
- facets: Array<{ category: string; count: number }>
- total: number
- }
- },
- staleTime: 5 * 60 * 1000,
- })
-
- type ApiKey = { metadata: Record | null }
- const { data: apiKeys = [] } = useQuery({
+ const {
+ data: apiKeys = [],
+ refetch: refetchKeys,
+ isLoading: apiKeysLoading,
+ } = useQuery({
queryKey: ["api-keys", org?.id],
queryFn: async () => {
if (!org?.id) return []
- const data = (await authClient.apiKey.list({
- fetchOptions: { query: { metadata: { organizationId: org.id } } },
- })) as unknown as ApiKey[]
- return data.filter((key) => key.metadata?.organizationId === org.id)
+ const API_URL =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+ const res = await fetch(`${API_URL}/v3/auth/keys`, {
+ credentials: "include",
+ })
+ if (!res.ok) return []
+ const data = (await res.json()) as { keys?: ListedApiKey[] }
+ return data.keys ?? []
},
- enabled: !!org?.id,
+ enabled: !publicMode && !!org?.id,
staleTime: 30 * 1000,
})
- const connectedPluginCount = apiKeys.filter(
- (key) => key.metadata?.sm_type === "plugin_auth",
- ).length
+ const { data: xBookmarksImport } = useQuery({
+ queryKey: ["x-bookmarks-import-status"],
+ queryFn: async () => {
+ const response = await $fetch("@post/documents/documents", {
+ body: {
+ page: 1,
+ limit: 1,
+ sort: "createdAt",
+ order: "desc",
+ categories: ["tweet"],
+ },
+ disableValidation: true,
+ })
+ if (response.error)
+ throw new Error(
+ response.error?.message || "Failed to load X bookmarks status",
+ )
+ return {
+ count: response.data?.pagination?.totalItems ?? 0,
+ lastImportedAt: response.data?.documents?.[0]?.createdAt ?? null,
+ }
+ },
+ staleTime: 5 * 60 * 1000,
+ enabled: !publicMode,
+ })
- const tweetCount =
- facetsData?.facets.find((f) => f.category === "tweet")?.count ?? 0
+ const tweetCount = xBookmarksImport?.count ?? 0
+ const lastTweetImportAt = xBookmarksImport?.lastImportedAt ?? null
- const getStatusLabel = (
- id: CardId,
- ): { label: string; variant: "connected" | "neutral" } | undefined => {
- if (id === "connections" && hasProProduct) {
- return connections.length > 0
- ? { label: `${connections.length} connected`, variant: "connected" }
- : { label: "Not connected", variant: "neutral" }
+ const keyPrefix = useCallback((key: ListedApiKey): string | null => {
+ return key.start ?? (key.name?.startsWith("sm_") ? key.name : null)
+ }, [])
+
+ const { active: activePlugins, setup: setupPlugins } = useMemo(
+ () => parsePluginAuthKeys(apiKeys, keyPrefix),
+ [apiKeys, keyPrefix],
+ )
+
+ const activeMcpKeys = useMemo(
+ () => parseMcpAuthKeys(apiKeys, keyPrefix),
+ [apiKeys, keyPrefix],
+ )
+
+ const activePluginById = useMemo(() => {
+ const map = new Map()
+ for (const key of activePlugins) {
+ const existing = map.get(key.pluginId)
+ if (!existing) {
+ map.set(key.pluginId, key)
+ continue
+ }
+ const a = key.lastRequest ? new Date(key.lastRequest).getTime() : 0
+ const b = existing.lastRequest
+ ? new Date(existing.lastRequest).getTime()
+ : 0
+ if (a >= b) map.set(key.pluginId, key)
}
- if (id === "import") {
- return tweetCount > 0
- ? { label: `${tweetCount} tweets imported`, variant: "connected" }
- : undefined
+ return map
+ }, [activePlugins])
+
+ const activeMcpKey = useMemo(() => {
+ let latest: ConnectedMcpKey | null = null
+ for (const key of activeMcpKeys) {
+ if (!latest) {
+ latest = key
+ continue
+ }
+ const a = toMs(key.lastRequest ?? key.createdAt)
+ const b = toMs(latest.lastRequest ?? latest.createdAt)
+ if (a >= b) latest = key
}
- if (id === "plugins") {
- return connectedPluginCount > 0
- ? { label: `${connectedPluginCount} connected`, variant: "connected" }
- : undefined
+ return latest
+ }, [activeMcpKeys])
+
+ const activeCountByPlugin = useMemo(() => {
+ const map = new Map()
+ for (const key of activePlugins) {
+ map.set(key.pluginId, (map.get(key.pluginId) ?? 0) + 1)
+ }
+ return map
+ }, [activePlugins])
+
+ const setupPluginIds = useMemo(
+ () => new Set(setupPlugins.map((k) => k.pluginId)),
+ [setupPlugins],
+ )
+
+ const connectionsByProvider = useMemo(() => {
+ const out: Record = {
+ "google-drive": [],
+ notion: [],
+ onedrive: [],
+ granola: [],
+ }
+ for (const c of connections) {
+ const p = c.provider as ConnectorProvider
+ if (p in out) out[p].push(c)
+ }
+ return out
+ }, [connections])
+
+ const connectionSource = useMemo(() => {
+ const m = new Map()
+ for (const c of connections) {
+ const p = c.provider as ConnectorProvider
+ if (p in CONNECTOR_META) m.set(c.id, p)
+ }
+ return m
+ }, [connections])
+
+ const createPluginKeyMutation = useMutation({
+ mutationFn: async (pluginId: string) => {
+ const API_URL =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+ const params = new URLSearchParams({ client: pluginId })
+ const res = await fetch(`${API_URL}/v3/auth/key?${params}`, {
+ credentials: "include",
+ })
+ if (!res.ok) {
+ if (res.status === 403) {
+ throw new Error(
+ "This plugin requires a Pro plan. Hermes and Codex are available on the Free plan.",
+ )
+ }
+ const errorData = (await res.json().catch(() => ({}))) as {
+ message?: string
+ }
+ throw new Error(errorData.message || "Failed to create plugin key")
+ }
+ return (await res.json()) as { key: string }
+ },
+ 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",
+ })
+ },
+ onSettled: () => {
+ setConnectingPlugin(null)
+ queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] })
+ },
+ onSuccess: (data, pluginId) => {
+ setNewKey({ open: true, key: data.key, pluginId, loading: false })
+ },
+ })
+
+ const addConnectionMutation = useMutation({
+ mutationFn: async (provider: ConnectorProvider) => {
+ const response = await $fetch("@post/connections/:provider", {
+ params: { provider },
+ body: {
+ redirectUrl: window.location.href,
+ containerTags: [],
+ },
+ })
+ if ("data" in response && response.data && !("error" in response.data)) {
+ return response.data
+ }
+ throw new Error(response.error?.message || "Failed to connect")
+ },
+ onMutate: (provider) => {
+ setConnectingProvider(provider)
+ analytics.connectionAuthStarted({ provider })
+ },
+ onError: (err) => {
+ setConnectingProvider(null)
+ toast.error("Failed to connect", {
+ description: err instanceof Error ? err.message : "Unknown error",
+ })
+ },
+ onSuccess: (data) => {
+ if (data?.authLink) {
+ window.location.href = data.authLink
+ return
+ }
+ setConnectingProvider(null)
+ toast.error("Connect link missing — try again.")
+ },
+ })
+
+ const handleRevokePluginKey = async (keyId: string) => {
+ try {
+ await authClient.apiKey.delete({ keyId })
+ toast.success("Plugin disconnected")
+ refetchKeys()
+ } catch {
+ toast.error("Failed to disconnect plugin")
}
- return undefined
}
- return (
-
-
-
-
-
-
Integrations
-
-
- Connect supermemory to your tools and workflows
-
-
+ 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],
+ )
-
- {CARD_GROUPS.map((group) => {
- const groupCards = cards.filter((c) => group.ids.includes(c.id))
- return (
-
-
-
- {group.label}
-
-
+ const redirectToLogin = useCallback(() => {
+ const loginUrl = new URL("/login", window.location.origin)
+ loginUrl.searchParams.set("redirect", window.location.href)
+ window.location.assign(loginUrl.toString())
+ }, [])
+
+ const availablePluginIds = publicMode
+ ? Object.keys(PLUGIN_CATALOG)
+ : (pluginsData?.plugins ?? Object.keys(PLUGIN_CATALOG))
+ const enabledPluginIds = new Set(
+ availablePluginIds.filter((id) => PLUGIN_CATALOG[id]),
+ )
+
+ const [category, setCategory] = useQueryState("cat", catParam)
+ const [, setAddDoc] = useQueryState("add", addDocumentParam)
+ const [, setDocId] = useQueryState("doc", docParam)
+ const [mcpClient, setMcpClient] = useQueryState("mcpClient", parseAsString)
+ const [mcpModalOpen, setMcpModalOpen] = useState(false)
+ const [search, setSearch] = useState("")
+ const [searchExpanded, setSearchExpanded] = useState(false)
+
+ const openMcpClient = (key: MCPClientKey) => {
+ void setMcpClient(key)
+ setMcpModalOpen(true)
+ }
+
+ // Deeplink: /integrations?connect=
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(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 (!connectorAccess) {
+ 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,
+ connectorAccess,
+ publicMode,
+ redirectToLogin,
+ setConnectTarget,
+ setAddDoc,
+ createPluginKeyMutation,
+ handleUpgrade,
+ ])
+
+ const closeMcpModal = () => {
+ setMcpModalOpen(false)
+ void setMcpClient(null)
+ }
+
+ const activeMcpClient = mcpClient
+ ? MCP_CLIENTS.find((c) => c.key === mcpClient)
+ : undefined
+
+ const allItems = useMemo- (
+ () =>
+ SECTIONS.flatMap((s) => s.items(PLUGIN_CATALOG)).filter(
+ (item) => item.kind !== "plugin" || enabledPluginIds.has(item.pluginId),
+ ),
+ [enabledPluginIds],
+ )
+
+ const isItemConnected = useCallback(
+ (item: Item): boolean => {
+ if (publicMode) return false
+ if (item.kind === "plugin") {
+ return activePluginById.has(item.pluginId)
+ }
+ if (item.kind === "connector") {
+ return connectionsByProvider[item.provider].length > 0
+ }
+ if (item.kind === "import") {
+ return tweetCount > 0
+ }
+ return false
+ },
+ [activePluginById, connectionsByProvider, publicMode, tweetCount],
+ )
+
+ const counts = useMemo
>(
+ () => ({
+ all: allItems.length,
+ connected: allItems.filter(isItemConnected).length,
+ plugins: allItems.filter((i) => itemCategory(i) === "plugins").length,
+ "knowledge-bases": allItems.filter(
+ (i) => itemCategory(i) === "knowledge-bases",
+ ).length,
+ "apps-extensions": allItems.filter(
+ (i) => itemCategory(i) === "apps-extensions",
+ ).length,
+ "ai-clients": allItems.filter((i) => itemCategory(i) === "ai-clients")
+ .length,
+ }),
+ [allItems, isItemConnected],
+ )
+
+ useEffect(() => {
+ if (category !== "all" && counts[category] === 0) {
+ void setCategory("all")
+ }
+ }, [category, counts, setCategory])
+
+ const railEntries = useMemo(() => {
+ const getSpaceName = (tag?: string): string | null => {
+ if (!tag) return null
+ if (tag === DEFAULT_PROJECT_ID) return "Default"
+ return allProjects.find((p) => p.containerTag === tag)?.name ?? null
+ }
+ const rows: Array<{ ts: number; entry: RailEntry }> = []
+ for (const [pluginId, key] of activePluginById) {
+ const plugin = PLUGIN_CATALOG[pluginId]
+ if (!plugin) continue
+ const count = activeCountByPlugin.get(pluginId) ?? 1
+ rows.push({
+ ts: toMs(key.lastRequest ?? key.createdAt),
+ entry: {
+ kind: "plugin",
+ id: `plugin-${pluginId}`,
+ name: plugin.name,
+ icon: (
+
+ ),
+ pro: !FREE_TIER_PLUGIN_IDS.includes(pluginId),
+ agentCount: count,
+ createdAt: key.createdAt ?? null,
+ lastActive: key.lastRequest ?? null,
+ onManage: () => setConnectedPluginId(pluginId),
+ },
+ })
+ }
+ if (activeMcpKey) {
+ rows.push({
+ ts: toMs(activeMcpKey.lastRequest ?? activeMcpKey.createdAt),
+ entry: {
+ kind: "mcp",
+ id: "mcp",
+ name: "Supermemory MCP",
+ icon: ,
+ connectionCount: activeMcpKeys.length,
+ createdAt: activeMcpKey.createdAt ?? null,
+ lastActive: activeMcpKey.lastRequest ?? null,
+ onManage: () => {
+ void setMcpClient("mcp-url")
+ setMcpModalOpen(true)
+ },
+ },
+ })
+ }
+ for (const provider of [
+ "google-drive",
+ "notion",
+ "onedrive",
+ ] as ConnectorProvider[]) {
+ const conns = connectionsByProvider[provider]
+ const primary = conns[0]
+ if (!primary) continue
+ const meta = CONNECTOR_META[provider]
+ const earliest = conns.reduce((min, c) => {
+ if (!min) return c.createdAt
+ return c.createdAt < min ? c.createdAt : min
+ }, null)
+ const email = conns.find((c) => c.email)?.email ?? null
+ rows.push({
+ ts: toMs(earliest),
+ entry: {
+ kind: "connector",
+ id: `connector-${provider}`,
+ name: meta.name,
+ documentLabel: meta.documentLabel,
+ icon: meta.icon,
+ pro: true,
+ provider,
+ connection: primary,
+ connectionCount: conns.length,
+ email,
+ spaceName: getSpaceName(primary.containerTags?.[0]),
+ createdAt: earliest,
+ onManage: () => void setAddDoc("connect"),
+ onReconnect: () => addConnectionMutation.mutate(provider),
+ },
+ })
+ }
+ rows.sort((a, b) => b.ts - a.ts)
+ return rows.map((r) => r.entry)
+ }, [
+ activeMcpKey,
+ activeMcpKeys.length,
+ activePluginById,
+ activeCountByPlugin,
+ connectionsByProvider,
+ allProjects,
+ setAddDoc,
+ setMcpClient,
+ addConnectionMutation,
+ ])
+
+ const hasActiveRail = railEntries.length > 0
+
+ const { data: recentDocs = [], isLoading: recentsLoading } = useQuery({
+ queryKey: ["integrations-recent-docs", org?.id],
+ queryFn: async () => {
+ const response = await $fetch("@post/documents/documents", {
+ body: {
+ page: 1,
+ limit: 6,
+ sort: "createdAt",
+ order: "desc",
+ containerTags: [],
+ },
+ disableValidation: true,
+ })
+ if (response.error) {
+ throw new Error(
+ response.error?.message || "Failed to load recent documents",
+ )
+ }
+ const data = response.data as z.infer<
+ typeof DocumentsWithMemoriesResponseSchema
+ >
+ return data.documents ?? []
+ },
+ enabled: !publicMode && !!org?.id,
+ staleTime: 60 * 1000,
+ })
+
+ const railLoading =
+ !publicMode && (apiKeysLoading || connectionsLoading || isAutumnLoading)
+ const showRightColumn =
+ !publicMode &&
+ (hasActiveRail || recentDocs.length > 0 || railLoading || recentsLoading)
+
+ const openRecentDoc = useCallback(
+ (doc: RecentDoc) => {
+ if (onOpenDocument) {
+ onOpenDocument(doc)
+ return
+ }
+ void setDocId(doc.id ?? doc.customId ?? null)
+ },
+ [onOpenDocument, setDocId],
+ )
+
+ const claudeCodeConnected = activePluginById.has("claude_code")
+ const claudeCodeNeedsPro =
+ !isAutumnLoading && !hasProProduct && !isFreeTierPlugin("claude_code")
+ const mcpConnected = !!activeMcpKey
+
+ const featuredPicks: FeaturedPick[] = [
+ {
+ id: "feat-poke",
+ name: "Poke",
+ emoji: "🌴",
+ headline: "Your memory, one text away.",
+ support: "recall and save anything by texting Poke",
+ tagline: "Connect Poke to recall and save memories over text.",
+ icon: (
+
+ ),
+ backdrop: (
+
+ ),
+ ctaLabel: "Connect",
+ onCta: () => {
+ if (publicMode) {
+ redirectToLogin()
+ return
+ }
+ window.open(POKE_RECIPE_URL, "_blank", "noopener,noreferrer")
+ },
+ },
+ {
+ id: "feat-mcp",
+ name: "Supermemory MCP",
+ headline: "Your AI tools forget everything between chats.",
+ support: "one setup gives Cursor, Claude & ChatGPT your memory",
+ tagline: "Plug your memory into any MCP client.",
+ icon: ,
+ backdrop: (
+
+ ),
+ docsUrl: "https://supermemory.ai/docs/supermemory-mcp/introduction",
+ ctaLabel: mcpConnected ? "Connected" : "Connect",
+ onCta: () => {
+ if (publicMode) {
+ redirectToLogin()
+ return
+ }
+ void setMcpClient(null)
+ setViewMode("mcp")
+ },
+ },
+ {
+ id: "feat-claude-code",
+ name: "Claude Code plugin",
+ headline: "Stop re-explaining your codebase every session.",
+ support: "remembers your conventions, decisions & project context",
+ tagline: "Long-term memory for your Claude Code sessions.",
+ icon: (
+
+ ),
+ backdrop: (
+
+ ),
+ docsUrl: "https://supermemory.ai/docs/integrations/claude-code",
+ ctaLabel: publicMode
+ ? "Connect"
+ : claudeCodeConnected
+ ? "Active"
+ : claudeCodeNeedsPro
+ ? "Upgrade"
+ : "Connect",
+ onCta: () => {
+ if (publicMode) {
+ redirectToLogin()
+ return
+ }
+ if (claudeCodeConnected) return
+ if (claudeCodeNeedsPro) {
+ handleUpgrade("api_pro")
+ return
+ }
+ createPluginKeyMutation.mutate("claude_code")
+ },
+ },
+ {
+ id: "feat-chrome",
+ name: "Chrome Extension",
+ headline: "That article you'll “read later”? Gone by next week.",
+ support: "save anything on the web in one click",
+ tagline: "Save anything on the web, straight from your browser.",
+ icon: ,
+ backdrop: ,
+ ctaLabel: "Connect",
+ onCta: () => {
+ if (publicMode) {
+ redirectToLogin()
+ return
+ }
+ window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer")
+ analytics.onboardingChromeExtensionClicked({ source: "integrations" })
+ },
+ },
+ ]
+
+ const q = search.trim().toLowerCase()
+ const visibleItems = allItems.filter((item) => {
+ if (category === "connected" && !isItemConnected(item)) return false
+ if (
+ category !== "all" &&
+ category !== "connected" &&
+ itemCategory(item) !== category
+ )
+ return false
+ if (q) {
+ const hay = `${item.name} ${item.tagline}`.toLowerCase()
+ if (!hay.includes(q)) return false
+ }
+ return true
+ })
+
+ const trackCard = (item: Item) =>
+ analytics.integrationCardClicked({
+ kind: item.kind,
+ id: item.id,
+ name: item.name,
+ })
+
+ const renderRight = (item: Item): ReactNode => {
+ if (publicMode) {
+ return (
+ {
+ trackCard(item)
+ redirectToLogin()
+ }}
+ >
+ Connect
+
+ )
+ }
+
+ switch (item.kind) {
+ case "plugin": {
+ const activeKey = activePluginById.get(item.pluginId)
+ const needsProUpgrade =
+ !isAutumnLoading && !hasProProduct && !isFreeTierPlugin(item.pluginId)
+ if (activeKey) {
+ const busy = connectingPlugin === item.pluginId
+ return (
+ {
+ if (needsProUpgrade) {
+ handleUpgrade("api_pro")
+ return
+ }
+ trackCard(item)
+ createPluginKeyMutation.mutate(item.pluginId)
+ }}
+ disabled={!!connectingPlugin}
+ className={cn(
+ "flex size-8 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] transition-colors hover:text-[#FAFAFA] disabled:opacity-50 sm:size-9",
+ "shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
+ )}
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+
+ )
+ }
+ if (setupPluginIds.has(item.pluginId)) {
+ return (
+ {
+ trackCard(item)
+ if (!PLUGIN_CATALOG[item.pluginId]?.usesOAuth) {
+ if (connectingPlugin) return
+ createPluginKeyMutation.mutate(item.pluginId)
+ return
+ }
+ setFinishSetupPluginId(item.pluginId)
+ }}
+ />
+ )
+ }
+ if (needsProUpgrade) {
+ return (
+ handleUpgrade("api_pro")}>
+ Upgrade
+
+ )
+ }
+ const busy = connectingPlugin === item.pluginId
+ return (
+ {
+ trackCard(item)
+ createPluginKeyMutation.mutate(item.pluginId)
+ }}
+ disabled={!!connectingPlugin}
+ >
+ {busy ? (
+ <>
+ Connecting…
+ >
+ ) : (
+ "Connect"
+ )}
+
+ )
+ }
+ case "connector": {
+ const count = connectionsByProvider[item.provider].length
+ const isGranola = item.provider === "granola"
+ const needsPlanUpgrade = !isAutumnLoading && !connectorAccess
+ if (count > 0) {
+ return (
+
+
{
+ trackCard(item)
+ if (isGranola) {
+ if (!connectorAccess) {
+ handleUpgrade("api_pro")
+ return
+ }
+ setGranolaModalOpen(true)
+ return
+ }
+ void setAddDoc("connect")
+ }}
+ className={cn(
+ "flex size-8 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] transition-colors hover:text-[#FAFAFA] sm:size-9",
+ "shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
+ )}
+ >
+
+
+
+ )
+ }
+ if (needsPlanUpgrade) {
+ return (
+ handleUpgrade("api_pro")}>
+ Upgrade
+
+ )
+ }
+ const busy = connectingProvider === item.provider
+ return (
+ {
+ trackCard(item)
+ if (isGranola) {
+ if (!connectorAccess) {
+ handleUpgrade("api_pro")
+ return
+ }
+ setGranolaModalOpen(true)
+ return
+ }
+ addConnectionMutation.mutate(item.provider)
+ }}
+ disabled={!!connectingProvider}
+ >
+ {busy ? (
+ <>
+ Connecting…
+ >
+ ) : (
+ "Connect"
+ )}
+
+ )
+ }
+ case "client": {
+ if (item.action.type === "external") {
+ return (
+ {
+ trackCard(item)
+ window.open(
+ (item.action as { type: "external"; href: string }).href,
+ "_blank",
+ "noopener,noreferrer",
+ )
+ if (item.id === "chrome") {
+ analytics.onboardingChromeExtensionClicked({
+ source: "integrations",
+ })
+ }
+ }}
+ >
+ Connect
+
+ )
+ }
+ if (item.id === "shortcuts") {
+ return
+ }
+ return (
+ {
+ trackCard(item)
+ setViewMode(
+ (item.action as { type: "view"; viewMode: ViewParamValue })
+ .viewMode,
+ )
+ }}
+ >
+ Connect
+
+ )
+ }
+ case "mcp-client":
+ return (
+ {
+ trackCard(item)
+ openMcpClient(item.clientKey)
+ }}
+ className={cn(
+ "flex size-8 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] transition-colors hover:text-[#FAFAFA] sm:size-9",
+ "shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
+ )}
+ >
+
+
+ )
+ case "import":
+ return (
+ {
+ trackCard(item)
+ setViewMode(item.viewMode)
+ }}
+ >
+ Connect
+
+ )
+ }
+ }
+
+ const renderInfoRight = (item: Item): ReactNode => {
+ if (publicMode) return renderRight(item)
+
+ switch (item.kind) {
+ case "plugin": {
+ const activeKey = activePluginById.get(item.pluginId)
+ const needsProUpgrade =
+ !isAutumnLoading && !hasProProduct && !isFreeTierPlugin(item.pluginId)
+ if (activeKey) {
+ const busy = connectingPlugin === item.pluginId
+ return (
+ {
+ if (needsProUpgrade) {
+ handleUpgrade("api_pro")
+ return
+ }
+ trackCard(item)
+ createPluginKeyMutation.mutate(item.pluginId)
+ }}
+ disabled={!!connectingPlugin}
+ >
+ {busy ? (
+ <>
+ Connecting…
+ >
+ ) : (
+ "Connect"
+ )}
+
+ )
+ }
+ return renderRight(item)
+ }
+ case "connector": {
+ const count = connectionsByProvider[item.provider].length
+ if (count > 0) {
+ return (
+ {
+ trackCard(item)
+ void setAddDoc("connect")
+ }}
+ >
+ Connect
+
+ )
+ }
+ return renderRight(item)
+ }
+ case "client": {
+ if (item.id === "shortcuts") {
+ return
+ }
+ return renderRight(item)
+ }
+ default:
+ return renderRight(item)
+ }
+ }
+
+ const renderStatus = (item: Item): ReactNode => {
+ if (publicMode) return null
+
+ switch (item.kind) {
+ case "plugin": {
+ const activeKey = activePluginById.get(item.pluginId)
+ if (!activeKey) return null
+ return (
+ {
+ trackCard(item)
+ setConnectedPluginId(item.pluginId)
+ }}
+ />
+ )
+ }
+ case "connector": {
+ const count = connectionsByProvider[item.provider].length
+ if (count <= 0) return null
+ return
+ }
+ case "import": {
+ if (tweetCount <= 0) return null
+ return
+ }
+ default:
+ return null
+ }
+ }
+
+ const renderItemCard = (item: Item) => (
+
+ )
+
+ const renderLeftIndicator = (_item: Item): ReactNode => {
+ return null
+ }
+
+ const dialogPlugin = newKey.pluginId
+ ? PLUGIN_CATALOG[newKey.pluginId]
+ : undefined
+ const connectedDialogPlugin = connectedPluginId
+ ? PLUGIN_CATALOG[connectedPluginId]
+ : undefined
+ const connectedDialogKeys = connectedPluginId
+ ? activePlugins.filter((key) => key.pluginId === connectedPluginId)
+ : []
+ const connectedDialogNeedsPro =
+ !!connectedPluginId &&
+ !isAutumnLoading &&
+ !hasProProduct &&
+ !isFreeTierPlugin(connectedPluginId)
+ const finishSetupPlugin = finishSetupPluginId
+ ? PLUGIN_CATALOG[finishSetupPluginId]
+ : undefined
+ const finishSetupSteps = finishSetupPlugin?.installSteps ?? []
+ const pluginSteps = dialogPlugin?.installSteps ?? []
+ const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
+ const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth
+ const setupSteps: InstallStep[] = skipGeneratedKeyStep
+ ? pluginSteps
+ : [
+ {
+ title: "Copy your API key",
+ description:
+ "You won't be able to see it again — store it somewhere safe.",
+ code: newKey.key,
+ copyLabel: "API key",
+ secret: true,
+ },
+ ...pluginSteps,
+ ]
+
+ return (
+
+ {shortcutsConnect.dialog}
+
+
+ {!q &&
}
+ {showRightColumn && (
+
+ setViewMode("list")}
+ />
+
+ )}
+
+
+
+
+
+
+ void setCategory(v)}
+ counts={counts}
+ compact={searchExpanded || !!search}
+ />
+
+
-
- {groupCards.map((card) => {
- const status = getStatusLabel(card.id)
- return (
-
{
- if (card.externalHref) {
- window.open(
- card.externalHref,
- "_blank",
- "noopener,noreferrer",
- )
- analytics.onboardingChromeExtensionClicked({
- source: "integrations",
- })
- } else if (card.id === "connections") {
- void setAddDoc("connect")
- } else {
- void setViewMode(card.id as ViewParamValue)
+ {visibleItems.length === 0 ? (
+
+ {q
+ ? `No integrations match “${search}”.`
+ : "Nothing in this category yet."}
+
+ ) : q || category !== "all" ? (
+
+ {visibleItems.map((item) => renderItemCard(item))}
+
+ ) : (
+
+ {SECTION_ORDER.map((cat) => {
+ const items = visibleItems.filter(
+ (i) => itemCategory(i) === cat,
+ )
+ if (items.length === 0) return null
+ return (
+
+ ) : null
}
- }}
- />
- )
- })}
+ >
+ {items.map((item) => (
+
+ {renderItemCard(item)}
+
+ ))}
+
+ )
+ })}
+
+ )}
+
+
+ {showRightColumn && (
+
+
+
+
setViewMode("list")}
+ />
- )
- })}
+ )}
+
+
+
{
+ setNewKey((s) => ({
+ open,
+ key: open ? s.key : "",
+ pluginId: open ? s.pluginId : null,
+ loading: open ? s.loading : false,
+ }))
+ if (!open) void setConnectTarget(null)
+ }}
+ >
+
+
+ Set up {dialogPlugin?.name ?? "your plugin"}
+
+
+ {dialogPlugin && (
+
+
+
+ )}
+
+
+ Set up {dialogPlugin?.name ?? "your plugin"}
+
+
+ {newKey.loading
+ ? "Generating your key…"
+ : "Copy your key and run these steps to finish."}
+
+
+
+ {dialogPlugin?.docsUrl && (
+
+ Docs
+
+ )}
+
+
+
+
+
+
+
+ {newKey.loading ? (
+
+
+ Generating your key…
+
+ ) : (
+
+ )}
+
+
+
+ {
+ 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",
+ INSET,
+ )}
+ >
+ Done
+
+
+
+
+
+
{
+ if (!open) setConnectedPluginId(null)
+ }}
+ >
+
+
+ {connectedDialogPlugin?.name ?? "Plugin"} connection
+
+
+ {connectedDialogPlugin && (
+
+
+
+ )}
+
+
+ {connectedDialogPlugin?.name ?? "Plugin"}
+
+
+
+ Active
+ {activePluginById.get(connectedPluginId ?? "")?.lastRequest && (
+
+ ·{" "}
+ {formatRelativeTime(
+ activePluginById.get(connectedPluginId ?? "")
+ ?.lastRequest,
+ )}
+
+ )}
+
+
+
+ {connectedDialogPlugin?.docsUrl && (
+
+ Docs
+
+ )}
+
+
+
+
+
+
+
+ {connectedDialogKeys.length > 1
+ ? `${connectedDialogKeys.length} connections`
+ : "Connection"}
+
+ {connectedDialogKeys.length > 0 ? (
+
+ {connectedDialogKeys.map((key) => (
+
+
+ {key.keyStart ? `${key.keyStart}...` : "API key"}
+
+ void handleRevokePluginKey(key.keyId)}
+ />
+
+ ))}
+
+ ) : (
+
+ No active connection was found.
+
+ )}
+
+ Connect this plugin to another agent to run them in parallel.
+
+
+
+ {connectedDialogNeedsPro ? (
+
handleUpgrade("api_pro")}>
+ Upgrade to connect
+ more
+
+ ) : (
+
{
+ if (!connectedPluginId) return
+ const pluginId = connectedPluginId
+ setConnectedPluginId(null)
+ createPluginKeyMutation.mutate(pluginId)
+ }}
+ disabled={!!connectingPlugin}
+ >
+ {connectingPlugin === connectedPluginId ? (
+ <>
+ Connecting…
+ >
+ ) : (
+ <>
+ Connect another
+ >
+ )}
+
+ )}
+
+
+ Done
+
+
+
+
+
+
+
{
+ if (!open) setFinishSetupPluginId(null)
+ }}
+ >
+
+
+ Finish setup {finishSetupPlugin?.name ?? "plugin"}
+
+
+ {finishSetupPlugin && (
+
+
+
+ )}
+
+
+ Finish setup {finishSetupPlugin?.name ?? "plugin"}
+
+
+ Complete install in the tool — this card turns active after the
+ first API call.
+
+
+
+
+
+
+
+
+ {finishSetupSteps.length > 0 ? (
+
+ ) : (
+
+ Open {finishSetupPlugin?.name ?? "the plugin"} and finish
+ authentication, then send a test memory.
+
+ )}
+
+
+
+
+
+ Done
+
+
+
+
+
+
+
{
+ if (!open) closeMcpModal()
+ else setMcpModalOpen(true)
+ }}
+ >
+
+
+ Set up {activeMcpClient?.name ?? "MCP client"}
+
+
+
+ {activeMcpClient && activeMcpClient.key !== "mcp-url" ? (
+
+ ) : (
+
+ )}
+
+
+
+ Set up {activeMcpClient?.name ?? "MCP client"}
+
+
+ Connect supermemory MCP to{" "}
+ {activeMcpClient?.name ?? "your client"}.
+
+
+
+
+
+
+
+ Done
+
+
+
+
+
+
{
+ setGranolaModalOpen(open && connectorAccess)
+ if (!open) void setConnectTarget(null)
+ }}
+ />
)
}
diff --git a/apps/web/components/integrations/install-steps.tsx b/apps/web/components/integrations/install-steps.tsx
index 4616b07c..de2aaa50 100644
--- a/apps/web/components/integrations/install-steps.tsx
+++ b/apps/web/components/integrations/install-steps.tsx
@@ -15,14 +15,16 @@ export function PillButton({
children,
onClick,
disabled,
+ type = "button",
}: {
children: ReactNode
onClick?: () => void
disabled?: boolean
+ type?: "button" | "submit"
}) {
return (
| null
+ start?: string | null
+}
+
function SectionHeader({ children }: { children: ReactNode }) {
return (
void }) {
)
}
-function ConnectedPill({
+function toDate(value: string | Date | null | undefined): Date | null {
+ if (!value) return null
+ const date = value instanceof Date ? value : new Date(value)
+ return Number.isNaN(date.getTime()) ? null : date
+}
+
+function formatRelativeTime(value: string | Date | null | undefined): string {
+ const date = toDate(value)
+ if (!date) return "Never"
+ const absMs = Math.abs(Date.now() - date.getTime())
+ const minute = 60 * 1000
+ const hour = 60 * minute
+ const day = 24 * hour
+ if (absMs < minute) return "Just now"
+ if (absMs < hour) return `${Math.round(absMs / minute)}m ago`
+ if (absMs < day) return `${Math.round(absMs / hour)}h ago`
+ return `${Math.round(absMs / day)}d ago`
+}
+
+function formatDate(value: string | Date | null | undefined): string {
+ const date = toDate(value)
+ if (!date) return "Unknown"
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })
+}
+
+function maskKey(start: string | null | undefined): string {
+ if (!start) return "sm_********"
+ return `${start}********`
+}
+
+function keyPrefix(key: ListedApiKey): string | null {
+ return key.start ?? (key.name?.startsWith("sm_") ? key.name : null)
+}
+
+function DetailStat({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ )
+}
+
+function ActivePill({
+ plugin,
connectedKeys,
onRevoke,
}: {
+ plugin: PluginInfo
connectedKeys: ConnectedPlugin[]
onRevoke: (keyId: string) => void
}) {
+ const primaryKey = connectedKeys[0]
return (
@@ -140,7 +224,7 @@ function ConnectedPill({
)}
>
- Connected
+ ACTIVE
@@ -148,13 +232,67 @@ function ConnectedPill({
align="end"
className={cn(
dmSans125ClassName(),
- "w-[260px] rounded-xl border border-white/10 bg-[#1B1F24] p-2 text-[#FAFAFA]",
+ "w-[min(380px,calc(100vw-32px))] rounded-xl border border-white/10 bg-[#1B1F24] p-4 text-[#FAFAFA]",
)}
>
+
+
+
+
+ {plugin.name}
+
+
Active
+
+
+
+
+
+
+
+
+
+
+
+ {plugin.docsUrl && (
+
+ Docs
+
+ )}
+ {plugin.githubUrl && (
+
+ GitHub
+
+ )}
+
+ Connected {formatDate(primaryKey?.createdAt)}
+
+
+
{connectedKeys.length > 1
@@ -188,22 +326,26 @@ function PluginRow({
plugin,
pluginId,
connectedKeys,
+ needsSetup,
needsProUpgrade,
isConnecting,
actionsDisabled,
onConnect,
onUpgrade,
onRevoke,
+ onFinishSetup,
}: {
plugin: PluginInfo
pluginId: string
connectedKeys: ConnectedPlugin[]
+ needsSetup: boolean
needsProUpgrade: boolean
isConnecting: boolean
actionsDisabled: boolean
onConnect: (id: string) => void
onUpgrade: () => void
onRevoke: (keyId: string) => void
+ onFinishSetup: (id: string) => void
}) {
const isConnected = connectedKeys.length > 0
return (
@@ -240,7 +382,16 @@ function PluginRow({
{plugin.docsUrl &&
}
{isConnected ? (
-
+
+ ) : needsSetup ? (
+
onFinishSetup(pluginId)}>
+
+ Finish setup
+
) : needsProUpgrade ? (
Upgrade
@@ -307,6 +458,9 @@ export function PluginsDetail() {
const queryClient = useQueryClient()
const [tierFilter, setTierFilter] = useState("all")
const [connectingPlugin, setConnectingPlugin] = useState(null)
+ const [finishSetupPluginId, setFinishSetupPluginId] = useState(
+ null,
+ )
const [newKey, setNewKey] = useState<{
open: boolean
key: string
@@ -332,21 +486,51 @@ export function PluginsDetail() {
queryKey: ["plugins"],
})
- const { data: apiKeys = [], refetch: refetchKeys } = useQuery({
- enabled: !!org?.id,
- queryFn: async () => {
- if (!org?.id) return []
- const data = await authClient.apiKey.list({
- fetchOptions: { query: { metadata: { organizationId: org.id } } },
- })
- return data.filter((key) => key.metadata?.organizationId === org.id)
+ const { data: apiKeys = [], refetch: refetchKeys } = useQuery(
+ {
+ enabled: !!org?.id,
+ queryFn: async () => {
+ if (!org?.id) return []
+ const API_URL =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+ const res = await fetch(`${API_URL}/v3/auth/keys`, {
+ credentials: "include",
+ })
+ if (!res.ok) return []
+ const data = (await res.json()) as { keys?: ListedApiKey[] }
+ return data.keys ?? []
+ },
+ queryKey: ["api-keys", org?.id],
},
- queryKey: ["api-keys", org?.id],
- })
+ )
+
+ const setupPluginIds = useMemo(() => {
+ const ids = new Set()
+ for (const key of apiKeys) {
+ if (key.enabled === false) continue
+ if (key.lastRequest) continue
+ if (!key.metadata) continue
+ try {
+ const metadata =
+ typeof key.metadata === "string"
+ ? (JSON.parse(key.metadata) as {
+ sm_type?: string
+ sm_client?: string
+ })
+ : (key.metadata as { sm_type?: string; sm_client?: string })
+ if (metadata.sm_type === "plugin_auth" && metadata.sm_client) {
+ ids.add(normalizePluginClientId(metadata.sm_client))
+ }
+ } catch {}
+ }
+ return ids
+ }, [apiKeys])
const connectedPlugins = useMemo(() => {
const plugins: ConnectedPlugin[] = []
for (const key of apiKeys) {
+ if (key.enabled === false) continue
+ if (!key.lastRequest) continue
if (!key.metadata) continue
try {
const metadata =
@@ -358,12 +542,15 @@ export function PluginsDetail() {
: (key.metadata as { sm_type?: string; sm_client?: string })
if (metadata.sm_type === "plugin_auth" && metadata.sm_client) {
+ const createdAt = toDate(key.createdAt)?.toISOString()
+ const lastRequest = toDate(key.lastRequest)?.toISOString()
plugins.push({
id: key.id,
keyId: key.id,
- pluginId: metadata.sm_client,
- createdAt: key.createdAt.toISOString(),
- keyStart: key.start ?? null,
+ pluginId: normalizePluginClientId(metadata.sm_client),
+ createdAt: createdAt ?? new Date().toISOString(),
+ lastRequest: lastRequest ?? null,
+ keyStart: keyPrefix(key),
})
}
} catch {}
@@ -426,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")
@@ -463,13 +650,18 @@ export function PluginsDetail() {
const dialogPlugin = newKey.pluginId
? PLUGIN_CATALOG[newKey.pluginId]
: undefined
+ const finishSetupPlugin = finishSetupPluginId
+ ? PLUGIN_CATALOG[finishSetupPluginId]
+ : undefined
const pluginSteps = dialogPlugin?.installSteps ?? []
// If a step already embeds the key (an `export …="sm_…"` line), don't also
// show the bare key in its own step — that's the repetition to avoid.
- // Otherwise (wizard-style installs) lead with a copy-the-key step.
+ // Otherwise (wizard-style installs) lead with a copy-the-key step, unless
+ // the plugin performs browser OAuth itself.
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
- const setupSteps: InstallStep[] = stepsEmbedKey
+ const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth
+ const setupSteps: InstallStep[] = skipGeneratedKeyStep
? pluginSteps
: [
{
@@ -512,10 +704,15 @@ export function PluginsDetail() {
connectedKeys={connectedPlugins.filter(
(p) => p.pluginId === pluginId,
)}
+ needsSetup={
+ !connectedPluginIds.has(pluginId) &&
+ setupPluginIds.has(pluginId)
+ }
needsProUpgrade={needsProUpgrade}
isConnecting={connectingPlugin === pluginId}
actionsDisabled={!!connectingPlugin}
onConnect={(id) => createPluginKeyMutation.mutate(id)}
+ onFinishSetup={(id) => setFinishSetupPluginId(id)}
onUpgrade={handleUpgrade}
onRevoke={handleRevoke}
/>
@@ -638,6 +835,97 @@ export function PluginsDetail() {
+
+
{
+ if (!open) setFinishSetupPluginId(null)
+ }}
+ >
+
+
+ Finish setup {finishSetupPlugin?.name ?? "plugin"}
+
+
+ {finishSetupPlugin && (
+
+ )}
+
+
+ Finish setup {finishSetupPlugin?.name ?? "plugin"}
+
+
+ Complete install in the tool — status becomes active after the
+ first API call.
+
+
+
+
+
+
+
+
+ {finishSetupPlugin?.installSteps?.length ? (
+
+ ) : (
+
+ Open {finishSetupPlugin?.name ?? "the plugin"} and finish
+ authentication.
+
+ )}
+
+
+
+
+
+ Done
+
+
+
+
+
>
)
}
diff --git a/apps/web/components/integrations/shortcuts-detail.tsx b/apps/web/components/integrations/shortcuts-detail.tsx
index 9d43922b..833488eb 100644
--- a/apps/web/components/integrations/shortcuts-detail.tsx
+++ b/apps/web/components/integrations/shortcuts-detail.tsx
@@ -23,14 +23,18 @@ import Image from "next/image"
import { useId, useState } from "react"
import { toast } from "sonner"
+type ShortcutType = "add" | "search"
+
function PillButton({
children,
onClick,
disabled,
+ className,
}: {
children: React.ReactNode
- onClick?: () => void
+ onClick?: (e: React.MouseEvent) => void
disabled?: boolean
+ className?: string
}) {
return (
{children}
@@ -52,17 +57,14 @@ function PillButton({
)
}
-export function ShortcutsDetail() {
+export function useShortcutsConnect() {
const { org } = useAuth()
- const [showApiKeyModal, setShowApiKeyModal] = useState(false)
+ const [showSetupDialog, setShowSetupDialog] = useState(false)
const [apiKey, setApiKey] = useState("")
const [copied, setCopied] = useState(false)
- const [selectedShortcutType, setSelectedShortcutType] = useState<
- "add" | "search" | null
- >(null)
- const apiKeyId = useId()
+ const [shortcutType, setShortcutType] = useState(null)
- const handleCopyApiKey = async (key: string) => {
+ const copyApiKey = async (key: string) => {
try {
await navigator.clipboard.writeText(key)
setCopied(true)
@@ -73,20 +75,24 @@ export function ShortcutsDetail() {
}
}
- const createApiKeyMutation = useMutation({
+ const createKeyMutation = useMutation({
mutationFn: async () => {
+ if (!org?.id) throw new Error("Organization ID is required")
const res = await authClient.apiKey.create({
- metadata: { organizationId: org?.id, type: "ios-shortcut" },
+ metadata: { organizationId: org.id, type: "ios-shortcut" },
name: `ios-${generateId().slice(0, 8)}`,
- prefix: `sm_${org?.id}_`,
+ prefix: `sm_${org.id}_`,
})
- return res.key
+ if (res.error)
+ throw new Error(res.error.message ?? "Failed to create API key")
+ if (!res.data?.key) throw new Error("API key missing from response")
+ return res.data.key
},
onSuccess: (key) => {
setApiKey(key)
- setShowApiKeyModal(true)
+ setShowSetupDialog(true)
setCopied(false)
- handleCopyApiKey(key)
+ copyApiKey(key)
},
onError: (error) => {
toast.error("Failed to create API key", {
@@ -95,19 +101,224 @@ export function ShortcutsDetail() {
},
})
- const handleShortcutClick = (type: "add" | "search") => {
- setSelectedShortcutType(type)
- createApiKeyMutation.mutate()
+ const connect = (type: ShortcutType) => {
+ if (createKeyMutation.isPending) return
+ setShortcutType(type)
+ createKeyMutation.mutate()
}
- const handleOpenShortcut = () => {
- if (selectedShortcutType === "add") {
+ const openShortcut = () => {
+ if (shortcutType === "add") {
window.open(ADD_MEMORY_SHORTCUT_URL, "_blank")
- } else if (selectedShortcutType === "search") {
+ } else if (shortcutType === "search") {
window.open(SEARCH_MEMORY_SHORTCUT_URL, "_blank")
}
}
+ const dialog = (
+ copyApiKey(apiKey)}
+ onOpenChange={(open) => {
+ setShowSetupDialog(open)
+ if (!open) {
+ setShortcutType(null)
+ setApiKey("")
+ setCopied(false)
+ }
+ }}
+ onOpenShortcut={openShortcut}
+ open={showSetupDialog}
+ shortcutType={shortcutType}
+ />
+ )
+
+ return {
+ connect,
+ dialog,
+ isPending: createKeyMutation.isPending,
+ pendingType: createKeyMutation.isPending ? shortcutType : null,
+ }
+}
+
+export type ShortcutsConnectController = ReturnType
+
+export function ShortcutsConnectButtons({
+ controller,
+}: {
+ controller: ShortcutsConnectController
+}) {
+ const { connect, isPending, pendingType } = controller
+ return (
+
+
{
+ e.stopPropagation()
+ connect("add")
+ }}
+ disabled={isPending}
+ >
+ {pendingType === "add" ? (
+
+ ) : (
+
+ )}
+
+ {pendingType === "add" ? "Creating..." : "Add memory shortcut"}
+
+
+
{
+ e.stopPropagation()
+ connect("search")
+ }}
+ disabled={isPending}
+ >
+ {pendingType === "search" ? (
+
+ ) : (
+
+ )}
+
+ {pendingType === "search" ? "Creating..." : "Search memory shortcut"}
+
+
+
+ )
+}
+
+function ShortcutsSetupDialog({
+ apiKey,
+ copied,
+ onCopy,
+ onOpenChange,
+ onOpenShortcut,
+ open,
+ shortcutType,
+}: {
+ apiKey: string
+ copied: boolean
+ onCopy: () => void
+ onOpenChange: (open: boolean) => void
+ onOpenShortcut: () => void
+ open: boolean
+ shortcutType: ShortcutType | null
+}) {
+ const apiKeyId = useId()
+ return (
+
+
+
+
+
+ Setup Apple Shortcut
+
+
+
+
+
+ Your API Key
+
+
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ Follow these steps:
+
+
+ {[
+ 'Click "Add to Shortcuts" below to open the shortcut',
+ "Paste your API key when prompted",
+ "Start using your shortcut!",
+ ].map((text, i) => (
+
+
+ {i + 1}
+
+
+ {text}
+
+
+ ))}
+
+
+
+
+ Add to Shortcuts
+
+
+
+
+
+ )
+}
+
+export function ShortcutsDetail() {
+ const controller = useShortcutsConnect()
+ const { connect, dialog, isPending, pendingType } = controller
+
return (
<>
-
handleShortcutClick("add")}
- disabled={createApiKeyMutation.isPending}
- >
- {createApiKeyMutation.isPending &&
- selectedShortcutType === "add" ? (
+ connect("add")} disabled={isPending}>
+ {pendingType === "add" ? (
) : (
)}
- {createApiKeyMutation.isPending &&
- selectedShortcutType === "add"
- ? "Creating..."
- : "Add memory shortcut"}
+ {pendingType === "add" ? "Creating..." : "Add memory shortcut"}
- handleShortcutClick("search")}
- disabled={createApiKeyMutation.isPending}
- >
- {createApiKeyMutation.isPending &&
- selectedShortcutType === "search" ? (
+ connect("search")} disabled={isPending}>
+ {pendingType === "search" ? (
) : (
)}
- {createApiKeyMutation.isPending &&
- selectedShortcutType === "search"
+ {pendingType === "search"
? "Creating..."
: "Search memory shortcut"}
@@ -177,120 +376,7 @@ export function ShortcutsDetail() {
-
- {
- setShowApiKeyModal(open)
- if (!open) {
- setSelectedShortcutType(null)
- setApiKey("")
- setCopied(false)
- }
- }}
- >
-
-
-
-
- Setup Apple Shortcut
-
-
-
-
-
- Your API Key
-
-
-
- handleCopyApiKey(apiKey)}
- className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
- >
- {copied ? (
-
- ) : (
-
- )}
-
-
-
-
-
- Follow these steps:
-
-
- {[
- 'Click "Add to Shortcuts" below to open the shortcut',
- "Paste your API key when prompted",
- "Start using your shortcut!",
- ].map((text, i) => (
-
-
- {i + 1}
-
-
- {text}
-
-
- ))}
-
-
-
-
- Add to Shortcuts
-
-
-
-
-
+ {dialog}
>
)
}
diff --git a/apps/web/components/login-tools-panel.tsx b/apps/web/components/login-tools-panel.tsx
new file mode 100644
index 00000000..40c3b6d5
--- /dev/null
+++ b/apps/web/components/login-tools-panel.tsx
@@ -0,0 +1,57 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import { dmSansClassName } from "@/lib/fonts"
+import OrbitMemory from "@/components/orbit-memory"
+
+function LoginPanelBackground() {
+ return (
+ <>
+
+
+
+
+
+ >
+ )
+}
+
+export function LoginToolsPanel() {
+ return (
+
+
+
+
+
+ {/* overflow:visible lets the core glow bleed and fade into the
+ panel instead of being hard-clipped at the orbit's box edge;
+ the panel's own overflow-hidden clips it softly at the edges */}
+
+
+
+
+
+ One memory layer — context from any tool, everywhere you need it.
+
+
+ )
+}
diff --git a/apps/web/components/mcp-modal/claude-desktop-manual-timeline.tsx b/apps/web/components/mcp-modal/claude-desktop-manual-timeline.tsx
index 67ac3aa1..4d32fa3a 100644
--- a/apps/web/components/mcp-modal/claude-desktop-manual-timeline.tsx
+++ b/apps/web/components/mcp-modal/claude-desktop-manual-timeline.tsx
@@ -69,52 +69,76 @@ export function ClaudeDesktopManualTimeline({
}: ClaudeDesktopManualTimelineProps) {
const isDetail = variant === "detail"
- const textMuted = isDetail ? "text-[#B0B0B0]" : "text-muted-foreground"
- const border = isDetail ? "border-[#3D434D]" : "border-border"
- const spine = isDetail ? "bg-[#7C8C9E]" : "bg-muted-foreground/40"
+ const textMuted = isDetail ? "text-[#A1A1AA]" : "text-muted-foreground"
+ const border = isDetail ? "border-white/[0.07]" : "border-border"
+ const spine = isDetail ? "bg-white/[0.14]" : "bg-muted-foreground/40"
const circleBorder = isDetail
- ? "border-[#9CA8B8]"
+ ? "border-white/[0.12]"
: "border-muted-foreground/50"
- const circleBg = isDetail ? "bg-[#080B0F]" : "bg-background"
- const codeBg = isDetail ? "bg-[#050608]" : "bg-muted"
- const imgBorder = isDetail ? "border-[#3D434D]" : "border-border"
- const screenshotBed = isDetail ? "bg-[#2A2D35]" : "bg-muted"
+ const circleBg = isDetail ? "bg-[#0D121A]" : "bg-background"
+ const codeBg = isDetail ? "bg-[#0B0E13]" : "bg-muted"
+ const imgBorder = isDetail ? "border-white/[0.07]" : "border-border"
+ const screenshotBed = isDetail ? "bg-[#0B0E13]" : "bg-muted"
return (
-
+
{STEPS.map((item) => (
-
-
- {item.step}
-
+
+ {isDetail ? (
+
+ {item.step}
+
+ ) : (
+
+ {item.step}
+
+ )}
-
+
{item.body}
@@ -123,14 +147,14 @@ export function ClaudeDesktopManualTimeline({
@@ -139,13 +163,13 @@ export function ClaudeDesktopManualTimeline({
{snippetCopied ? (
- ✓
+
) : (
-
+
)}
diff --git a/apps/web/components/mcp-modal/mcp-detail-view.tsx b/apps/web/components/mcp-modal/mcp-detail-view.tsx
index 43960e18..c72c61f3 100644
--- a/apps/web/components/mcp-modal/mcp-detail-view.tsx
+++ b/apps/web/components/mcp-modal/mcp-detail-view.tsx
@@ -30,8 +30,70 @@ import {
getManualInstallEntry,
} from "@/lib/mcp-manual-instructions"
import { ClaudeDesktopManualTimeline } from "@/components/mcp-modal/claude-desktop-manual-timeline"
+import { INSET } from "@/components/integrations/install-steps"
+
+function McpCodeBlock({
+ code,
+ multiline,
+ onCopy,
+}: {
+ code: string
+ multiline?: boolean
+ onCopy?: () => void
+}) {
+ const [copied, setCopied] = useState(false)
+ const copy = () => {
+ navigator.clipboard.writeText(code)
+ setCopied(true)
+ toast.success("Copied to clipboard!")
+ setTimeout(() => setCopied(false), 2000)
+ onCopy?.()
+ }
+ return (
+
+
+
+ {code}
+
+ {!multiline && (
+
+ )}
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
const clients = {
+ antigravity: "Antigravity",
chatgpt: "ChatGPT",
codex: "Codex",
cursor: "Cursor",
@@ -70,6 +132,7 @@ const MCP_CATEGORIES: {
{
label: "IDEs",
items: [
+ { key: "antigravity", title: "Antigravity", subtitle: "AI IDE" },
{ key: "cursor", title: "Cursor", subtitle: "AI IDE" },
{ key: "vscode", title: "VS Code", subtitle: "IDE" },
{ key: "cline", title: "Cline", subtitle: "VS Code extension" },
@@ -84,6 +147,7 @@ const MCP_CATEGORIES: {
function clientIconSrc(key: ClientKey) {
if (key === "mcp-url") return "/mcp-icon.svg"
+ if (key === "antigravity") return "/mcp-supported-tools/antigravity.png"
const file = key === "claude-code" ? "claude" : key
return `/mcp-supported-tools/${file}.png`
}
@@ -101,7 +165,8 @@ function ClientToolIcon({
return (
{failed ? (
@@ -137,7 +202,6 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
"mcpTab",
parseAsStringLiteral(["oneClick", "manual"] as const).withDefault("manual"),
)
- const [isCommandCopied, setIsCommandCopied] = useState(false)
const [isManualCopied, setIsManualCopied] = useState(false)
const [activeStep, setActiveStep] = useQueryState(
"mcpStep",
@@ -170,15 +234,6 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
return command
}
- const copyToClipboard = () => {
- const command = generateInstallCommand()
- navigator.clipboard.writeText(command)
- analytics.mcpInstallCmdCopied()
- setIsCommandCopied(true)
- setActiveStep(3)
- setTimeout(() => setIsCommandCopied(false), 2000)
- }
-
const copyManualSnippet = (text: string) => {
navigator.clipboard.writeText(text)
analytics.mcpInstallCmdCopied()
@@ -203,17 +258,6 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
? "manual"
: setupTab
- const gradientStep3 =
- activeStep === 3
- ? {
- background:
- "linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
- backgroundClip: "text",
- WebkitBackgroundClip: "text",
- WebkitTextFillColor: "transparent",
- }
- : undefined
-
return (
0 && "border-t border-[#242A33]",
+ rowIndex > 0 && "border-t border-white/[0.06]",
)}
>
@@ -249,9 +293,9 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
setActiveStep(2)
}}
className={cn(
- "group flex w-full min-w-0 items-center gap-3 rounded-xl border border-[#242A33] bg-[#080B0F] p-3.5 text-left transition-colors",
- "hover:border-[#3273FC4D] hover:bg-[#08142D]",
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#3273FC]/40",
+ "group flex w-full min-w-0 items-center gap-3 rounded-[12px] bg-[#0D121A] p-3.5 text-left transition-colors",
+ "shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)] hover:bg-[#10151D]",
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/40",
)}
>
@@ -259,10 +303,12 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
{item.title}
-
{item.subtitle}
+
+ {item.subtitle}
+
@@ -270,51 +316,63 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
))}
-
+
You can connect several clients; setup is different for each one.
) : (
-
{
- setSelectedClient(null)
- setActiveStep(1)
- }}
- className="text-sm font-medium text-[#8B8B8B] transition-colors hover:text-[#FAFAFA]"
+ {!isEmbedded && (
+ <>
+ {
+ setSelectedClient(null)
+ setActiveStep(1)
+ }}
+ className="text-sm font-medium text-[#A1A1AA] transition-colors hover:text-[#FAFAFA]"
+ >
+ ← All clients
+
+
+
+
+
+
+ {clients[selectedKey]}
+
+
+ {detailSetup ? setupInstructionsSubtitle(detailSetup) : ""}
+
+
+
+ >
+ )}
+
+
- ← All clients
-
-
-
-
-
-
- {clients[selectedKey]}
-
-
- {detailSetup ? setupInstructionsSubtitle(detailSetup) : ""}
-
-
-
-
-
{detailSetup && mcpClientSetupShowsTabs(detailSetup) ? (
setSetupTab("manual")}
role="tab"
@@ -325,10 +383,10 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
setSetupTab("oneClick")}
role="tab"
@@ -345,47 +403,31 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
<>
{selectedClient === "mcp-url" && (
-
+
Paste this URL into any MCP client that supports remote
servers over HTTPS.
-
-
- {
- navigator.clipboard.writeText(
- "https://mcp.supermemory.ai/mcp",
- )
- analytics.mcpInstallCmdCopied()
- toast.success("Copied to clipboard!")
- setActiveStep(3)
- }}
- >
-
-
-
+
{
+ analytics.mcpInstallCmdCopied()
+ toast.success("Copied to clipboard!")
+ setActiveStep(3)
+ }}
+ />
)}
{selectedClient === "cursor" && (
-
+
Open the link below to add supermemory in Cursor, or use
Manual instructions to edit{" "}
-
+
~/.cursor/mcp.json
.
-
+
-
-
- {detailSetup != null &&
- mcpClientShowsManual(detailSetup, effectiveSetupTab)
- ? selectedClient === "chatgpt"
- ? "Finish in ChatGPT"
- : selectedClient === "claude"
- ? "Finish in Claude Desktop"
- : "Save and restart"
- : selectedClient === "cursor"
- ? "Finish in Cursor"
- : "Run in terminal"}
-
- {activeStep === 3 && (
-
+
-
{detailSetup != null &&
mcpClientShowsManual(detailSetup, effectiveSetupTab)
? selectedClient === "chatgpt"
- ? "Complete app setup in ChatGPT, then enable it for your chat."
+ ? "Finish in ChatGPT"
: selectedClient === "claude"
- ? "After Connectors, complete any prompts so supermemory is enabled for chat."
- : "Save your config, restart the client, and sign in if prompted."
+ ? "Finish in Claude Desktop"
+ : "Save and restart"
: selectedClient === "cursor"
- ? "Complete the install in Cursor and sign in with OAuth if asked."
- : "Waiting for installation"}
-
- )}
-
+ ? "Finish in Cursor"
+ : "Run in terminal"}
+
+ {activeStep === 3 && (
+
+
+ {detailSetup != null &&
+ mcpClientShowsManual(detailSetup, effectiveSetupTab)
+ ? selectedClient === "chatgpt"
+ ? "Complete app setup in ChatGPT, then enable it for your chat."
+ : selectedClient === "claude"
+ ? "After Connectors, complete any prompts so supermemory is enabled for chat."
+ : "Save your config, restart the client, and sign in if prompted."
+ : selectedClient === "cursor"
+ ? "Complete the install in Cursor and sign in with OAuth if asked."
+ : "Waiting for installation"}
+
+ )}
+
+ )}
)}
diff --git a/apps/web/components/memories-grid.tsx b/apps/web/components/memories-grid.tsx
index 795056cf..be363274 100644
--- a/apps/web/components/memories-grid.tsx
+++ b/apps/web/components/memories-grid.tsx
@@ -6,6 +6,7 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import { useCallback, memo, useMemo, useState, useRef, useEffect } from "react"
import { useQueryState } from "nuqs"
+import { AnimatePresence } from "motion/react"
import type { z } from "zod"
import { Masonry, useInfiniteLoader } from "masonic"
import { dmSansClassName } from "@/lib/fonts"
@@ -54,11 +55,21 @@ import {
CheckIcon,
LayoutGrid,
Loader,
+ MoreHorizontal,
Trash2Icon,
+ UserRound,
XIcon,
} from "lucide-react"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@ui/components/dropdown-menu"
import { useProcessingDocuments } from "@/hooks/use-processing-documents"
import { TimelineView } from "./timeline-view"
+import { SpaceProfilePanel } from "@/components/space-profile-panel"
+import { SpaceProfileModal } from "@/components/space-profile-modal"
// Document category type
type DocumentCategory =
@@ -249,6 +260,7 @@ export function MemoriesGrid({
emptyStateProps,
}: MemoriesGridProps) {
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
+ const [profileOpen, setProfileOpen] = useState(false)
const [localViewMode, setLocalViewMode] = useState<"grid" | "timeline">(
() => {
if (typeof window === "undefined") return "grid"
@@ -259,7 +271,7 @@ export function MemoriesGrid({
},
)
const { user, isSessionPending } = useAuth()
- const { effectiveContainerTags } = useProject()
+ const { effectiveContainerTags, selectedProject } = useProject()
const processingStatusMap = useProcessingDocuments()
const isMobile = useIsMobile()
const [selectedCategories, setSelectedCategories] = useQueryState(
@@ -347,6 +359,14 @@ export function MemoriesGrid({
localStorage.setItem("memories-view-mode", mode)
}, [])
+ const handleToggleProfile = useCallback(() => {
+ if (isMobile) {
+ setProfileOpen(true)
+ return
+ }
+ setProfileOpen((open) => !open)
+ }, [isMobile])
+
const handleCategoryToggle = useCallback(
(category: DocumentCategory) => {
setSelectedCategories((prev) => {
@@ -541,7 +561,7 @@ export function MemoriesGrid({
documents.every((d) => d.id && selectedDocumentIds.has(d.id))
return (
-
+
{!isEmpty && !isSelectionMode && (
- {onEnterSelectionMode && (
-
+
+
+
+ More
+
+
+
-
-
- )}
+ {onEnterSelectionMode && (
+
+
+ Select memories
+
+ )}
+
+
+ Space profile
+
+
+
)}
@@ -749,64 +804,82 @@ export function MemoriesGrid({
- {error ? (
-
-
- Error loading documents: {error.message}
-
-
- ) : isPending ? (
-
- ) : showNovaEmptyState ? (
-
- ) : isEmpty ? (
-
-
- No memories found
-
-
- ) : (
-
- {localViewMode === "timeline" ? (
-
- ) : (
-
- )}
-
- {isLoadingMore && localViewMode === "grid" && (
-
-
+
+ {error ? (
+
+
+ Error loading documents: {error.message}
- )}
-
- )}
+
+ ) : isPending ? (
+
+ ) : showNovaEmptyState ? (
+
+ ) : isEmpty ? (
+
+
+ No memories found
+
+
+ ) : (
+
+
+ {localViewMode === "timeline" ? (
+
+ ) : (
+
+ )}
+
+ {isLoadingMore && localViewMode === "grid" && (
+
+
+
+ )}
+
+
+ {profileOpen && !isMobile && (
+ setProfileOpen(false)}
+ />
+ )}
+
+
+ )}
+
+
)
}
diff --git a/apps/web/components/memory-graph/graph-card.tsx b/apps/web/components/memory-graph/graph-card.tsx
index 0269aab8..de8bed3d 100644
--- a/apps/web/components/memory-graph/graph-card.tsx
+++ b/apps/web/components/memory-graph/graph-card.tsx
@@ -4,6 +4,7 @@ import { memo, useMemo } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { Expand } from "lucide-react"
+import { SuperLoader } from "@/components/superloader"
import { useGraphApi } from "./hooks/use-graph-api"
import { useViewMode } from "@/lib/view-mode-context"
@@ -157,7 +158,12 @@ export const GraphCard = memo
(
{isLoading ? (
) : documentCount > 0 || memoryCount > 0 ? (
total + 1 + (doc.memoryEntries?.length ?? 0),
+ 0,
+ )
+}
+
function toGraphMemory(mem: ApiMemoryEntry): GraphApiMemory {
return {
id: mem.id,
@@ -108,7 +116,7 @@ function toGraphDocument(
}
export function useGraphApi(options: UseGraphApiOptions = {}) {
- const { containerTags, documentIds, enabled = true } = options
+ const { containerTags, documentIds, enabled = true, maxNodes } = options
const filteredDocumentIds = documentIds?.filter(Boolean)
const hasDocumentIds =
filteredDocumentIds != null && filteredDocumentIds.length > 0
@@ -126,6 +134,7 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
containerTags,
[],
filteredDocumentIds,
+ maxNodes,
],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
@@ -155,7 +164,16 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
return response.data as unknown as ApiDocumentsResponse
},
- getNextPageParam: (lastPage) => {
+ getNextPageParam: (lastPage, allPages) => {
+ if (hasDocumentIds) return undefined
+ if (maxNodes != null) {
+ const loadedNodes = allPages.reduce(
+ (total, page) => total + getGraphNodeCount(page.documents ?? []),
+ 0,
+ )
+ if (loadedNodes >= maxNodes) return undefined
+ }
+
const { currentPage, totalPages } = lastPage.pagination
return currentPage < totalPages ? currentPage + 1 : undefined
},
@@ -163,6 +181,29 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
enabled,
})
+ const loadedNodeCount = useMemo(() => {
+ if (!data?.pages) return 0
+ return data.pages.reduce(
+ (total, page) => total + getGraphNodeCount(page.documents ?? []),
+ 0,
+ )
+ }, [data])
+
+ useEffect(() => {
+ if (!enabled || hasDocumentIds) return
+ if (!hasNextPage || isFetchingNextPage) return
+ if (maxNodes != null && loadedNodeCount >= maxNodes) return
+ fetchNextPage()
+ }, [
+ enabled,
+ hasDocumentIds,
+ hasNextPage,
+ isFetchingNextPage,
+ loadedNodeCount,
+ maxNodes,
+ fetchNextPage,
+ ])
+
const documents = useMemo(() => {
if (!data?.pages) return []
return data.pages.flatMap((page) =>
diff --git a/apps/web/components/memory-graph/memory-graph-wrapper.tsx b/apps/web/components/memory-graph/memory-graph-wrapper.tsx
index 0b2ca826..0757b0c2 100644
--- a/apps/web/components/memory-graph/memory-graph-wrapper.tsx
+++ b/apps/web/components/memory-graph/memory-graph-wrapper.tsx
@@ -1,8 +1,8 @@
"use client"
-import { useEffect, useRef, useState } from "react"
import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph"
import type { GraphThemeColors } from "@supermemory/memory-graph"
+import { SuperLoader } from "@/components/superloader"
import { useGraphApi } from "./hooks/use-graph-api"
export interface MemoryGraphWrapperProps {
@@ -34,20 +34,6 @@ export function MemoryGraph({
canvasRef,
...rest
}: MemoryGraphWrapperProps) {
- const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
- const containerRef = useRef(null)
-
- useEffect(() => {
- const el = containerRef.current
- if (!el) return
- const ro = new ResizeObserver(() => {
- setContainerSize({ width: el.clientWidth, height: el.clientHeight })
- })
- ro.observe(el)
- setContainerSize({ width: el.clientWidth, height: el.clientHeight })
- return () => ro.disconnect()
- }, [])
-
const {
documents,
isLoading: apiIsLoading,
@@ -59,16 +45,17 @@ export function MemoryGraph({
} = useGraphApi({
containerTags,
documentIds,
- enabled: containerSize.width > 0 && containerSize.height > 0,
+ maxNodes,
})
+ const isInitialLoading = externalIsLoading || apiIsLoading
return (
-
+
loadMore() : undefined}
+ isLoading={false}
+ isLoadingMore={false}
+ onLoadMore={hasMore && !isLoadingMore ? () => loadMore() : undefined}
hasMore={hasMore}
error={externalError || apiError}
variant={variant}
@@ -85,6 +72,16 @@ export function MemoryGraph({
>
{children}
+ {isInitialLoading && (
+
+
+
+ )}
)
}
diff --git a/apps/web/components/next-app-research-cta.tsx b/apps/web/components/next-app-research-cta.tsx
deleted file mode 100644
index e391c89e..00000000
--- a/apps/web/components/next-app-research-cta.tsx
+++ /dev/null
@@ -1,310 +0,0 @@
-"use client"
-
-import { useCallback, useEffect, useState } from "react"
-import { usePathname } from "next/navigation"
-import { Phone, Users, X as XIcon } from "lucide-react"
-import { useLobbyside } from "@lobbyside/react"
-import { useAuth } from "@lib/auth-context"
-import { cn } from "@lib/utils"
-import { dmSans125ClassName } from "@/lib/fonts"
-import { analytics } from "@/lib/analytics"
-
-const STORAGE_KEY = "sm_next_app_research_cta_dismissed_v1"
-
-const BOOK_CALL_HREF = "https://cal.com/supermemory/growth"
-
-const LOBBYSIDE_WIDGET_ID = "e385c52f-4dd3-4fb2-81eb-da3a78059014"
-
-function ResearchCtaHeroGraphic({
- avatarUrl,
- hostName,
-}: {
- avatarUrl?: string
- hostName?: string
-}) {
- return (
-
-
-
-
-
-
- ×
-
- {avatarUrl ? (
-
-
-
-
- ) : (
-
-
-
- )}
-
-
- )
-}
-
-export function NextAppResearchCta() {
- const pathname = usePathname()
- const [mounted, setMounted] = useState(false)
- const [dismissed, setDismissed] = useState(false)
- const widget = useLobbyside(LOBBYSIDE_WIDGET_ID)
- const { user, org } = useAuth()
-
- useEffect(() => {
- setMounted(true)
- setDismissed(localStorage.getItem(STORAGE_KEY) === "1")
- }, [])
-
- const handleDismiss = useCallback((e: React.MouseEvent) => {
- e.stopPropagation()
- localStorage.setItem(STORAGE_KEY, "1")
- setDismissed(true)
- analytics.nextAppResearchCtaDismissed()
- }, [])
-
- const handleJoinCall = useCallback(async () => {
- if (widget.status !== "online" || widget.isQueueFull) return
- analytics.nextAppResearchCtaLobbysideCallClicked()
- // Open the tab synchronously so Safari/iOS keep the user-activation
- // gesture. We redirect it once joinCall() resolves, or fall back to
- // the book-a-call URL if the host goes offline / queue fills / the
- // request errors between render and click.
- const pendingTab = window.open("", "_blank")
- const navigate = (url: string) => {
- if (pendingTab && !pendingTab.closed) {
- pendingTab.location.href = url
- } else {
- window.open(url, "_blank", "noopener,noreferrer")
- }
- }
- try {
- const visitor: Record
= {}
- if (user?.email) visitor.email = user.email
- if (user?.name) visitor.name = user.name
- if (org?.name) visitor.company = org.name
- const github = (user as { github?: unknown } | null)?.github
- if (typeof github === "string" && github) visitor.github = github
- const joinArgs = Object.keys(visitor).length > 0 ? { visitor } : undefined
- const { entryUrl } = await widget.joinCall(joinArgs)
- navigate(entryUrl)
- } catch (err) {
- console.error("[Lobbyside] joinCall failed", err)
- navigate(BOOK_CALL_HREF)
- }
- }, [widget, user, org])
-
- const handleCardKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- if (e.target !== e.currentTarget) return
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault()
- handleJoinCall()
- }
- },
- [handleJoinCall],
- )
-
- const handleBookClick = useCallback(() => {
- analytics.nextAppResearchCtaBookCallClicked()
- }, [])
-
- if (
- !mounted ||
- dismissed ||
- pathname.startsWith("/onboarding") ||
- widget.status === "loading"
- ) {
- return null
- }
-
- const cardBaseClasses = cn(
- "fixed z-[45] bottom-4 left-4 min-w-[280px] max-w-[min(calc(100vw-2rem),22.5rem)]",
- "rounded-xl border border-white/[0.08] bg-[#0D121A]/95 backdrop-blur-md",
- "shadow-[0_8px_32px_rgba(0,0,0,0.35)] p-3.5",
- )
-
- if (widget.status !== "online" || widget.isQueueFull) {
- return (
-
-
-
-
-
-
- Be part of the next supermemory app
-
-
-
-
-
-
- Share what you want next. We’d love a quick call.
-
-
-
-
-
- )
- }
-
- return (
-
-
-
-
-
-
- {widget.ctaText}
-
-
-
-
-
-
-
-
- {widget.hostName}
-
- {widget.hostTitle ? (
-
- {widget.hostTitle}
-
- ) : null}
-
-
{
- e.stopPropagation()
- handleJoinCall()
- }}
- className={cn(
- dmSans125ClassName(),
- "shrink-0 inline-flex text-[13px] font-medium text-[#A3A3A3]",
- "tracking-[-0.13px] underline underline-offset-4 decoration-white/20",
- "hover:text-[#FAFAFA] hover:decoration-white/40 transition-colors",
- "cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm",
- )}
- >
- {widget.buttonText}
-
-
-
-
-
- )
-}
diff --git a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx
new file mode 100644
index 00000000..1a709f0c
--- /dev/null
+++ b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx
@@ -0,0 +1,697 @@
+"use client"
+
+import { LogoFull } from "@ui/assets/Logo"
+import { Button } from "@ui/components/button"
+import { Input } from "@ui/components/input"
+import { cn } from "@lib/utils"
+import { ArrowRight, Check, Globe, Loader2 } from "lucide-react"
+import { useQueryClient } from "@tanstack/react-query"
+import { AnimatePresence, motion } from "motion/react"
+import { type ReactNode, useEffect, useRef, useState } from "react"
+import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
+import {
+ type ResearchEvent,
+ type ResearchStat,
+ useResearchStatus,
+} from "@/hooks/use-research-status"
+import {
+ cardSurfaceStyle,
+ DomainLogo,
+ fieldLabel,
+ inputBevelStyle,
+ inputClass,
+ UserAvatar,
+} from "./step-about"
+import { ResearchActionRail } from "./research-action-rail"
+import {
+ type CompanyBrainConfirmResult,
+ workspaceNameFromDomain,
+} from "./types"
+
+interface CompanyBrainOnboardingProps {
+ name: string
+ avatarUrl: string | null
+ domain: string
+ submitting: boolean
+ onConfirm: (domain: string) => Promise
+ onDone: () => void
+}
+
+const BACKEND =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+
+type Phase = "confirm" | "research"
+
+function normalizeDomain(input: string): string {
+ return input
+ .trim()
+ .toLowerCase()
+ .replace(/^https?:\/\//, "")
+ .replace(/^www\./, "")
+ .replace(/\/.*$/, "")
+}
+
+export function CompanyBrainOnboarding({
+ name,
+ avatarUrl,
+ domain: initialDomain,
+ submitting,
+ onConfirm,
+ onDone,
+}: CompanyBrainOnboardingProps) {
+ const [phase, setPhase] = useState("confirm")
+ const [domain, setDomain] = useState(initialDomain)
+ const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false)
+ const firstName = name.trim().split(/\s+/)[0] ?? ""
+ const clean = normalizeDomain(domain)
+ const queryClient = useQueryClient()
+ const { status: researchStatus } = useResearchStatus(phase === "research")
+ const researchDone = researchStatus === "done"
+
+ const handleConfirm = async () => {
+ if (!clean || submitting) return
+ const result = await onConfirm(clean)
+ if (!result.ok) return
+ setServerSchedulesResearch(result.serverSchedulesResearch)
+ setPhase("research")
+ }
+
+ // New-org signup schedules research after provisioning; if that hook is slow
+ // or fails, force-start from the client so onboarding doesn't stall.
+ useEffect(() => {
+ if (phase !== "research" || !serverSchedulesResearch || !clean) return
+ const timer = window.setTimeout(() => {
+ void (async () => {
+ try {
+ const res = await fetch(`${BACKEND}/brain/research/status`, {
+ credentials: "include",
+ headers: { "X-App-Source": "nova" },
+ })
+ if (!res.ok) return
+ const state = (await res.json()) as {
+ status?: string | null
+ events?: { aspect: string }[]
+ }
+ if (state.status === "done") return
+ // Real research aspects use ord >= 2; bail if one is already underway.
+ if ((state.events?.length ?? 0) >= 3) return
+ await fetch(`${BACKEND}/brain/research/start`, {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ "content-type": "application/json",
+ "X-App-Source": "nova",
+ },
+ body: JSON.stringify({ domain: clean }),
+ })
+ queryClient.invalidateQueries({ queryKey: ["brain-research-status"] })
+ } catch {}
+ })()
+ }, 40_000)
+ return () => window.clearTimeout(timer)
+ }, [phase, serverSchedulesResearch, clean, queryClient])
+
+ return (
+
+
+
+
+
+
+ {/* Persistent card: full confirm card, then morphs into a slim docked header. */}
+
+
+ {phase === "confirm" ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {phase === "confirm" && (
+
+
+ {submitting ? (
+ <>
+ Starting…
+
+ >
+ ) : (
+ <>
+ Confirm
+
+ >
+ )}
+
+
+ )}
+
+
+ {phase === "research" && (
+
+
+
+ )}
+
+
+
+ )
+}
+
+function ConfirmBody({
+ firstName,
+ name,
+ avatarUrl,
+ domain,
+ onDomainChange,
+ onConfirm,
+ submitting,
+}: {
+ firstName: string
+ name: string
+ avatarUrl: string | null
+ domain: string
+ onDomainChange: (v: string) => void
+ onConfirm: () => void
+ submitting: boolean
+}) {
+ return (
+ <>
+
+
+
+
+ {firstName ? `Hey ${firstName} 👋` : "Hey there 👋"}
+
+
+ I'll research your company and set up its Brain.
+
+
+
+
+
+
Company domain
+
+
+
+
+
onDomainChange(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && !submitting) onConfirm()
+ }}
+ placeholder="your-team.com"
+ spellCheck={false}
+ autoComplete="off"
+ className={cn(inputClass, "pl-14")}
+ style={inputBevelStyle}
+ />
+
+
+ >
+ )
+}
+
+function DockedHeader({
+ domain,
+ done,
+ onContinue,
+}: {
+ domain: string
+ done: boolean
+ onContinue: () => void
+}) {
+ const brandName = workspaceNameFromDomain(domain) || domain
+ return (
+
+
+
+
+
+ {brandName}
+
+
+ {done ? "Company Brain ready" : "Building your Company Brain…"}
+
+ {done ? (
+
+ Continue
+
+
+ ) : (
+
+ )}
+
+ )
+}
+
+function ResearchTranscript() {
+ const { status, events } = useResearchStatus()
+ const running = status !== "done"
+ const scrollRef = useRef(null)
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new events
+ useEffect(() => {
+ scrollRef.current?.scrollTo({
+ top: scrollRef.current.scrollHeight,
+ behavior: "smooth",
+ })
+ }, [events.length])
+
+ return (
+
+
+
+
+
+ {/* Top-only overlay in the card color: text slides up under a solid edge. */}
+
+
+
+ )
+}
+
+function Timeline({
+ events,
+ running,
+}: {
+ events: ResearchEvent[]
+ running: boolean
+}) {
+ if (events.length === 0) {
+ return (
+
+
+ Starting deep research…
+
+ )
+ }
+
+ return (
+
+
+ {events.map((e, i) => {
+ const isLast = i === events.length - 1
+ const active = running && e.status === "in_progress"
+ return (
+
+
+
+ {!isLast && (
+
+ )}
+
+
+
+ {e.label}
+
+ {e.detail && e.status !== "in_progress" && (
+
+
+
+ )}
+ {e.status === "complete" && e.stats.length > 0 && (
+
+ )}
+ {e.status === "complete" && e.highlights.length > 0 && (
+
+ )}
+ {e.status === "complete" && e.sources.length > 0 && (
+
+ )}
+ {active && }
+
+
+ )
+ })}
+
+
+ )
+}
+
+// grok inlines citations as `[[1]](url)` (sometimes consecutive). Render them
+// as compact superscript links and collapse any leftover `[n]` bare markers.
+const CITE_RE = /\[\[(\d+)\]\]\((https?:\/\/[^)\s]+)\)/g
+
+function CitedText({ text }: { text: string }) {
+ const nodes: ReactNode[] = []
+ let last = 0
+ let m: RegExpExecArray | null
+ CITE_RE.lastIndex = 0
+ // biome-ignore lint/suspicious/noAssignInExpressions: regex walk
+ while ((m = CITE_RE.exec(text)) !== null) {
+ if (m.index > last) {
+ nodes.push(cleanBareCites(text.slice(last, m.index)))
+ }
+ nodes.push(
+
+ {m[1]}
+ ,
+ )
+ last = m.index + m[0].length
+ }
+ if (last < text.length) nodes.push(cleanBareCites(text.slice(last)))
+ return <>{nodes}>
+}
+
+function cleanBareCites(s: string): string {
+ return s.replace(/\[\[?\d+\]\]?/g, "").replace(/\s{2,}/g, " ")
+}
+
+const THINKING_PHRASES = [
+ "Searching the web…",
+ "Reading sources…",
+ "Cross-checking facts…",
+ "Summarizing findings…",
+]
+
+function ThinkingLine() {
+ const [i, setI] = useState(0)
+ useEffect(() => {
+ const t = setInterval(
+ () => setI((v) => (v + 1) % THINKING_PHRASES.length),
+ 1600,
+ )
+ return () => clearInterval(t)
+ }, [])
+ return (
+
+ {THINKING_PHRASES[i]}
+
+ )
+}
+
+// Boxless stat strip: values over tiny labels, split by hairline dividers.
+function StatStrip({ stats }: { stats: ResearchStat[] }) {
+ const shown = stats.slice(0, 3)
+ if (!shown.length) return null
+ return (
+
+ {shown.map((s, i) => (
+ 0 && "border-l border-[rgba(82,89,102,0.2)] pl-5",
+ )}
+ >
+
+ {s.value}
+
+
+ {s.label}
+
+
+ ))}
+
+ )
+}
+
+// Quiet outline chips, short entities only (no sentences).
+function Chips({ items }: { items: string[] }) {
+ const shown = items.filter((t) => t.length <= 42).slice(0, 4)
+ if (!shown.length) return null
+ return (
+
+ {shown.map((t) => (
+
+ {t}
+
+ ))}
+
+ )
+}
+
+function hostname(url: string): string {
+ try {
+ return new URL(url).hostname.replace(/^www\./, "")
+ } catch {
+ return url.replace(/^https?:\/\//, "").replace(/\/.*$/, "")
+ }
+}
+
+function SourceFavicon({ host }: { host: string }) {
+ const sources = [
+ `https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${host}&size=32`,
+ `https://icons.duckduckgo.com/ip3/${host}.ico`,
+ ]
+ const [idx, setIdx] = useState(0)
+ if (idx >= sources.length) {
+ return
+ }
+ return (
+ setIdx((i) => i + 1)}
+ />
+ )
+}
+
+// Minimal, deduped source row: favicon + muted domain, no boxes.
+function SourceChips({ urls }: { urls: string[] }) {
+ const seen = new Set()
+ const hosts: { host: string; url: string }[] = []
+ for (const url of urls) {
+ const host = hostname(url)
+ if (seen.has(host)) continue
+ seen.add(host)
+ hosts.push({ host, url })
+ }
+ const shown = hosts.slice(0, 4)
+ const extra = hosts.length - shown.length
+ if (!shown.length) return null
+ return (
+
+
+ Sources
+
+ {shown.map(({ host, url }) => (
+
+
+
+
+ {host}
+
+ ))}
+ {extra > 0 && (
+ +{extra}
+ )}
+
+ )
+}
+
+// All dots render in a 14px box so their centers align regardless of shape.
+function EventDot({ status, active }: { status: string; active: boolean }) {
+ if (active) {
+ return
+ }
+ if (status === "complete") {
+ return (
+
+
+
+ )
+ }
+ return (
+
+
+
+ )
+}
+
+function Backdrop() {
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/apps/web/components/onboarding-brain/onboarding-confetti.tsx b/apps/web/components/onboarding-brain/onboarding-confetti.tsx
new file mode 100644
index 00000000..2aac5ac6
--- /dev/null
+++ b/apps/web/components/onboarding-brain/onboarding-confetti.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+import { useQueryState } from "nuqs"
+
+const COLORS = ["#4BA0FA", "#FF8A47", "#B19CFF", "#10A37F", "#fafafa"]
+
+export function OnboardingConfetti() {
+ const [onboarded, setOnboarded] = useQueryState("onboarded")
+ const fired = useRef(false)
+
+ useEffect(() => {
+ if (onboarded !== "1" || fired.current) return
+ fired.current = true
+ setOnboarded(null)
+
+ const reduceMotion = window.matchMedia?.(
+ "(prefers-reduced-motion: reduce)",
+ ).matches
+ if (reduceMotion) return
+
+ let raf = 0
+ const run = async () => {
+ const confetti = (await import("canvas-confetti")).default
+ const end = Date.now() + 1400
+ const frame = () => {
+ confetti({
+ particleCount: 4,
+ angle: 60,
+ spread: 70,
+ startVelocity: 55,
+ origin: { x: 0, y: 0.7 },
+ colors: COLORS,
+ })
+ confetti({
+ particleCount: 4,
+ angle: 120,
+ spread: 70,
+ startVelocity: 55,
+ origin: { x: 1, y: 0.7 },
+ colors: COLORS,
+ })
+ if (Date.now() < end) raf = requestAnimationFrame(frame)
+ }
+ frame()
+ }
+ run()
+
+ return () => cancelAnimationFrame(raf)
+ }, [onboarded, setOnboarded])
+
+ return null
+}
diff --git a/apps/web/components/onboarding-brain/research-action-rail.tsx b/apps/web/components/onboarding-brain/research-action-rail.tsx
new file mode 100644
index 00000000..9033d5e1
--- /dev/null
+++ b/apps/web/components/onboarding-brain/research-action-rail.tsx
@@ -0,0 +1,777 @@
+"use client"
+
+import { authClient } from "@lib/auth"
+import { useAuth } from "@lib/auth-context"
+import { cn } from "@lib/utils"
+import { Button } from "@ui/components/button"
+import { Input } from "@ui/components/input"
+import { AnimatePresence, motion } from "motion/react"
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { ArrowRight, Check, Loader2, Mail, Plus, Trash2 } from "lucide-react"
+import { toast } from "sonner"
+import {
+ brainConnectorIcon,
+ SlackMark,
+} from "@/components/brain-connector-icons"
+import { useSettingsModal } from "@/components/settings/settings-modal"
+import { useResearchStatus } from "@/hooks/use-research-status"
+import { dmSans125ClassName } from "@/lib/fonts"
+import { cardSurfaceStyle, inputBevelStyle, inputClass } from "./step-about"
+
+const BACKEND =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+const MCP_BASE = `${BACKEND}/brain/mcp-connections`
+
+const FEATURED_SLUGS = ["linear", "granola", "sentry"] as const
+const SETUP_STEPS = ["slack", "apps", "invite"] as const
+type SetupStepId = (typeof SETUP_STEPS)[number]
+
+const ROTATE_MS = 35_000
+const REVEAL_DELAY_MS = 1_800
+
+const tileStyle = {
+ boxShadow:
+ "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
+}
+
+const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi
+
+type AuthType = "oauth" | "static" | "none"
+type CatalogEntry = {
+ slug: string
+ name: string
+ category: string
+ authType: AuthType
+ tokenHint?: string
+}
+type ConnRow = {
+ serverSlug: string
+ status: "active" | "pending" | "error"
+ userId: string | null
+}
+
+export type ParallelSetupStats = {
+ slackConnected: boolean
+ appsConnected: number
+ invitesSent: number
+}
+
+const STEP_META: Record = {
+ slack: {
+ label: "Add to Slack",
+ hint: "Ask your company brain in-channel.",
+ },
+ apps: {
+ label: "Connect apps",
+ hint: "",
+ },
+ invite: {
+ label: "Invite teammates",
+ hint: "Multiply what the brain remembers.",
+ },
+}
+
+function titleCase(s: string) {
+ return s.replace(/\b\w/g, (c) => c.toUpperCase())
+}
+
+export function ResearchActionRail({
+ domain,
+ onStatsChange,
+}: {
+ domain: string
+ onStatsChange?: (stats: ParallelSetupStats) => void
+}) {
+ const { org } = useAuth()
+ const { openSettings } = useSettingsModal()
+ const { events } = useResearchStatus()
+ const [catalog, setCatalog] = useState(null)
+ const [rows, setRows] = useState([])
+ const [slack, setSlack] = useState<{
+ connected: boolean
+ teamName: string | null
+ } | null>(null)
+ const [busy, setBusy] = useState(null)
+ const [invites, setInvites] = useState<{ email: string }[]>([])
+ const [draft, setDraft] = useState("")
+ const [invitesSent, setInvitesSent] = useState(0)
+ const [sendingInvites, setSendingInvites] = useState(false)
+ const [spotlight, setSpotlight] = useState("slack")
+ const [rotationPaused, setRotationPaused] = useState(false)
+ const [minDelayPassed, setMinDelayPassed] = useState(false)
+ const pauseTimerRef = useRef | null>(null)
+
+ const domainOrFallback = (domain || "your-team.com").trim().toLowerCase()
+
+ const load = useCallback(async () => {
+ try {
+ const [cat, conn, s] = await Promise.all([
+ fetch(`${MCP_BASE}/catalog`, { credentials: "include" }),
+ fetch(`${MCP_BASE}/`, { credentials: "include" }),
+ fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
+ ])
+ try {
+ if (cat.ok) {
+ const data: { catalog?: CatalogEntry[] } = await cat.json()
+ setCatalog(data.catalog ?? [])
+ } else setCatalog([])
+ } catch {
+ setCatalog([])
+ }
+ try {
+ if (conn.ok) {
+ const data: { connections?: ConnRow[] } = await conn.json()
+ setRows(data.connections ?? [])
+ } else setRows([])
+ } catch {
+ setRows([])
+ }
+ try {
+ if (s.ok) setSlack(await s.json())
+ } catch {}
+ } catch {
+ setCatalog([])
+ setRows([])
+ }
+ }, [])
+
+ useEffect(() => {
+ void load()
+ const onFocus = () => void load()
+ window.addEventListener("focus", onFocus)
+ return () => window.removeEventListener("focus", onFocus)
+ }, [load])
+
+ useEffect(() => {
+ const t = window.setTimeout(() => setMinDelayPassed(true), REVEAL_DELAY_MS)
+ return () => window.clearTimeout(t)
+ }, [])
+
+ const isConnected = useCallback(
+ (slug: string) =>
+ rows.some((r) => r.serverSlug === slug && r.status === "active"),
+ [rows],
+ )
+
+ const apps = catalog ?? []
+ const featured = FEATURED_SLUGS.map((slug) =>
+ apps.find((a) => a.slug === slug),
+ ).filter((a): a is CatalogEntry => Boolean(a))
+ const appsConnected = apps.filter((a) => isConnected(a.slug)).length
+ const slackConnected = slack?.connected ?? false
+
+ const stepDone = useCallback(
+ (id: SetupStepId) => {
+ if (id === "slack") return slackConnected
+ if (id === "apps") return appsConnected > 0
+ return invitesSent > 0
+ },
+ [slackConnected, appsConnected, invitesSent],
+ )
+
+ const incompleteSteps = useMemo(
+ () => SETUP_STEPS.filter((id) => !stepDone(id)),
+ [stepDone],
+ )
+
+ const pauseRotation = useCallback((ms = 60_000) => {
+ setRotationPaused(true)
+ if (pauseTimerRef.current) clearTimeout(pauseTimerRef.current)
+ pauseTimerRef.current = setTimeout(() => setRotationPaused(false), ms)
+ }, [])
+
+ useEffect(() => {
+ return () => {
+ if (pauseTimerRef.current) clearTimeout(pauseTimerRef.current)
+ }
+ }, [])
+
+ useEffect(() => {
+ if (rotationPaused || incompleteSteps.length === 0) return
+ if (!incompleteSteps.includes(spotlight)) {
+ setSpotlight(incompleteSteps[0] ?? "slack")
+ }
+ const timer = window.setInterval(() => {
+ setSpotlight((prev) => {
+ const pool = SETUP_STEPS.filter((id) => !stepDone(id))
+ if (pool.length === 0) return prev
+ const idx = pool.indexOf(prev)
+ return pool[(idx + 1) % pool.length] ?? pool[0] ?? "slack"
+ })
+ }, ROTATE_MS)
+ return () => window.clearInterval(timer)
+ }, [rotationPaused, incompleteSteps, spotlight, stepDone])
+
+ useEffect(() => {
+ onStatsChange?.({
+ slackConnected,
+ appsConnected,
+ invitesSent,
+ })
+ }, [slackConnected, appsConnected, invitesSent, onStatsChange])
+
+ const setupBeatDone = events.some(
+ (e) => e.aspect === "prepare" && e.status === "complete",
+ )
+ const revealed = minDelayPassed && (setupBeatDone || events.length >= 2)
+
+ const focusStep = (id: SetupStepId) => {
+ setSpotlight(id)
+ pauseRotation()
+ }
+
+ const connect = async (entry: CatalogEntry) => {
+ pauseRotation()
+ setBusy(entry.slug)
+ try {
+ if (entry.authType === "static") {
+ const token = window.prompt(
+ `Paste a token for ${entry.name}.${entry.tokenHint ? `\n${entry.tokenHint}` : ""}`,
+ )
+ if (!token) return
+ const res = await fetch(`${MCP_BASE}/${entry.slug}/connect-static`, {
+ method: "POST",
+ credentials: "include",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ token, shared: false }),
+ })
+ if (!res.ok) {
+ toast.error("Couldn't connect.")
+ return
+ }
+ toast.success(`${entry.name} connected.`)
+ await load()
+ return
+ }
+ const res = await fetch(`${MCP_BASE}/${entry.slug}/connect`, {
+ method: "POST",
+ credentials: "include",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ shared: false,
+ redirectUrl: window.location.href,
+ }),
+ })
+ if (!res.ok) {
+ toast.error("Couldn't start the connection.")
+ return
+ }
+ const data: { authUrl?: string; ok?: boolean } = await res.json()
+ if (data.authUrl) window.open(data.authUrl, "_blank", "noopener")
+ else if (data.ok) {
+ toast.success(`${entry.name} connected.`)
+ await load()
+ } else toast.error("Couldn't start the connection.")
+ } catch {
+ toast.error("Couldn't start the connection.")
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ const addInvites = (text: string) => {
+ const found = text.match(EMAIL_RE) ?? []
+ if (found.length === 0) return
+ const existing = new Set(invites.map((i) => i.email.toLowerCase()))
+ const next: { email: string }[] = []
+ for (const raw of found) {
+ const email = raw.trim().toLowerCase()
+ if (!email || existing.has(email)) continue
+ existing.add(email)
+ next.push({ email })
+ }
+ if (next.length === 0) {
+ setDraft("")
+ return
+ }
+ setInvites((prev) => [...prev, ...next])
+ setDraft("")
+ }
+
+ const sendInvites = async () => {
+ if (sendingInvites || invites.length === 0) return
+ if (!org?.id) {
+ toast.error("Organization isn't ready yet.")
+ return
+ }
+ setSendingInvites(true)
+ try {
+ const results = await Promise.allSettled(
+ invites.map((inv) =>
+ authClient.organization.inviteMember({
+ email: inv.email,
+ role: "member",
+ organizationId: org.id,
+ resend: true,
+ }),
+ ),
+ )
+ const failed = results.filter(
+ (r) =>
+ r.status === "rejected" ||
+ (r.status === "fulfilled" && Boolean(r.value?.error)),
+ ).length
+ const sent = invites.length - failed
+ if (sent > 0) {
+ setInvitesSent((n) => n + sent)
+ setInvites([])
+ toast.success(
+ `Sent ${sent} invite${sent === 1 ? "" : "s"} to your team.`,
+ )
+ }
+ if (failed > 0) {
+ toast.error(
+ `${failed} invite${failed === 1 ? "" : "s"} couldn't be sent.`,
+ )
+ }
+ } catch {
+ toast.error("Couldn't send invites. Try again in a moment.")
+ } finally {
+ setSendingInvites(false)
+ }
+ }
+
+ const doneSummary = (id: SetupStepId) => {
+ if (id === "slack") return slack?.teamName ?? "Connected"
+ if (id === "apps") return `${appsConnected} connected`
+ return `${invitesSent} sent`
+ }
+
+ return (
+
+
+
+ While we research
+
+
+ {!revealed ? (
+
+ {SETUP_STEPS.map((id, i) => {
+ const isLast = i === SETUP_STEPS.length - 1
+ const meta = STEP_META[id]
+ return (
+
+
+
+
+
+ {!isLast && (
+
+ )}
+
+
+ {meta.label}
+
+
+ )
+ })}
+
+ ) : (
+
+ {SETUP_STEPS.map((id, i) => {
+ const done = stepDone(id)
+ const active = !done && spotlight === id
+ const isLast = i === SETUP_STEPS.length - 1
+ const meta = STEP_META[id]
+
+ return (
+
+
+
+ {!isLast && (
+
+ )}
+
+
+
+
focusStep(id)}
+ className="w-full text-left"
+ >
+
+ {meta.label}
+
+ {done && (
+
+ {doneSummary(id)}
+
+ )}
+
+
+
+ {active && (
+
+ {meta.hint ? (
+
+ {meta.hint}
+
+ ) : null}
+
+
+ {id === "slack" && (
+
+ )}
+ {id === "apps" && (
+
{
+ pauseRotation()
+ openSettings("company-brain")
+ }}
+ />
+ )}
+ {id === "invite" && (
+
+ setInvites((prev) =>
+ prev.filter((i) => i.email !== email),
+ )
+ }
+ onSendInvites={sendInvites}
+ onFocus={() => pauseRotation()}
+ />
+ )}
+
+
+ )}
+
+
+
+ )
+ })}
+
+ )}
+
+
+ )
+}
+
+function SetupDot({ done, active }: { done: boolean; active: boolean }) {
+ if (done) {
+ return (
+
+
+
+ )
+ }
+ if (active) {
+ return (
+
+
+
+ )
+ }
+ return (
+
+
+
+ )
+}
+
+function SlackStepBody({
+ connected,
+ teamName,
+}: {
+ connected: boolean
+ teamName: string | null
+}) {
+ if (connected) {
+ return (
+
+ Live in {teamName ?? "your workspace"}
+
+ )
+ }
+ return (
+
+
+ Add to Slack
+
+ )
+}
+
+function AppsStepBody({
+ catalog,
+ featured,
+ isConnected,
+ busy,
+ onConnect,
+ onBrowse,
+}: {
+ catalog: CatalogEntry[] | null
+ featured: CatalogEntry[]
+ isConnected: (slug: string) => boolean
+ busy: string | null
+ onConnect: (entry: CatalogEntry) => void
+ onBrowse: () => void
+}) {
+ if (catalog === null) {
+ return (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ )
+ }
+
+ if (featured.length === 0) {
+ return (
+
+ Browse connections →
+
+ )
+ }
+
+ const allConnected = featured.every((e) => isConnected(e.slug))
+
+ return (
+
+
+ {featured.map((entry, i) => (
+
onConnect(entry)}
+ showDivider={i < featured.length - 1}
+ />
+ ))}
+
+
+ {allConnected ? "Browse more connections →" : "More connections →"}
+
+
+ )
+}
+
+function InviteStepBody({
+ domain,
+ draft,
+ invites,
+ invitesSent,
+ sendingInvites,
+ onDraftChange,
+ onAddInvites,
+ onRemoveInvite,
+ onSendInvites,
+ onFocus,
+}: {
+ domain: string
+ draft: string
+ invites: { email: string }[]
+ invitesSent: number
+ sendingInvites: boolean
+ onDraftChange: (v: string) => void
+ onAddInvites: (text: string) => void
+ onRemoveInvite: (email: string) => void
+ onSendInvites: () => void
+ onFocus: () => void
+}) {
+ return (
+
+
+ {invites.length > 0 && (
+
+ {invites.map((inv) => (
+
+
+ {inv.email}
+
+ onRemoveInvite(inv.email)}
+ className="text-[#525D6E] hover:text-[#A1A1AA] p-0.5"
+ aria-label={`Remove ${inv.email}`}
+ >
+
+
+
+ ))}
+
+ {sendingInvites ? (
+ <>
+ Sending…
+
+ >
+ ) : (
+ <>
+ Send {invites.length} invite
+ {invites.length === 1 ? "" : "s"}
+
+ >
+ )}
+
+
+ )}
+ {invitesSent > 0 && invites.length === 0 && (
+
+ {invitesSent} invite{invitesSent === 1 ? "" : "s"} sent
+
+ )}
+
+ )
+}
+
+function AppTile({
+ icon,
+ name,
+ subtitle,
+ connected,
+ busy,
+ onConnect,
+ showDivider = false,
+}: {
+ icon: React.ReactNode
+ name: string
+ subtitle: string
+ connected: boolean
+ busy: boolean
+ onConnect: () => void
+ showDivider?: boolean
+}) {
+ return (
+
+
+ {icon}
+
+
+
+ {name}
+
+
+ {subtitle}
+
+
+ {connected ? (
+
+ ) : (
+
+ {busy ? : "Connect"}
+
+ )}
+
+ )
+}
diff --git a/apps/web/components/onboarding-brain/shell.tsx b/apps/web/components/onboarding-brain/shell.tsx
new file mode 100644
index 00000000..f2eb9f8a
--- /dev/null
+++ b/apps/web/components/onboarding-brain/shell.tsx
@@ -0,0 +1,152 @@
+"use client"
+
+import { LogoFull } from "@ui/assets/Logo"
+import { cn } from "@lib/utils"
+import { dmSansClassName } from "@/lib/fonts"
+import { motion } from "motion/react"
+import { BRAIN_STEPS, BRAIN_STEP_LABELS, type BrainStep } from "./types"
+
+interface ShellProps {
+ step: BrainStep
+ domain?: string | null
+ steps?: BrainStep[]
+ children: React.ReactNode
+}
+
+export function BrainShell({ step, steps, children }: ShellProps) {
+ const visibleSteps: BrainStep[] = steps ?? BRAIN_STEPS
+
+ return (
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+ )
+}
+
+function StepIndicator({
+ step,
+ visibleSteps,
+}: {
+ step: BrainStep
+ visibleSteps: BrainStep[]
+}) {
+ const currentIdx = visibleSteps.indexOf(step)
+ return (
+
+ {visibleSteps.map((s, i) => {
+ const isDone = i < currentIdx
+ const isCurrent = i === currentIdx
+ const isLast = i === visibleSteps.length - 1
+ return (
+
+
+
+
+ {BRAIN_STEP_LABELS[s]}
+
+
+ {!isLast && (
+
+
+
+ )}
+
+ )
+ })}
+
+ )
+}
+
+function StepDot({ done, current }: { done: boolean; current: boolean }) {
+ if (current) {
+ return (
+
+
+
+
+ )
+ }
+ if (done) {
+ return (
+
+
+
+ )
+ }
+ return (
+
+
+
+ )
+}
diff --git a/apps/web/components/onboarding-brain/step-about.tsx b/apps/web/components/onboarding-brain/step-about.tsx
new file mode 100644
index 00000000..22db1387
--- /dev/null
+++ b/apps/web/components/onboarding-brain/step-about.tsx
@@ -0,0 +1,583 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+import { AnimatePresence, motion } from "motion/react"
+import { Button } from "@ui/components/button"
+import { Input } from "@ui/components/input"
+import { Textarea } from "@ui/components/textarea"
+import {
+ ArrowRight,
+ Brain,
+ Building2,
+ LayoutGrid,
+ Loader2,
+ Mail,
+ Plug,
+ Terminal,
+ UserPlus,
+ Users2,
+ Wand2,
+} from "lucide-react"
+import { cn } from "@lib/utils"
+import { dmSans125ClassName } from "@/lib/fonts"
+import type { BrainMode } from "./types"
+
+const BACKEND =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+
+export interface AboutValues {
+ name: string
+ about: string
+ workspaceName: string
+ workspaceDomain: string
+}
+
+interface Props {
+ mode: BrainMode
+ onModeChange: (m: BrainMode) => void
+ allowTeam: boolean
+ domain: string | null
+ suggestedWorkspaceName: string
+ defaultName: string
+ avatarUrl: string | null
+ values: AboutValues
+ onChange: (next: AboutValues) => void
+ onContinue: () => void
+ submitting?: boolean
+}
+
+export const cardSurfaceStyle = {
+ boxShadow:
+ "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
+}
+
+export const inputBevelStyle = {
+ boxShadow:
+ "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
+}
+
+export const fieldLabel = "pl-2 pb-2 font-semibold text-[14px] text-[#737373]"
+export const inputClass =
+ "bg-[#0F1217] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#525D6E] h-12 px-4 shadow-none focus-visible:ring-0 focus-visible:border-[rgba(115,115,115,0.3)] transition-colors"
+
+export function StepAbout({
+ mode,
+ onModeChange,
+ allowTeam,
+ domain,
+ suggestedWorkspaceName,
+ defaultName,
+ avatarUrl,
+ values,
+ onChange,
+ onContinue,
+ submitting,
+}: Props) {
+ // biome-ignore lint/correctness/useExhaustiveDependencies: one-time initialization when defaults become available
+ useEffect(() => {
+ const patch: Partial = {}
+ if (!values.name && defaultName) patch.name = defaultName
+ if (!values.workspaceName && suggestedWorkspaceName) {
+ patch.workspaceName = suggestedWorkspaceName
+ }
+ if (!values.workspaceDomain && domain) {
+ patch.workspaceDomain = domain
+ }
+ if (Object.keys(patch).length > 0) onChange({ ...values, ...patch })
+ }, [defaultName, suggestedWorkspaceName, domain])
+
+ const teamGated = mode === "team" && !allowTeam
+ const isTeam = mode === "team" && !teamGated
+
+ // Default the workspace name per mode: name-derived for Personal (field hidden),
+ // email-derived for Team unless the user has typed their own.
+ const workspaceTouched = useRef(false)
+ // biome-ignore lint/correctness/useExhaustiveDependencies: derive on mode/name changes only
+ useEffect(() => {
+ if (mode === "personal") {
+ const first = values.name.trim().split(/\s+/)[0]
+ const derived = first
+ ? `${first}'s Brain`
+ : suggestedWorkspaceName || "My brain"
+ if (values.workspaceName !== derived) {
+ onChange({ ...values, workspaceName: derived })
+ }
+ } else if (!workspaceTouched.current) {
+ const derived = suggestedWorkspaceName || ""
+ if (values.workspaceName !== derived) {
+ onChange({ ...values, workspaceName: derived })
+ }
+ }
+ }, [mode, values.name, suggestedWorkspaceName])
+
+ // Auto-draft the "what does your company do" blurb from the domain.
+ const valuesRef = useRef(values)
+ valuesRef.current = values
+ const aboutTouched = useRef(false)
+ const summarizedDomain = useRef(null)
+ const latestDraftDomain = useRef(null)
+ const [drafting, setDrafting] = useState(false)
+ const [drafted, setDrafted] = useState(false)
+
+ const draftCompany = async (rawDomain: string) => {
+ const d = rawDomain.trim().toLowerCase()
+ if (!d || summarizedDomain.current === d) return
+ if (aboutTouched.current && valuesRef.current.about.trim()) return
+ summarizedDomain.current = d
+ latestDraftDomain.current = d
+ setDrafting(true)
+ try {
+ const res = await fetch(`${BACKEND}/brain/company-summary`, {
+ method: "POST",
+ credentials: "include",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ domain: d }),
+ })
+ // Ignore a stale response if the domain changed mid-flight.
+ if (latestDraftDomain.current !== d) return
+ if (!res.ok) return
+ const data = (await res.json()) as { summary?: string | null }
+ if (
+ data.summary &&
+ !aboutTouched.current &&
+ latestDraftDomain.current === d
+ ) {
+ onChange({ ...valuesRef.current, about: data.summary })
+ setDrafted(true)
+ }
+ } catch {
+ if (latestDraftDomain.current === d) summarizedDomain.current = null
+ } finally {
+ if (latestDraftDomain.current === d) setDrafting(false)
+ }
+ }
+
+ // Draft once when entering Team with a domain already filled (e.g. from email).
+ // biome-ignore lint/correctness/useExhaustiveDependencies: run on team-entry only
+ useEffect(() => {
+ if (!isTeam) return
+ const d = (values.workspaceDomain || domain || "").trim()
+ if (d && !values.about.trim()) draftCompany(d)
+ }, [isTeam])
+
+ const canContinue =
+ !teamGated &&
+ values.name.trim().length > 0 &&
+ values.workspaceName.trim().length > 0
+
+ return (
+
+
+
+
+
+
+
+
+ Tell us about you
+
+
+ So your brain sounds like yours, not the docs.
+
+
+
+
+
+
+
+
Your name
+
onChange({ ...values, name: e.target.value })}
+ placeholder="e.g. Mahesh"
+ className={inputClass}
+ style={inputBevelStyle}
+ />
+
+
+ {isTeam && (
+
+
Workspace name
+
{
+ workspaceTouched.current = true
+ onChange({ ...values, workspaceName: e.target.value })
+ }}
+ placeholder={suggestedWorkspaceName || "Acme"}
+ className={inputClass}
+ style={inputBevelStyle}
+ />
+
+ )}
+
+
+ {/* Company domain slides in only for Team mode. */}
+
+ {isTeam && (
+
+ Company domain
+
+
+ {values.workspaceDomain || domain ? (
+
+ ) : (
+
+ )}
+
+
+ onChange({ ...values, workspaceDomain: e.target.value })
+ }
+ onBlur={(e) => {
+ const d = e.target.value.trim()
+ if (d !== values.workspaceDomain) {
+ onChange({ ...values, workspaceDomain: d })
+ }
+ draftCompany(d)
+ }}
+ placeholder="your-team.com"
+ className={cn(inputClass, "pl-14")}
+ style={inputBevelStyle}
+ />
+
+
+ )}
+
+
+ {!teamGated && (
+
+
+
+ {isTeam ? (
+ "What does your company do?"
+ ) : (
+ <>
+ What are you here for?{" "}
+
+ (optional)
+
+ >
+ )}
+
+ {isTeam &&
+ (drafting ? (
+
+
+ Drafting from your site…
+
+ ) : drafted ? (
+
+
+ Drafted from your site · edit anything
+
+ ) : null)}
+
+
+ )}
+
+
+ {teamGated ? (
+
+ onModeChange("personal")} />
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {submitting ? (
+ <>
+ Creating…
+
+ >
+ ) : (
+ <>
+ Continue
+
+ >
+ )}
+
+
+
+ )
+}
+
+const PERSONAL_PERKS: Perk[] = [
+ {
+ icon: ,
+ title: "Your own brain",
+ blurb: "Notes, docs, bookmarks — all searchable in one place.",
+ },
+ {
+ icon: ,
+ title: "Plug into your AI tools",
+ blurb: "Claude, Cursor, ChatGPT — your context, everywhere.",
+ },
+ {
+ icon: ,
+ title: "Switch to a team anytime",
+ blurb: "Invite teammates whenever you're ready.",
+ },
+]
+
+const TEAM_PERKS: Perk[] = [
+ {
+ icon: ,
+ title: "Invite teammates",
+ blurb: "Everyone contributes to the same brain.",
+ },
+ {
+ icon: ,
+ title: "Shared coding agent context",
+ blurb: "Claude, Cursor, MCP — same brain across the team.",
+ },
+ {
+ icon: ,
+ title: "Org-wide spaces",
+ blurb: "Carve out sales, eng, design with their own access.",
+ },
+]
+
+export function DomainLogo({ domain }: { domain: string }) {
+ const sources = [
+ `https://logo.clearbit.com/${domain}`,
+ `https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${domain}&size=64`,
+ `https://icons.duckduckgo.com/ip3/${domain}.ico`,
+ ]
+ const [idx, setIdx] = useState(0)
+ if (idx >= sources.length) {
+ return
+ }
+ return (
+ setIdx((i) => i + 1)}
+ />
+ )
+}
+
+function TeamBetaGate({ onUsePersonal }: { onUsePersonal: () => void }) {
+ return (
+
+
+
+ Private beta
+
+
+ Team workspaces are invite-only
+
+
+ We're onboarding teams to Company Brain one at a time. Email us for
+ access — or start with a personal workspace and invite your team later.
+
+
+
+ )
+}
+
+function ModeToggle({
+ mode,
+ onChange,
+}: {
+ mode: BrainMode
+ onChange: (m: BrainMode) => void
+}) {
+ const items: { id: BrainMode; label: string }[] = [
+ { id: "personal", label: "Personal" },
+ { id: "team", label: "Team" },
+ ]
+ return (
+
+
+ {items.map((item) => {
+ const isActive = mode === item.id
+ return (
+ onChange(item.id)}
+ className={cn(
+ "relative z-10 h-8 rounded-full text-center transition-colors duration-200",
+ isActive
+ ? "text-[#fafafa]"
+ : "text-[#737373] hover:text-[#fafafa]",
+ )}
+ >
+ {item.label}
+
+ )
+ })}
+
+ )
+}
+
+export function UserAvatar({
+ url,
+ name,
+ className,
+}: {
+ url: string | null
+ name: string
+ className?: string
+}) {
+ const [errored, setErrored] = useState(false)
+ const initial = (name?.trim()?.[0] ?? "?").toUpperCase()
+ const hasImage = url && !errored
+
+ return (
+
+ {hasImage ? (
+
setErrored(true)}
+ />
+ ) : (
+
+ {initial}
+
+ )}
+
+ )
+}
+
+type Perk = { icon: React.ReactNode; title: string; blurb: string }
+
+function PerksFooter({ perks }: { perks: Perk[] }) {
+ return (
+
+ {perks.map((p) => (
+
+ {p.icon}
+ {p.title}
+
+ ))}
+
+ )
+}
diff --git a/apps/web/components/onboarding-brain/step-ingest.tsx b/apps/web/components/onboarding-brain/step-ingest.tsx
new file mode 100644
index 00000000..bd65dcc5
--- /dev/null
+++ b/apps/web/components/onboarding-brain/step-ingest.tsx
@@ -0,0 +1,576 @@
+"use client"
+
+import { useEffect, useMemo, useState } from "react"
+import type { ReactNode } from "react"
+import Image from "next/image"
+import Link from "next/link"
+import { Button } from "@ui/components/button"
+import {
+ ArrowRight,
+ Check,
+ Copy,
+ ExternalLink,
+ Loader2,
+ Plug,
+} from "lucide-react"
+import { cn } from "@lib/utils"
+import { dmSans125ClassName } from "@/lib/fonts"
+import { toast } from "sonner"
+import { PLUGIN_CATALOG } from "@/lib/plugin-catalog"
+import { analytics } from "@/lib/analytics"
+import type { BrainMode } from "./types"
+
+const BACKEND =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+
+const TEST_PROMPT = "What do we know about [topic]?"
+
+type FlowToolId = "slack" | "mcp" | "codex" | "claude-code"
+type FlowToolKind = "slack" | "mcp" | "plugin"
+
+type FlowTool = {
+ id: FlowToolId
+ label: string
+ blurb: string
+ kind: FlowToolKind
+ pluginId?: string
+ recommended?: boolean
+}
+
+const TOOL_OPTIONS: Record = {
+ // Team/company-brain onboarding is focused: just get Supermemory into Slack.
+ // Coding agents live under "More tools (full catalog)".
+ team: [
+ {
+ id: "slack",
+ label: "Slack",
+ blurb: "Ask questions in-channel.",
+ kind: "slack",
+ recommended: true,
+ },
+ ],
+ personal: [
+ {
+ id: "mcp",
+ label: "MCP",
+ blurb: "Use the universal URL in any client.",
+ kind: "mcp",
+ recommended: true,
+ },
+ {
+ id: "codex",
+ label: "Codex",
+ blurb: "OpenAI's coding agent.",
+ kind: "plugin",
+ pluginId: "codex",
+ },
+ {
+ id: "claude-code",
+ label: "Claude Code",
+ blurb: "Context in your terminal.",
+ kind: "plugin",
+ pluginId: "claude_code",
+ },
+ ],
+}
+
+const modalCardStyle = {
+ boxShadow:
+ "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
+}
+
+const inputBevelStyle = {
+ boxShadow:
+ "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
+}
+
+interface Props {
+ mode: BrainMode
+ mcpUrl: string
+ onContinue: () => void
+}
+
+export function StepIngest({ mode, mcpUrl, onContinue }: Props) {
+ const tools = TOOL_OPTIONS[mode]
+ const [selected, setSelected] = useState(tools[0].id)
+
+ useEffect(() => {
+ setSelected(tools[0].id)
+ }, [tools])
+
+ const activeTool = useMemo(
+ () => tools.find((t) => t.id === selected) ?? tools[0],
+ [tools, selected],
+ )
+
+ const handleContinue = () => {
+ analytics.onboardingIngestCompleted()
+ onContinue()
+ }
+
+ const handleSkip = () => {
+ analytics.onboardingIngestSkipped()
+ onContinue()
+ }
+
+ return (
+
+
+
+
+ Use your brain where you work
+
+
+ {mode === "team"
+ ? "Add Supermemory to Slack so your team can ask in any channel."
+ : "Pick the tool you open every day — about 60 seconds."}
+
+
+
+
+ {tools.length > 1 ? (
+ <>
+
{
+ analytics.onboardingAgentSelected({ agent: id })
+ setSelected(id)
+ }}
+ />
+
+ {activeTool.blurb}
+
+ >
+ ) : (
+
+
+
+
+
+
+ Set up {activeTool.label}
+
+
+ {activeTool.blurb}
+
+
+
+ )}
+
+ {activeTool.kind === "slack" ? (
+
+ ) : activeTool.kind === "mcp" ? (
+
+ ) : activeTool.pluginId ? (
+
+ ) : null}
+
+ 1 ? "justify-between" : "justify-end",
+ )}
+ >
+ {tools.length > 1 && (
+
+ More tools
+
(full catalog)
+
+
+ )}
+
+
+ Skip for now
+
+
+ Continue
+
+
+
+
+
+
+
+ )
+}
+
+function ToolIcon({ id, className }: { id: FlowToolId; className?: string }) {
+ if (id === "slack") return
+ if (id === "mcp")
+ return
+ const file = id === "claude-code" ? "claude" : id
+ return (
+
+ )
+}
+
+function FlowTabs({
+ tools,
+ selected,
+ onSelect,
+}: {
+ tools: readonly FlowTool[]
+ selected: FlowToolId
+ onSelect: (id: FlowToolId) => void
+}) {
+ return (
+
+ {tools.map((tool) => {
+ const active = tool.id === selected
+ return (
+ onSelect(tool.id)}
+ aria-pressed={active}
+ className={cn(
+ "relative flex h-10 items-center justify-center gap-2 rounded-[10px] border border-transparent px-2 text-[13px] font-medium transition-colors",
+ active ? "text-[#fafafa]" : "text-[#737373] hover:text-[#fafafa]",
+ )}
+ style={
+ active
+ ? { background: "#00173C", borderColor: "#2261CA66" }
+ : undefined
+ }
+ >
+
+ {tool.label}
+
+ )
+ })}
+
+ )
+}
+
+function StepRow({
+ index,
+ title,
+ done,
+ children,
+ last,
+}: {
+ index: number
+ title: ReactNode
+ done?: boolean
+ children?: ReactNode
+ last?: boolean
+}) {
+ return (
+
+
+
+ {done ? : index}
+
+ {!last && (
+
+ )}
+
+
+
+ {title}
+
+ {children ?
{children}
: null}
+
+
+ )
+}
+
+// Coding-agent plugins (Codex, Claude Code) auto-login via OAuth, so the
+// "Save your API key" step is dropped — we render the remaining install steps.
+function PluginSetup({ pluginId }: { pluginId: string }) {
+ const plugin = PLUGIN_CATALOG[pluginId]
+ const steps = (plugin?.installSteps ?? []).filter(
+ (s) => !s.secret && !s.code?.includes("sm_..."),
+ )
+
+ return (
+
+ {steps.map((step, i) => (
+
+ {step.description ? (
+
+ {step.description}
+
+ ) : null}
+ {step.code ? : null}
+
+ ))}
+
+
+
+
+ )
+}
+
+function McpGenericSetup({ mcpUrl }: { mcpUrl: string }) {
+ return (
+
+
+
+
+
+
+ Per-client setup guides
+
+
+
+
+
+
+
+ )
+}
+
+function SlackSetupPanel() {
+ const [status, setStatus] = useState<{
+ connected: boolean
+ teamName: string | null
+ } | null>(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ let active = true
+ ;(async () => {
+ try {
+ const res = await fetch(`${BACKEND}/brain/slack/status`, {
+ credentials: "include",
+ })
+ if (active && res.ok) {
+ setStatus(
+ (await res.json()) as {
+ connected: boolean
+ teamName: string | null
+ },
+ )
+ }
+ } finally {
+ if (active) setLoading(false)
+ }
+ })()
+ return () => {
+ active = false
+ }
+ }, [])
+
+ const connected = status?.connected ?? false
+
+ return (
+
+
+
+ Add Supermemory to your Slack workspace
+
+ {loading ? (
+
+
+ Checking…
+
+ ) : (
+
+
+ Add to Slack
+
+ )}
+
+ )
+ }
+ />
+
+ Mention @supermemory in{" "}
+ #general
+ >
+ }
+ />
+
+
+
+
+ )
+}
+
+function CopyCodeBlock({ code }: { code: string }) {
+ const [copied, setCopied] = useState(false)
+
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(code)
+ setCopied(true)
+ toast.success("Copied")
+ setTimeout(() => setCopied(false), 1500)
+ } catch {
+ toast.error("Could not copy")
+ }
+ }
+
+ return (
+
+
+ {code}
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+function McpUrlRow({ url }: { url: string }) {
+ const [copied, setCopied] = useState(false)
+
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(url)
+ setCopied(true)
+ toast.success("MCP URL copied")
+ setTimeout(() => setCopied(false), 2000)
+ } catch {
+ toast.error("Could not copy")
+ }
+ }
+
+ return (
+
+
+
+ Universal MCP URL
+
+
+ {url}
+
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+ {copied ? "Copied" : "Copy URL"}
+
+
+ )
+}
+
+function SlackMark({ className }: { className?: string }) {
+ return (
+
+ Slack
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/components/onboarding-brain/step-sources.tsx b/apps/web/components/onboarding-brain/step-sources.tsx
new file mode 100644
index 00000000..5c63d1e2
--- /dev/null
+++ b/apps/web/components/onboarding-brain/step-sources.tsx
@@ -0,0 +1,1407 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+import { useQuery } from "@tanstack/react-query"
+import { useQueryState } from "nuqs"
+import Image from "next/image"
+import { Button } from "@ui/components/button"
+import { Dialog, DialogClose, DialogContent } from "@ui/components/dialog"
+import { GoogleDrive, Granola, Notion, OneDrive } from "@ui/assets/icons"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@ui/components/select"
+import {
+ AlertTriangle,
+ ArrowRight,
+ Check,
+ ChevronLeft,
+ ChevronRight,
+ Coins,
+ ExternalLink,
+ FolderOpen,
+ Github,
+ LoaderIcon,
+ Lock,
+ Plus,
+ X,
+} from "lucide-react"
+import {
+ AppleShortcutsIcon,
+ ChromeIcon,
+ RaycastIcon,
+} from "@/components/integration-icons"
+
+function XBookmarksIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ )
+}
+
+function GrokIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ )
+}
+
+function GmailIcon({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+
+
+ )
+}
+import { cn } from "@lib/utils"
+import {
+ PLAN_DISPLAY_NAMES,
+ PLAN_RANK,
+ type PlanType,
+ useTokenUsage,
+} from "@/hooks/use-token-usage"
+import { GranolaConnectModal } from "@/components/granola-connect-modal"
+import { dmSans125ClassName } from "@/lib/fonts"
+import { $fetch } from "@lib/api"
+import { useAuth } from "@lib/auth-context"
+import { useConnectorAccess } from "@/hooks/use-connector-access"
+import {
+ ADD_MEMORY_SHORTCUT_URL,
+ CHROME_EXTENSION_URL,
+ RAYCAST_EXTENSION_URL,
+} from "@lib/constants"
+import { useCustomer } from "autumn-js/react"
+import { toast } from "sonner"
+import { analytics } from "@/lib/analytics"
+import type { BrainMode } from "./types"
+
+type SourceId =
+ | "drive"
+ | "notion"
+ | "gmail"
+ | "github"
+ | "onedrive"
+ | "granola"
+ | "bookmarks"
+ | "chatapps"
+ | "chrome"
+ | "shortcuts"
+ | "raycast"
+type SourceState = "idle" | "connecting" | "connected" | "waitlist"
+type DriveScope = "selective" | "full"
+type RequiredPlan = "pro" | "max"
+
+const PROVIDER_TO_SOURCE: Record = {
+ "google-drive": "drive",
+ notion: "notion",
+ onedrive: "onedrive",
+ granola: "granola",
+}
+
+const SOURCE_LABEL: Partial> = {
+ drive: "Google Drive",
+ notion: "Notion",
+ onedrive: "OneDrive",
+ granola: "Granola",
+}
+
+const PLAN_LABELS: Record = {
+ pro: "Pro",
+ max: "Max",
+}
+
+const BOOK_CALL_HREF = "https://cal.com/maheshthedev/15min"
+
+type PlanCardDefinition = {
+ id: PlanType
+ name: string
+ price: string
+ period: string
+ credits: string
+ productId: "api_free" | "api_pro" | "api_max" | "api_scale" | "api_enterprise"
+ description: string
+ includesFrom?: string
+ features: string[]
+ isContactSales?: boolean
+ mostPopular?: boolean
+}
+
+type CheckoutPlanId = Extract<
+ PlanCardDefinition["productId"],
+ "api_pro" | "api_max" | "api_scale"
+>
+
+const PLAN_CARDS: PlanCardDefinition[] = [
+ {
+ id: "pro",
+ name: "Pro",
+ price: "$19",
+ period: "/mo",
+ credits: "$20",
+ productId: "api_pro",
+ description: "For people building with AI memory",
+ features: [
+ "Auto top-up when balance runs low",
+ "All plugins (Claude Code, Cursor, Hermes...)",
+ "Priority support",
+ ],
+ },
+ {
+ id: "max",
+ name: "Max",
+ price: "$100",
+ period: "/mo",
+ credits: "$130",
+ productId: "api_max",
+ description: "For power users who outgrow Pro",
+ includesFrom: "Pro",
+ mostPopular: true,
+ features: ["6x the credits of Pro", "Gmail connector", "Priority support"],
+ },
+ {
+ id: "scale",
+ name: "Scale",
+ price: "$399",
+ period: "/mo",
+ credits: "$600",
+ productId: "api_scale",
+ description: "For teams and production workloads",
+ includesFrom: "Max",
+ features: [
+ "Auto top-up & spend caps",
+ "S3 & Web Crawler connectors",
+ "Dedicated support",
+ ],
+ },
+ {
+ id: "enterprise",
+ name: "Enterprise",
+ price: "Custom",
+ period: "",
+ credits: "Unlimited",
+ productId: "api_enterprise",
+ description: "Custom deployments with dedicated engineering",
+ includesFrom: "Scale",
+ features: [
+ "Custom metering & billing",
+ "Custom integrations & SSO",
+ "Forward-deployed engineer",
+ ],
+ isContactSales: true,
+ },
+]
+
+const PLAN_CARD_SCROLL_STEP = 406
+
+const countsAsConnected = (state: SourceState | undefined) =>
+ state === "connected" || state === "waitlist"
+
+export interface SourcesValues {
+ connected: Partial>
+ driveScope: DriveScope
+}
+
+interface Props {
+ containerTag: string
+ mode: BrainMode
+ values: SourcesValues
+ onChange: (next: SourcesValues) => void
+ onContinue: () => void
+}
+
+const modalCardStyle = {
+ boxShadow:
+ "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
+}
+
+const inputBevelStyle = {
+ boxShadow:
+ "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
+}
+
+function ChatAppsIconCluster() {
+ const tileClass =
+ "absolute flex size-[28px] items-center justify-center rounded-[10px] border border-white/10 bg-[#0D1117] shadow-[0_4px_10px_rgba(0,0,0,0.28)]"
+ const imageClass = "size-4 object-contain"
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export function StepSources({
+ containerTag,
+ mode,
+ values,
+ onChange,
+ onContinue,
+}: Props) {
+ const [moreOpen, setMoreOpen] = useState(false)
+ const [plansOpen, setPlansOpen] = useState(false)
+ const [granolaOpen, setGranolaOpen] = useState(false)
+ const [requestedPlan, setRequestedPlan] = useState("pro")
+ const [requestedConnector, setRequestedConnector] = useState("This connector")
+ const { hasMax, connectorAccess, loading: planLoading } = useConnectorAccess()
+ const { org, isRestoring } = useAuth()
+
+ useEffect(() => {
+ setMoreOpen(false)
+ }, [])
+
+ const valuesRef = useRef(values)
+ valuesRef.current = values
+ // dedupe toasts across the param + reconcile paths
+ const announced = useRef(new Set())
+ const seeded = useRef(false)
+
+ const markConnected = (ids: SourceId[]) => {
+ const current = valuesRef.current
+ const updates: Partial> = {}
+ for (const id of ids) {
+ if (current.connected[id] !== "connected") updates[id] = "connected"
+ }
+ if (Object.keys(updates).length > 0) {
+ onChange({
+ ...current,
+ connected: { ...current.connected, ...updates },
+ })
+ }
+ }
+
+ // keyed by org so it re-runs once the active org restores on reload
+ const { data: liveConnections, refetch: refetchConnections } = useQuery({
+ queryKey: ["onboarding-connections", org?.id],
+ queryFn: async () => {
+ const res = await $fetch("@post/connections/list", {
+ body: { containerTags: [] },
+ })
+ if (res.error) return [] as Array<{ provider?: string }>
+ return (res.data ?? []) as Array<{ provider?: string }>
+ },
+ enabled: !isRestoring && !!org?.id,
+ staleTime: 10_000,
+ refetchOnWindowFocus: true,
+ })
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reconcile on fetched connections only
+ useEffect(() => {
+ if (!liveConnections) return
+ const ids = liveConnections
+ .map((c) => (c.provider ? PROVIDER_TO_SOURCE[c.provider] : undefined))
+ .filter((id): id is SourceId => Boolean(id))
+ markConnected(ids)
+ // first load: seed without toasting pre-existing connections
+ if (!seeded.current) {
+ seeded.current = true
+ for (const id of ids) announced.current.add(id)
+ return
+ }
+ for (const id of ids) {
+ if (!announced.current.has(id)) {
+ announced.current.add(id)
+ toast.success(`${SOURCE_LABEL[id] ?? "Source"} connected`)
+ }
+ }
+ }, [liveConnections])
+
+ // Post-OAuth redirect lands with ?connected= — confirm it instantly.
+ const [connectedParam, setConnectedParam] = useQueryState("connected")
+ // biome-ignore lint/correctness/useExhaustiveDependencies: run when the param arrives
+ useEffect(() => {
+ if (!connectedParam) return
+ const id = PROVIDER_TO_SOURCE[connectedParam]
+ if (id) {
+ markConnected([id])
+ if (!announced.current.has(id)) {
+ announced.current.add(id)
+ toast.success(`${SOURCE_LABEL[id] ?? "Source"} connected`)
+ }
+ }
+ setConnectedParam(null)
+ const t1 = setTimeout(() => void refetchConnections(), 1500)
+ const t2 = setTimeout(() => void refetchConnections(), 4000)
+ return () => {
+ clearTimeout(t1)
+ clearTimeout(t2)
+ }
+ }, [connectedParam])
+
+ // company_brain unlocks pro connectors; max stays gated
+ const isLocked = (plan?: RequiredPlan) => {
+ if (!plan || planLoading) return false
+ if (plan === "max") return !hasMax
+ return !connectorAccess
+ }
+
+ const setState = (id: SourceId, state: SourceState) => {
+ onChange({ ...values, connected: { ...values.connected, [id]: state } })
+ }
+
+ const connectRealProvider = async (
+ provider: "google-drive" | "notion" | "onedrive",
+ id: SourceId,
+ ) => {
+ analytics.onboardingIntegrationClicked({ integration: provider })
+ setState(id, "connecting")
+ try {
+ const metadata: Record = {}
+ if (provider === "google-drive") {
+ metadata.scope = values.driveScope
+ }
+ const res = await $fetch("@post/connections/:provider", {
+ params: { provider },
+ body: {
+ redirectUrl: window.location.href,
+ containerTags: [containerTag],
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
+ },
+ })
+ const data = "data" in res ? res.data : null
+ if (data && "authLink" in data && data.authLink) {
+ window.location.href = data.authLink
+ return
+ }
+ throw new Error("No auth link returned")
+ } catch (err) {
+ setState(id, "idle")
+ toast.error(
+ err instanceof Error ? err.message : "Could not start connection",
+ )
+ }
+ }
+
+ const openExternal = (id: SourceId, url: string) => {
+ analytics.onboardingIntegrationClicked({ integration: id })
+ window.open(url, "_blank", "noopener,noreferrer")
+ setState(id, "connected")
+ }
+
+ const requestWaitlist = (id: SourceId) => {
+ analytics.onboardingIntegrationClicked({ integration: id })
+ setState(id, "waitlist")
+ }
+
+ const guard = (
+ plan: RequiredPlan | undefined,
+ title: string,
+ fn: () => void,
+ ) => {
+ return () => {
+ if (isLocked(plan) && plan) {
+ setRequestedPlan(plan)
+ setRequestedConnector(title)
+ setPlansOpen(true)
+ return
+ }
+ fn()
+ }
+ }
+
+ const connectedCount = Object.values(values.connected).filter(
+ countsAsConnected,
+ ).length
+
+ const handleContinue = () => {
+ analytics.onboardingSourcesCompleted({ connected_count: connectedCount })
+ onContinue()
+ }
+
+ return (
+
+
+
+
+
+ {mode === "personal"
+ ? "Bring your context together"
+ : "Connect your team's signals"}
+
+
+ Start with the sources that carry the most context. Add more
+ anytime.
+
+
+
+
+ {mode === "personal" ? (
+ <>
+ }
+ state={values.connected.bookmarks ?? "idle"}
+ ctaLabel="Connect"
+ doneLabel="Opened"
+ perks={[
+ "Bookmarks become searchable memories",
+ "One-click import from the X bookmarks tab",
+ "Works via the Chrome extension",
+ ]}
+ onConnect={() =>
+ openExternal("bookmarks", CHROME_EXTENSION_URL)
+ }
+ />
+ }
+ bareIconFrame
+ state={values.connected.chatapps ?? "idle"}
+ ctaLabel="Connect"
+ doneLabel="Opened"
+ perks={[
+ "Sync your ChatGPT memories",
+ "Carry context across every assistant",
+ "Import once, recall anywhere",
+ ]}
+ onConnect={() =>
+ openExternal("chatapps", CHROME_EXTENSION_URL)
+ }
+ />
+
+ >
+ ) : (
+ <>
+
+ setGranolaOpen(true)}
+ />
+
+ >
+ )}
+
+
+
+
+
setMoreOpen((open) => !open)}
+ className="text-[#737373] font-medium text-[14px] hover:text-[#fafafa] inline-flex items-center gap-1.5 transition-colors"
+ >
+
+ More integrations
+
+ (Gmail, GitHub, OneDrive…)
+
+
+
+
+
+ {moreOpen ? (
+
+ setGranolaOpen(true)}
+ />
+
+ ) : null}
+
+
+
+
+ {moreOpen &&
}
+
+
{
+ announced.current.add("granola")
+ markConnected(["granola"])
+ }}
+ />
+
+ )
+}
+
+function OnboardingPlansModal({
+ open,
+ onOpenChange,
+ requestedConnector,
+ requestedPlan,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ requestedConnector: string
+ requestedPlan: RequiredPlan
+}) {
+ const autumn = useCustomer()
+ const { currentPlan, isLoading } = useTokenUsage(autumn)
+ const [upgradingPlan, setUpgradingPlan] = useState(
+ null,
+ )
+ const [planIndex, setPlanIndex] = useState(0)
+ const lastPlanIndex = Math.max(0, PLAN_CARDS.length - 2)
+
+ useEffect(() => {
+ if (open) {
+ setPlanIndex(0)
+ }
+ }, [open])
+
+ const handleUpgrade = async (planId: CheckoutPlanId) => {
+ setUpgradingPlan(planId)
+ try {
+ const result = await autumn.attach({
+ planId,
+ successUrl: window.location.href,
+ })
+ if ((result as { paymentUrl?: string })?.paymentUrl) {
+ window.location.href = (result as { paymentUrl: string }).paymentUrl
+ return
+ }
+ autumn.refetch?.()
+ } catch (error) {
+ console.error(error)
+ toast.error("Failed to start checkout. Please try again.")
+ } finally {
+ setUpgradingPlan(null)
+ }
+ }
+
+ const scrollPlans = (direction: -1 | 1) => {
+ setPlanIndex((index) =>
+ Math.min(lastPlanIndex, Math.max(0, index + direction)),
+ )
+ }
+
+ return (
+
+
+
+
+
+ scrollPlans(-1)}
+ disabled={planIndex === 0}
+ className="flex size-8 items-center justify-center rounded-full border border-white/[0.08] bg-[#14161A] text-[#A3A3A3] transition-colors hover:bg-white/[0.05] hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-35"
+ aria-label="Show previous plans"
+ >
+
+
+ scrollPlans(1)}
+ disabled={planIndex === lastPlanIndex}
+ className="flex size-8 items-center justify-center rounded-full border border-white/[0.08] bg-[#14161A] text-[#A3A3A3] transition-colors hover:bg-white/[0.05] hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-35"
+ aria-label="Show next plans"
+ >
+
+
+
+
+
+
+
+
+
+
+ {requestedConnector} requires{" "}
+
+ {PLAN_LABELS[requestedPlan]}
+
+ .
+
+
+
+
+
+
+
+ {PLAN_CARDS.map((plan) => {
+ const isCurrent = currentPlan === plan.id
+ const isIncluded =
+ !isCurrent && PLAN_RANK[currentPlan] > PLAN_RANK[plan.id]
+ const isBusy = upgradingPlan === plan.productId
+ const checkoutPlanId =
+ plan.productId === "api_pro" ||
+ plan.productId === "api_max" ||
+ plan.productId === "api_scale"
+ ? plan.productId
+ : null
+
+ return (
+
+
+ Book a call
+
+
+ ) : (
+ {
+ if (checkoutPlanId) {
+ handleUpgrade(checkoutPlanId)
+ }
+ }}
+ className={cn(
+ "h-10 w-full rounded-[10px] bg-[#0A65CC] text-[13px] font-semibold text-white hover:bg-[#0B72E7]",
+ (isCurrent || isIncluded) &&
+ "bg-white/[0.04] text-[#737373] hover:bg-white/[0.04]",
+ )}
+ >
+ {isBusy ? (
+
+ ) : isCurrent ? (
+ "Your current plan"
+ ) : isIncluded ? (
+ `Included with ${PLAN_DISPLAY_NAMES[currentPlan]}`
+ ) : checkoutPlanId ? (
+ `Upgrade to ${plan.name}`
+ ) : (
+ "Your current plan"
+ )}
+
+ )
+ }
+ />
+
+ )
+ })}
+
+
+
+
+
+ )
+}
+
+function OnboardingPlanCard({
+ action,
+ plan,
+}: {
+ action: React.ReactNode
+ plan: PlanCardDefinition
+}) {
+ return (
+
+ {plan.mostPopular ? (
+
+ Most popular
+
+ ) : null}
+
+ {plan.name}
+
+
+
+
+ {plan.price}
+
+ {plan.period ? (
+ {plan.period}
+ ) : null}
+
+
+
+ {plan.description}
+
+
+ {plan.isContactSales ? null : (
+
+
+
+
+ {plan.credits}
+
+
+ of usage included
+
+
+
+ )}
+
+ {plan.includesFrom ? (
+
+
+
+ Everything in {plan.includesFrom}, plus
+
+
+
+ ) : null}
+
+
+ {plan.features.map((feature) => (
+
+
+ {feature}
+
+ ))}
+
+
+ {action}
+
+ )
+}
+
+function SourceActions({
+ connectedCount,
+ onContinue,
+ className,
+}: {
+ connectedCount: number
+ onContinue: () => void
+ className?: string
+}) {
+ return (
+
+
+ Skip for now
+
+
+ Continue
+ {connectedCount > 0 && (
+ ({connectedCount})
+ )}
+
+
+
+ )
+}
+
+function GoogleDriveSourceCard({
+ mode,
+ values,
+ onChange,
+ isLocked,
+ guard,
+ connectRealProvider,
+}: {
+ mode: BrainMode
+ values: SourcesValues
+ onChange: (next: SourcesValues) => void
+ isLocked: (plan?: RequiredPlan) => boolean
+ guard: (
+ plan: RequiredPlan | undefined,
+ title: string,
+ fn: () => void,
+ ) => () => void
+ connectRealProvider: (
+ provider: "google-drive" | "notion" | "onedrive",
+ id: SourceId,
+ ) => void
+}) {
+ return (
+ }
+ state={values.connected.drive ?? "idle"}
+ ctaLabel="Connect"
+ locked={isLocked("pro")}
+ requiredPlan="pro"
+ perks={[
+ "Docs, sheets, slides — all parsed",
+ "Stays in sync as files change",
+ "You pick what to share at sign-in",
+ ]}
+ onConnect={guard("pro", "Google Drive", () =>
+ connectRealProvider("google-drive", "drive"),
+ )}
+ headerNote={
+ values.driveScope === "full" ? (
+
+
+ Full Drive can exhaust your monthly usage.
+
+ ) : null
+ }
+ footerLeft={
+ onChange({ ...values, driveScope: s })}
+ />
+ }
+ footerRight={
+
+ }
+ />
+ )
+}
+
+function NotionSourceCard({
+ mode,
+ values,
+ isLocked,
+ guard,
+ connectRealProvider,
+}: {
+ mode: BrainMode
+ values: SourcesValues
+ isLocked: (plan?: RequiredPlan) => boolean
+ guard: (
+ plan: RequiredPlan | undefined,
+ title: string,
+ fn: () => void,
+ ) => () => void
+ connectRealProvider: (
+ provider: "google-drive" | "notion" | "onedrive",
+ id: SourceId,
+ ) => void
+}) {
+ return (
+ }
+ state={values.connected.notion ?? "idle"}
+ ctaLabel="Connect"
+ locked={isLocked("pro")}
+ requiredPlan="pro"
+ perks={[
+ "Pages and database rows",
+ "Stays in sync when you edit",
+ "Pick which workspaces ingest",
+ ]}
+ onConnect={guard("pro", "Notion", () =>
+ connectRealProvider("notion", "notion"),
+ )}
+ footerRight={
+
+ }
+ />
+ )
+}
+
+function GranolaSourceCard({
+ state,
+ isLocked,
+ guard,
+ onOpen,
+}: {
+ state: SourceState
+ isLocked: (plan?: RequiredPlan) => boolean
+ guard: (
+ plan: RequiredPlan | undefined,
+ title: string,
+ fn: () => void,
+ ) => () => void
+ onOpen: () => void
+}) {
+ return (
+ }
+ state={state}
+ ctaLabel="Connect"
+ locked={isLocked("pro")}
+ requiredPlan="pro"
+ perks={[
+ "Meeting notes auto-captured",
+ "Decisions and action items extracted",
+ "Synced after every meeting",
+ ]}
+ onConnect={guard("pro", "Granola", () => {
+ analytics.onboardingIntegrationClicked({ integration: "granola" })
+ onOpen()
+ })}
+ />
+ )
+}
+
+function MoreSourcesGrid({
+ mode,
+ values,
+ onChange,
+ isLocked,
+ guard,
+ openExternal,
+ requestWaitlist,
+ connectRealProvider,
+ onOpenGranola,
+}: {
+ mode: BrainMode
+ values: SourcesValues
+ onChange: (next: SourcesValues) => void
+ isLocked: (plan?: RequiredPlan) => boolean
+ guard: (
+ plan: RequiredPlan | undefined,
+ title: string,
+ fn: () => void,
+ ) => () => void
+ openExternal: (id: SourceId, url: string) => void
+ requestWaitlist: (id: SourceId) => void
+ connectRealProvider: (
+ provider: "google-drive" | "notion" | "onedrive",
+ id: SourceId,
+ ) => void
+ onOpenGranola: () => void
+}) {
+ return (
+ <>
+ }
+ state={values.connected.raycast ?? "idle"}
+ ctaLabel="Connect"
+ doneLabel="Opened"
+ perks={[
+ "Add memories without leaving Raycast",
+ "Search your brain from anywhere",
+ "Fast keyboard-first capture",
+ ]}
+ onConnect={() => openExternal("raycast", RAYCAST_EXTENSION_URL)}
+ />
+ }
+ state={values.connected.shortcuts ?? "idle"}
+ ctaLabel="Connect"
+ doneLabel="Opened"
+ perks={[
+ "Save from the iOS share sheet",
+ "Capture text, links and photos",
+ "Works on iPhone, iPad and Mac",
+ ]}
+ onConnect={() => openExternal("shortcuts", ADD_MEMORY_SHORTCUT_URL)}
+ />
+ }
+ state={values.connected.chrome ?? "idle"}
+ ctaLabel="Connect"
+ doneLabel="Opened"
+ perks={[
+ "Save any page in one click",
+ "Import your X bookmarks",
+ "Sync ChatGPT memories",
+ ]}
+ onConnect={() => openExternal("chrome", CHROME_EXTENSION_URL)}
+ />
+ {mode === "personal" ? (
+
+ ) : null}
+ }
+ state={values.connected.onedrive ?? "idle"}
+ ctaLabel="Connect"
+ locked={isLocked("pro")}
+ requiredPlan="pro"
+ perks={[
+ "Word, Excel, PowerPoint — all parsed",
+ "Stays in sync as files change",
+ "You pick what to share at sign-in",
+ ]}
+ onConnect={guard("pro", "OneDrive", () =>
+ connectRealProvider("onedrive", "onedrive"),
+ )}
+ />
+ }
+ state={values.connected.gmail ?? "idle"}
+ ctaLabel="Connect"
+ locked={isLocked("max")}
+ requiredPlan="max"
+ perks={[
+ "Threads become searchable context",
+ "Decisions and follow-ups surfaced",
+ "You control which labels sync",
+ ]}
+ onConnect={guard("max", "Gmail", () => requestWaitlist("gmail"))}
+ />
+ }
+ state={values.connected.github ?? "idle"}
+ ctaLabel="Connect"
+ locked={isLocked("max")}
+ requiredPlan="max"
+ perks={[
+ "PRs and issues parsed",
+ "READMEs and docs indexed",
+ "Stays in sync with new activity",
+ ]}
+ onConnect={guard("max", "GitHub", () => requestWaitlist("github"))}
+ />
+ {mode === "personal" ? (
+
+ ) : null}
+ >
+ )
+}
+
+function SourceCard({
+ title,
+ blurb,
+ icon,
+ state,
+ ctaLabel,
+ doneLabel,
+ perks,
+ bareIconFrame,
+ disabled,
+ locked,
+ requiredPlan,
+ headerNote,
+ footerLeft,
+ footerRight,
+ onConnect,
+}: {
+ title: string
+ blurb: string
+ icon: React.ReactNode
+ state: SourceState
+ ctaLabel: string
+ doneLabel?: string
+ perks: string[]
+ bareIconFrame?: boolean
+ disabled?: boolean
+ locked?: boolean
+ requiredPlan?: RequiredPlan
+ headerNote?: React.ReactNode
+ footerLeft?: React.ReactNode
+ footerRight?: React.ReactNode
+ onConnect: () => void
+}) {
+ const isDone = state === "connected" || state === "waitlist"
+
+ return (
+
+
+
+
+
+ {title}
+
+
+ {blurb}
+
+ {headerNote}
+
+ {isDone ? (
+
+
+ {state === "waitlist" ? "Requested" : (doneLabel ?? "Connected")}
+
+ ) : (
+
+ {state === "connecting" ? (
+ "Opening…"
+ ) : (
+ <>
+ {ctaLabel}
+ {locked && }
+ >
+ )}
+
+ )}
+
+
+
+ {perks.map((p) => (
+
+
+ {p}
+
+ ))}
+
+
+ {(footerLeft || footerRight) && (
+
+
{footerLeft}
+
{footerRight}
+
+ )}
+
+ )
+}
+
+function SpaceChip({ name }: { name: string }) {
+ return (
+
+
+ Saves to
+
+
+ {name}
+
+ )
+}
+
+function DriveScopePicker({
+ value,
+ onChange,
+}: {
+ value: DriveScope
+ onChange: (s: DriveScope) => void
+}) {
+ return (
+ onChange(v as DriveScope)}>
+
+
+
+
+
+ Files & folders
+
+
+ Full Drive
+
+
+
+ )
+}
diff --git a/apps/web/components/onboarding-brain/step-team.tsx b/apps/web/components/onboarding-brain/step-team.tsx
new file mode 100644
index 00000000..591967c7
--- /dev/null
+++ b/apps/web/components/onboarding-brain/step-team.tsx
@@ -0,0 +1,331 @@
+"use client"
+
+import { useMemo, useState } from "react"
+import { Button } from "@ui/components/button"
+import { Input } from "@ui/components/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@ui/components/select"
+import { ArrowRight, Loader2, Mail, Plus, Trash2, Users } from "lucide-react"
+import { cn } from "@lib/utils"
+import { dmSans125ClassName } from "@/lib/fonts"
+
+const modalCardStyle = {
+ boxShadow:
+ "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
+}
+
+const inputBevelStyle = {
+ boxShadow:
+ "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
+}
+
+const inputClass =
+ "bg-[#0F1217] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#525D6E] h-12 shadow-none focus-visible:ring-0 focus-visible:border-[rgba(115,115,115,0.3)] transition-colors"
+
+export interface TeamValues {
+ invites: { email: string; role: "admin" | "member" }[]
+ visibility: "team-private" | "org-shared"
+ suggestChanges: boolean
+}
+
+interface Props {
+ mode: "personal" | "team"
+ isScale: boolean
+ inviteDomain: string | null
+ values: TeamValues
+ onChange: (next: TeamValues) => void
+ onContinue: () => void
+ onSkip?: () => void
+ onUpgrade: () => void
+ submitting?: boolean
+}
+
+const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi
+
+export function StepTeam({
+ mode,
+ inviteDomain,
+ values,
+ onChange,
+ onContinue,
+ onSkip,
+ submitting,
+}: Props) {
+ const [draft, setDraft] = useState("")
+ const domainOrFallback = (inviteDomain || "acme.com").trim().toLowerCase()
+
+ const addInvites = (text: string) => {
+ const found = text.match(EMAIL_RE) ?? []
+ if (found.length === 0) return
+ const existing = new Set(values.invites.map((i) => i.email.toLowerCase()))
+ const next: { email: string; role: "admin" | "member" }[] = []
+ for (const raw of found) {
+ const email = raw.trim().toLowerCase()
+ if (!email || existing.has(email)) continue
+ existing.add(email)
+ next.push({ email, role: "member" })
+ }
+ if (next.length === 0) {
+ setDraft("")
+ return
+ }
+ onChange({ ...values, invites: [...values.invites, ...next] })
+ setDraft("")
+ }
+
+ const removeInvite = (email: string) => {
+ onChange({
+ ...values,
+ invites: values.invites.filter((i) => i.email !== email),
+ })
+ }
+
+ const setRole = (email: string, role: "admin" | "member") => {
+ onChange({
+ ...values,
+ invites: values.invites.map((i) =>
+ i.email === email ? { ...i, role } : i,
+ ),
+ })
+ }
+
+ const domainBreakdown = useMemo(() => {
+ const counts = new Map()
+ for (const inv of values.invites) {
+ const at = inv.email.lastIndexOf("@")
+ if (at < 0) continue
+ const domain = inv.email.slice(at + 1).toLowerCase()
+ counts.set(domain, (counts.get(domain) ?? 0) + 1)
+ }
+ return [...counts.entries()].sort((a, b) => b[1] - a[1])
+ }, [values.invites])
+
+ if (mode === "personal") {
+ return (
+
+
+
+
+
+ Going solo — for now
+
+
+ You're in Personal mode, so there's no team step. Switch to Team in
+ the top bar anytime to invite others.
+
+
+ Continue
+
+
+
+ )
+ }
+
+ const count = values.invites.length
+
+ return (
+
+
+
+
+
+
+
+
+ Invite your team
+
+
+ A brain gets sharper as more people contribute. You can also do
+ this later.
+
+
+
+
+
+
+
+ Paste multiple emails at once — we'll split them for you.
+
+
+ {count === 0 ? (
+
+
+ No invites yet.
+
+
+ Try{" "}
+ alex@{domainOrFallback} ,{" "}
+ sam@{domainOrFallback} ,
+ etc.
+
+
+ ) : (
+ <>
+
+
+ {count} invite{count === 1 ? "" : "s"}
+
+ {domainBreakdown.length > 0 && (
+
+ {domainBreakdown.slice(0, 3).map(([d, n]) => (
+
+ {d} · {n}
+
+ ))}
+
+ )}
+
+
+ {values.invites.map((inv) => (
+
+
+
+ {(inv.email[0] ?? "?").toUpperCase()}
+
+
+
+ {inv.email}
+
+
+ setRole(inv.email, r as "admin" | "member")
+ }
+ >
+
+
+
+
+
+ Member
+
+
+ Admin
+
+
+
+
removeInvite(inv.email)}
+ className="text-[#737373] hover:text-[#fafafa] p-1 transition-colors"
+ aria-label={`Remove ${inv.email}`}
+ >
+
+
+
+ ))}
+
+ >
+ )}
+
+
+
+ Skip for now
+
+
+ {submitting ? (
+ <>
+ Sending…
+
+ >
+ ) : (
+ <>
+ {count > 0
+ ? `Send ${count} invite${count === 1 ? "" : "s"}`
+ : "Continue"}
+
+ >
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/web/components/onboarding-brain/types.ts b/apps/web/components/onboarding-brain/types.ts
new file mode 100644
index 00000000..b77eeec7
--- /dev/null
+++ b/apps/web/components/onboarding-brain/types.ts
@@ -0,0 +1,151 @@
+export type CompanyBrainConfirmResult =
+ | { ok: true; serverSchedulesResearch: boolean }
+ | { ok: false }
+
+export type BrainMode = "personal" | "team"
+
+export type BrainStep = "about" | "sources" | "ingest" | "team"
+
+export const BRAIN_STEPS: BrainStep[] = ["about", "sources", "ingest", "team"]
+
+export const BRAIN_STEP_LABELS: Record = {
+ about: "About",
+ sources: "Tools",
+ ingest: "Flows",
+ team: "Team",
+}
+
+const FREE_EMAIL_DOMAINS = new Set([
+ "gmail.com",
+ "googlemail.com",
+ "yahoo.com",
+ "yahoo.co.uk",
+ "yahoo.co.in",
+ "outlook.com",
+ "hotmail.com",
+ "live.com",
+ "icloud.com",
+ "me.com",
+ "mac.com",
+ "aol.com",
+ "protonmail.com",
+ "proton.me",
+ "pm.me",
+ "fastmail.com",
+ "zoho.com",
+ "yandex.com",
+ "yandex.ru",
+ "mail.com",
+ "qq.com",
+ "163.com",
+ "126.com",
+ "naver.com",
+ "duck.com",
+])
+
+export function detectModeFromEmail(
+ email: string | undefined | null,
+): BrainMode {
+ if (!email) return "personal"
+ const at = email.lastIndexOf("@")
+ if (at < 0) return "personal"
+ const domain = email
+ .slice(at + 1)
+ .toLowerCase()
+ .trim()
+ if (!domain) return "personal"
+ if (FREE_EMAIL_DOMAINS.has(domain)) return "personal"
+ return "team"
+}
+
+export function workspaceNameFromEmail(
+ email: string | undefined | null,
+): string {
+ if (!email) return ""
+ const at = email.lastIndexOf("@")
+ if (at < 0) return ""
+ const domain = email.slice(at + 1).toLowerCase()
+ const root = domain.split(".")[0] ?? ""
+ if (!root) return ""
+ return root.charAt(0).toUpperCase() + root.slice(1)
+}
+
+/** e.g. duolingo.com → Duolingo (first hostname label, title-cased). */
+export function workspaceNameFromDomain(
+ domain: string | undefined | null,
+): string {
+ if (!domain) return ""
+ const clean = domain
+ .trim()
+ .toLowerCase()
+ .replace(/^https?:\/\//, "")
+ .replace(/^www\./, "")
+ .replace(/\/.*$/, "")
+ const host = clean.split(".")[0] ?? ""
+ if (!host) return ""
+ return host.charAt(0).toUpperCase() + host.slice(1)
+}
+
+export function workspaceDomainFromEmail(
+ email: string | undefined | null,
+): string | null {
+ if (!email) return null
+ const at = email.lastIndexOf("@")
+ if (at < 0) return null
+ const domain = email
+ .slice(at + 1)
+ .toLowerCase()
+ .trim()
+ return domain || null
+}
+
+export type BrainMetadata = {
+ brainOnboardingVersion?: "v1"
+ brainOnboardingComplete?: boolean
+ brainMode?: BrainMode
+ brainWorkspaceName?: string
+ brainWorkspaceDomain?: string | null
+ brainAbout?: string
+ brainContainerTag?: string
+ brainSources?: {
+ drive?: { status: "connected" | "pending" | "skipped" }
+ gmail?: { status: "requested" | "connected" | "skipped"; range?: string }
+ notion?: { status: "connected" | "pending" | "skipped" }
+ granola?: { status: "waitlist" | "skipped" }
+ }
+ brainInvites?: { email: string; role: "admin" | "member" }[]
+ brainPermissions?: {
+ visibility: "team-private" | "org-shared"
+ suggestChanges: boolean
+ }
+}
+
+export function generateOrgSlug(name: string): string {
+ const base =
+ name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "") || "org"
+ return `${base}-${Math.floor(100000 + Math.random() * 900000)}`
+}
+
+export function generateUsername(name: string): string {
+ const base =
+ name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "_")
+ .replace(/(^_|_$)/g, "") || "user"
+ return `${base}${Math.floor(100000 + Math.random() * 900000)}`
+}
+
+export function containerTagFromWorkspace(
+ name: string,
+ mode: BrainMode,
+): string {
+ const slug = name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "")
+ if (!slug) return mode === "team" ? "team-brain" : "personal-brain"
+ return mode === "team" ? `${slug}-brain` : `${slug}-personal`
+}
diff --git a/apps/web/components/orbit-memory.tsx b/apps/web/components/orbit-memory.tsx
new file mode 100644
index 00000000..757d2a6d
--- /dev/null
+++ b/apps/web/components/orbit-memory.tsx
@@ -0,0 +1,582 @@
+"use client"
+
+/**
+ * OrbitMemory — Supermemory "Connect Your Tools" onboarding visual (desktop, left column).
+ * Zero-dependency. A curated constellation of what Supermemory connects to — AI models & agents
+ * (Claude, ChatGPT/Codex, Gemini, Copilot, Perplexity), the MCP protocol, and apps/tools — orbits
+ * a central Supermemory mark on layered dotted tracks (continuous, gentle per-ring parallax).
+ * A single requestAnimationFrame loop periodically connects to ONE node: a STATIC dashed neutral-
+ * gray track fades in and staggered blue comet streaks glide along it while the node glows cobalt,
+ * then it disconnects and moves to the next. Honors prefers-reduced-motion.
+ */
+import type React from "react"
+import { useEffect, useRef } from "react"
+
+const ICONS: Record = {
+ googlegemini:
+ ' ',
+ openai:
+ ' ',
+ raycast:
+ '\n \n ',
+ n8n: ' ',
+ googledrive:
+ '\n \n \n \n \n \n \n ',
+ claude:
+ ' ',
+ dropbox:
+ ' ',
+ figma:
+ '\n\n \n \n \n \n \n \n\n\n \n \n \n ',
+ linear:
+ ' ',
+ slack:
+ '\n \n \n \n \n \n \n ',
+ gmail:
+ '\n \n \n \n \n \n \n \n \n \n ',
+ notion:
+ ' ',
+ perplexity:
+ '\n \n \n \n ',
+ obsidian:
+ ' ',
+ githubcopilot:
+ ' ',
+ mcp: ' ',
+}
+const INTEG: {
+ key?: string
+ label?: string
+ sub?: string
+ ring: number
+ ang: number
+ size: number
+ dim: boolean
+ _x?: number
+ _y?: number
+}[] = [
+ { ring: 0, ang: 0.0, size: 80, dim: false, key: "claude" },
+ { ring: 0, ang: 60.0, size: 80, dim: false, key: "openai" },
+ { ring: 0, ang: 120.0, size: 80, dim: false, key: "googlegemini" },
+ { ring: 0, ang: 180.0, size: 80, dim: false, key: "githubcopilot" },
+ { ring: 0, ang: 240.0, size: 80, dim: false, key: "perplexity" },
+ { ring: 0, ang: 300.0, size: 80, dim: false, key: "mcp", sub: "MCP" },
+ { ring: 1, ang: 18.0, size: 58, dim: true, key: "notion" },
+ { ring: 1, ang: 54.0, size: 58, dim: true, key: "slack" },
+ { ring: 1, ang: 90.0, size: 58, dim: true, key: "linear" },
+ { ring: 1, ang: 126.0, size: 58, dim: true, key: "figma" },
+ { ring: 1, ang: 162.0, size: 58, dim: true, key: "googledrive" },
+ { ring: 1, ang: 198.0, size: 58, dim: true, key: "gmail" },
+ { ring: 1, ang: 234.0, size: 58, dim: true, key: "obsidian" },
+ { ring: 1, ang: 270.0, size: 58, dim: true, key: "dropbox" },
+ { ring: 1, ang: 306.0, size: 58, dim: true, key: "n8n" },
+ { ring: 1, ang: 342.0, size: 58, dim: true, key: "raycast" },
+]
+const DW = 780
+const DH = 1024
+const CX = 390
+const CY = 512
+const RR = [200, 335]
+const SPEED = [1.0, 0.78]
+const DRAW: [number, number, number, string][] = [
+ [125, 0.28, 1.7, "#74a8f6"],
+ [200, 0.18, 1.9, "#4389ff"],
+ [335, 0.14, 1.8, "#4389ff"],
+ [378, 0.05, 1.7, "#74a8f6"],
+]
+const ACCENT: Record = {
+ claude: "217,119,87",
+ openai: "190,210,245",
+ googlegemini: "80,130,255",
+ githubcopilot: "190,210,245",
+ perplexity: "32,184,205",
+ mcp: "190,210,245",
+ notion: "205,213,235",
+ slack: "54,197,240",
+ linear: "110,120,230",
+ figma: "162,89,255",
+ googledrive: "0,180,90",
+ gmail: "234,67,53",
+ obsidian: "138,99,246",
+ dropbox: "40,120,255",
+ n8n: "234,75,113",
+ raycast: "255,99,99",
+}
+const NC = 2
+const HL = 22
+const P1 =
+ "M86.9333 31.8278H56.1203V5.39087H46.1648V34.0754C46.1648 37.122 47.3832 40.0479 49.5488 42.2037L74.7085 67.2494L81.7477 60.2421L63.1652 41.7439H86.9389V31.8336L86.9333 31.8278Z"
+const P2 =
+ "M10.4478 17.1572L28.5503 35.6573H5.39062V45.5685H35.4075V72.0078H45.1057V43.3207C45.1057 40.2736 43.9188 37.3474 41.8091 35.1917L17.3051 10.1492L10.4478 17.1572Z"
+const SPARK_HTML = ` `
+
+export default function OrbitMemory({
+ className = "",
+ style,
+ grain = true,
+}: {
+ className?: string
+ style?: React.CSSProperties
+ grain?: boolean
+}) {
+ const stageRef = useRef(null)
+ const tileRefs = useRef([])
+ const trackRef = useRef(null)
+ const cmRefs = useRef([])
+ const cmgRefs = useRef([])
+ const gradRefs = useRef([])
+ const logoRef = useRef(null)
+ const ringRefs = useRef([])
+ const dtrackRef = useRef(null)
+ const omRefs = useRef([])
+ const omgRefs = useRef([])
+ const ogRefs = useRef([])
+
+ useEffect(() => {
+ const stage = stageRef.current
+ if (!stage) return
+ const nodes = tileRefs.current
+ const track = trackRef.current!
+ const cms = cmRefs.current
+ const cmgs = cmgRefs.current
+ const grads = gradRefs.current
+ const dtrack = dtrackRef.current!
+ const oms = omRefs.current
+ const omgs = omgRefs.current
+ const ogrs = ogRefs.current
+ let scale = 1
+ const measure = () => {
+ scale = stage.clientWidth / DW
+ }
+ measure()
+ const ro = new ResizeObserver(measure)
+ ro.observe(stage)
+ const setLine = (
+ el: Element,
+ ax: number,
+ ay: number,
+ bx: number,
+ by: number,
+ ) => {
+ el.setAttribute("x1", ax.toFixed(1))
+ el.setAttribute("y1", ay.toFixed(1))
+ el.setAttribute("x2", bx.toFixed(1))
+ el.setAttribute("y2", by.toFixed(1))
+ }
+ const hideConn = () => {
+ track.setAttribute("stroke-opacity", "0")
+ dtrack.setAttribute("stroke-opacity", "0")
+ for (let k = 0; k < NC; k++) {
+ cms[k].setAttribute("opacity", "0")
+ cmgs[k].setAttribute("opacity", "0")
+ oms[k].setAttribute("opacity", "0")
+ omgs[k].setAttribute("opacity", "0")
+ }
+ }
+ let angle = 0
+ const place = () => {
+ for (let i = 0; i < INTEG.length; i++) {
+ const it = INTEG[i]
+ const a = ((it.ang + angle * SPEED[it.ring]) * Math.PI) / 180
+ const R = RR[it.ring]
+ const ux = CX + R * Math.cos(a)
+ const uy = CY + R * Math.sin(a)
+ it._x = ux
+ it._y = uy
+ const n = nodes[i]
+ if (n)
+ n.style.transform = `translate(-50%,-50%) translate(${((ux - CX) * scale).toFixed(2)}px,${((uy - CY) * scale).toFixed(2)}px)`
+ }
+ }
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
+ place()
+ hideConn()
+ return () => ro.disconnect()
+ }
+ const ORBIT = 2200
+ const RECV = 2000
+ const SEND = 2000
+ const DISC = 700
+ const INN: number[] = []
+ const OUT: number[] = []
+ for (let i = 0; i < INTEG.length; i++) {
+ ;(INTEG[i].ring === 0 ? INN : OUT).push(i)
+ }
+ let phase = "orbit"
+ let pT0 = 0
+ let ci = 0
+ let src = -1
+ let dst = -1
+ let last = 0
+ let raf = 0
+ let lastPing = 0
+ let ri = 0
+ const rings = ringRefs.current
+ const ping = () => {
+ const el = rings[ri++ % rings.length]
+ if (!el) return
+ el.getAnimations().forEach((a) => {
+ a.cancel()
+ })
+ el.animate(
+ [
+ { transform: "scale(1)", opacity: 0, offset: 0 },
+ { transform: "scale(1.55)", opacity: 0.7, offset: 0.22 },
+ { transform: "scale(2)", opacity: 0.55, offset: 0.55 },
+ { transform: "scale(2.45)", opacity: 0, offset: 1 },
+ ],
+ { duration: 2400, easing: "linear" },
+ )
+ }
+ const BASE = 360 / 90 / 1000
+ const eo = (t: number) => 1 - (1 - t) ** 3
+ const ei = (t: number) => t * t * t
+ const seg = (
+ now: number,
+ trk: SVGLineElement,
+ nx: number,
+ ny: number,
+ P: number,
+ gr: SVGLinearGradientElement[],
+ cr: SVGLineElement[],
+ gl: SVGLineElement[],
+ dir: number,
+ off: number,
+ ) => {
+ setLine(trk, CX, CY, nx, ny)
+ trk.setAttribute("stroke-opacity", (0.62 * P).toFixed(2))
+ const dx = nx - CX
+ const dy = ny - CY
+ const len = Math.hypot(dx, dy) || 1
+ const ux = dx / len
+ const uy = dy / len
+ for (let k = 0; k < NC; k++) {
+ const f = (now / 1500 + (k + off) / NC) % 1
+ const t = dir < 0 ? 1 - f : f
+ const pxc = CX + dx * t
+ const pyc = CY + dy * t
+ const ax = pxc - ux * HL
+ const ay = pyc - uy * HL
+ const bx = pxc + ux * HL
+ const by = pyc + uy * HL
+ setLine(gr[k], ax, ay, bx, by)
+ setLine(cr[k], ax, ay, bx, by)
+ setLine(gl[k], ax, ay, bx, by)
+ const o = Math.sin(f * Math.PI) * P
+ cr[k].setAttribute("opacity", o.toFixed(2))
+ gl[k].setAttribute("opacity", (0.6 * o).toFixed(2))
+ }
+ }
+ const frame = (now: number) => {
+ if (!last) last = now
+ const dt = Math.min(now - last, 50)
+ last = now
+ if (!pT0) pT0 = now
+ const pt = now - pT0
+ if (phase === "orbit" && pt > ORBIT) {
+ phase = "receive"
+ pT0 = now
+ src = INN[(ci * 5) % INN.length]
+ dst = OUT[(ci * 7) % OUT.length]
+ ci++
+ nodes[src]?.classList.add("sm-active")
+ stage.style.setProperty(
+ "--cm",
+ `rgb(${ACCENT[INTEG[src].key!] || "91,157,255"})`,
+ )
+ } else if (phase === "receive" && pt > RECV) {
+ phase = "send"
+ pT0 = now
+ nodes[dst]?.classList.add("sm-active")
+ } else if (phase === "send" && pt > SEND) {
+ phase = "disconnect"
+ pT0 = now
+ nodes[src]?.classList.remove("sm-active")
+ nodes[dst]?.classList.remove("sm-active")
+ } else if (phase === "disconnect" && pt > DISC) {
+ phase = "orbit"
+ pT0 = now
+ src = -1
+ dst = -1
+ }
+ angle += BASE * dt
+ place()
+ if (phase === "send" && now - lastPing > 820) {
+ ping()
+ lastPing = now
+ }
+ if (src < 0) {
+ hideConn()
+ } else {
+ const cpt = now - pT0
+ let ps = 1
+ if (phase === "receive") ps = eo(Math.min(cpt / RECV, 1))
+ else if (phase === "disconnect") ps = 1 - ei(Math.min(cpt / DISC, 1))
+ let pd = 0
+ if (phase === "send") pd = eo(Math.min(cpt / SEND, 1))
+ else if (phase === "disconnect") pd = 1 - ei(Math.min(cpt / DISC, 1))
+ seg(
+ now,
+ track,
+ INTEG[src]._x!,
+ INTEG[src]._y!,
+ ps,
+ grads,
+ cms,
+ cmgs,
+ -1,
+ 0,
+ )
+ seg(
+ now,
+ dtrack,
+ INTEG[dst]._x!,
+ INTEG[dst]._y!,
+ pd,
+ ogrs,
+ oms,
+ omgs,
+ 1,
+ 0.5,
+ )
+ }
+ raf = requestAnimationFrame(frame)
+ }
+ raf = requestAnimationFrame(frame)
+ return () => {
+ cancelAnimationFrame(raf)
+ ro.disconnect()
+ }
+ }, [])
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {[0, 1].map((k) => (
+ {
+ if (el) gradRefs.current[k] = el
+ }}
+ >
+
+
+
+
+ ))}
+ {[0, 1].map((k) => (
+ {
+ if (el) ogRefs.current[k] = el
+ }}
+ >
+
+
+
+
+ ))}
+
+
+ {DRAW.map(([r, o, w, c]) => (
+
+ ))}
+
+
+
+ {[0, 1].map((k) => (
+ {
+ if (el) cmgRefs.current[k] = el
+ }}
+ stroke={`url(#sm-cg${k})`}
+ strokeWidth={5.5}
+ strokeLinecap="round"
+ opacity={0}
+ filter="url(#sm-cglow)"
+ />
+ ))}
+ {[0, 1].map((k) => (
+ {
+ if (el) cmRefs.current[k] = el
+ }}
+ stroke={`url(#sm-cg${k})`}
+ strokeWidth={2.3}
+ strokeLinecap="round"
+ opacity={0}
+ />
+ ))}
+ {[0, 1].map((k) => (
+ {
+ if (el) omgRefs.current[k] = el
+ }}
+ stroke={`url(#sm-og${k})`}
+ strokeWidth={5}
+ strokeLinecap="round"
+ opacity={0}
+ filter="url(#sm-cglow)"
+ />
+ ))}
+ {[0, 1].map((k) => (
+ {
+ if (el) omRefs.current[k] = el
+ }}
+ stroke={`url(#sm-og${k})`}
+ strokeWidth={2.1}
+ strokeLinecap="round"
+ opacity={0}
+ />
+ ))}
+
+
+ {INTEG.map((it, i) => (
+
{
+ if (el) tileRefs.current[i] = el
+ }}
+ className={`sm-tile${it.dim ? " sm-dim" : ""}`}
+ style={
+ {
+ width: `${((it.size / DW) * 100).toFixed(3)}cqmin`,
+ height: `${((it.size / DW) * 100).toFixed(3)}cqmin`,
+ ["--s" as unknown as string]: `${((it.size / DW) * 100).toFixed(3)}cqmin`,
+ ["--ac" as unknown as string]: ACCENT[it.key!] || "47,123,255",
+ } as React.CSSProperties
+ }
+ >
+ {it.sub ? (
+
+
+ {it.sub}
+
+ ) : it.label ? (
+ {it.label}
+ ) : (
+
+ )}
+
+ ))}
+
+
+
+ {(["ring-0", "ring-1", "ring-2", "ring-3"] as const).map((rk, k) => (
+
{
+ if (el) ringRefs.current[k] = el
+ }}
+ />
+ ))}
+
+
+ {grain &&
}
+
+ )
+}
+
+const CSS = `
+.sm-orbit{position:relative;width:100%;height:100%;container-type:size;overflow:hidden;isolation:isolate;background:#05060b;--ease-soft:cubic-bezier(.45,0,.55,1)}
+.sm-glow{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);border-radius:50%;pointer-events:none}
+.sm-glow-1{width:150cqmin;height:150cqmin;background:radial-gradient(circle, rgba(8,100,240,.40) 0%, rgba(6,98,239,.20) 28%, rgba(6,98,239,.08) 50%, transparent 70%);opacity:.68}
+.sm-glow-2{width:92cqmin;height:92cqmin;background:radial-gradient(circle, rgba(50,126,255,.46) 0%, rgba(47,123,255,.18) 38%, transparent 66%);opacity:.66}
+.sm-glow-3{width:54cqmin;height:54cqmin;background:radial-gradient(circle, rgba(98,158,255,.70) 0%, rgba(30,100,240,.28) 42%, transparent 68%);opacity:.8}
+.sm-lines{position:absolute;inset:0;width:100%;height:100%;z-index:2;overflow:visible}
+.sm-tiles{position:absolute;inset:0;z-index:6}
+.sm-tile{--ac:47,123,255;position:absolute;left:50%;top:50%;display:grid;place-items:center;border-radius:28%;background:rgba(15,19,31,.94);border:0.3px solid rgba(245,247,252,.12);box-shadow:0 calc(var(--s)*0.1111) calc(var(--s)*0.2778) calc(var(--s)*-0.0694) rgba(0,0,0,.5),inset 0 calc(var(--s)*0.0139) calc(var(--s)*0.0097) rgba(255,255,255,.12),inset 0 calc(var(--s)*0.1804) calc(var(--s)*0.1804) rgba(0,0,0,.25),inset 0 calc(var(--s)*-0.1353) calc(var(--s)*0.1804) rgba(0,0,0,.25),inset 0 calc(var(--s)*-0.0451) calc(var(--s)*0.1804) rgba(186,209,255,.25),0 calc(var(--s)*0.1227) calc(var(--s)*0.2863) rgba(3,4,28,.39),0 calc(var(--s)*0.5317) calc(var(--s)*0.5317) rgba(3,4,28,.34),0 calc(var(--s)*1.1452) calc(var(--s)*0.6953) rgba(3,4,28,.2),0 calc(var(--s)*2.045) calc(var(--s)*0.818) rgba(3,4,28,.06),0 calc(var(--s)*-0.1636) calc(var(--s)*4.09) rgba(41,130,255,.5);transition:box-shadow .5s var(--ease-soft),border-color .5s var(--ease-soft),border-width .5s var(--ease-soft);will-change:transform}
+.sm-tile.sm-active{border-color:rgba(var(--ac),.6);border-width:0.3px;box-shadow:0 calc(var(--s)*0.1111) calc(var(--s)*0.2778) calc(var(--s)*-0.0694) rgba(0,0,0,.5),inset 0 calc(var(--s)*0.0139) calc(var(--s)*0.0097) rgba(255,255,255,.12),inset 0 calc(var(--s)*0.1804) calc(var(--s)*0.1804) rgba(var(--ac),.3),inset 0 calc(var(--s)*-0.1353) calc(var(--s)*0.1804) rgba(0,0,0,.25),inset 0 calc(var(--s)*-0.0451) calc(var(--s)*0.1804) rgba(186,209,255,.25),0 calc(var(--s)*0.1227) calc(var(--s)*0.2863) rgba(3,4,28,.39),0 calc(var(--s)*0.5317) calc(var(--s)*0.5317) rgba(3,4,28,.34),0 calc(var(--s)*1.1452) calc(var(--s)*0.6953) rgba(3,4,28,.2),0 calc(var(--s)*2.045) calc(var(--s)*0.818) rgba(3,4,28,.06),0 calc(var(--s)*-0.1636) calc(var(--s)*4.09) rgba(var(--ac),.62)}
+.sm-glyph{width:46%;height:46%;color:#eef3ff;opacity:.92;display:grid;place-items:center}
+.sm-glyph svg{width:100%;height:100%;display:block}
+.sm-tile.sm-dim .sm-glyph{opacity:.72}
+.sm-tile.sm-active .sm-glyph{color:#fff;opacity:1}
+.sm-glabel{font:600 2.55cqmin/1 -apple-system,system-ui,sans-serif;letter-spacing:.08em;color:#eef3ff;opacity:.92}
+.sm-iconsub{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.5cqmin;width:100%;height:100%}
+.sm-iconsub .sm-glyph{width:42%;height:42%}
+.sm-gsub{font:600 1.4cqmin/1 -apple-system,system-ui,sans-serif;letter-spacing:.01em;color:#cfe0ff;opacity:.8}
+.sm-tile.sm-active .sm-gsub{color:#e6f0ff;opacity:1}
+.sm-tile.sm-active .sm-glabel{color:#fff;opacity:1}
+.sm-grain{position:absolute;inset:0;z-index:7;pointer-events:none;opacity:.05;background-image:url("data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20width%3D%27200%27%20height%3D%27200%27%3E%3Cfilter%20id%3D%27n%27%3E%3CfeTurbulence%20type%3D%27fractalNoise%27%20baseFrequency%3D%270.9%27%20numOctaves%3D%272%27%20stitchTiles%3D%27stitch%27%2F%3E%3CfeColorMatrix%20type%3D%27saturate%27%20values%3D%270%27%2F%3E%3C%2Ffilter%3E%3Crect%20width%3D%27100%25%27%20height%3D%27100%25%27%20filter%3D%27url%28%23n%29%27%2F%3E%3C%2Fsvg%3E");background-size:200px 200px}
+.sm-core{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:5;width:0;height:0;display:grid;place-items:center}
+.sm-core>*{position:absolute}
+.sm-cglow{width:43.6cqmin;height:43.6cqmin;border-radius:50%;background:rgba(47,123,255,.63);filter:blur(7.7cqmin);pointer-events:none}
+.sm-clogo{width:0;height:0;display:grid;place-items:center;will-change:transform}
+.sm-clogo>*{position:absolute}
+.sm-ring{position:absolute;width:17cqmin;height:17cqmin;border-radius:50%;background:radial-gradient(circle, transparent 56%, rgba(130,185,255,.6) 74%, rgba(175,210,255,.22) 84%, transparent 100%);mix-blend-mode:screen;opacity:0;will-change:transform,opacity;pointer-events:none}
+.sm-tile2{width:17.7cqmin;height:17.7cqmin;border-radius:29%;background:linear-gradient(180deg,#293047 0%,#384369 19%,#21273f 51%,#141824 100%);box-shadow:inset 0 .5cqmin .55cqmin rgba(0,0,0,.28),inset 0 -.4cqmin .55cqmin rgba(0,0,0,.28),inset 0 -.14cqmin .55cqmin rgba(186,209,255,.32),0 .35cqmin .85cqmin rgba(3,4,28,.4),0 1.55cqmin 1.55cqmin rgba(3,4,28,.34),0 3.3cqmin 2cqmin rgba(3,4,28,.2),0 -.5cqmin 9cqmin rgba(41,130,255,.5)}
+.sm-spark{width:12cqmin;height:9.8cqmin;display:grid;place-items:center}
+.sm-spark svg{width:100%;height:100%;display:block;overflow:visible}
+`
diff --git a/apps/web/components/org-plan-badge.tsx b/apps/web/components/org-plan-badge.tsx
index e5775e65..12fa1d19 100644
--- a/apps/web/components/org-plan-badge.tsx
+++ b/apps/web/components/org-plan-badge.tsx
@@ -10,6 +10,7 @@ const orgPlanBadgeBase = cn(
const ORG_PLAN_BADGE_STYLES: Record = {
free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]",
pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]",
+ max: "bg-[#1E7FE0] font-bold tracking-[0.36px] text-[#00171A]",
scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]",
enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]",
}
diff --git a/apps/web/components/review-memories-card.tsx b/apps/web/components/review-memories-card.tsx
new file mode 100644
index 00000000..dd828aac
--- /dev/null
+++ b/apps/web/components/review-memories-card.tsx
@@ -0,0 +1,79 @@
+"use client"
+
+import { ReviewMemoriesModal } from "@/components/review-memories-modal"
+import { useInferredMemories } from "@/hooks/use-inferred-memories"
+import { dmSansClassName } from "@/lib/fonts"
+import { cn } from "@lib/utils"
+import { ArrowRight } from "lucide-react"
+import { useEffect, useState } from "react"
+
+// "Suggested for you" entry point — opens the swipe-review modal. The trigger
+// hides when there's nothing to review, but the modal stays mounted while open
+// (even after the last card) so it can show its "all caught up" summary.
+// Styled to match the Weekly digest box above it.
+export function ReviewMemoriesCard({
+ containerTag,
+ className,
+}: {
+ containerTag: string | undefined
+ className?: string
+}) {
+ const [open, setOpen] = useState(false)
+ const { data: memories = [] } = useInferredMemories(containerTag)
+ const liveCount = memories.length
+
+ // While the modal is open, freeze the count shown on the trigger so the
+ // background button stays put (doesn't tick down or vanish) until it closes.
+ const [frozenCount, setFrozenCount] = useState(0)
+ const count = open ? frozenCount : liveCount
+
+ const handleOpenChange = (next: boolean) => {
+ if (next) setFrozenCount(liveCount)
+ setOpen(next)
+ }
+
+ // Switching spaces shouldn't carry an open modal over to the new space.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reset on space switch
+ useEffect(() => {
+ setOpen(false)
+ }, [containerTag])
+
+ return (
+ <>
+ {count > 0 && (
+ handleOpenChange(true)}
+ className={cn(
+ "group flex w-full items-center justify-between gap-3 rounded-xl bg-[#0c1a30]/80 px-3.5 py-3 text-left shadow-[0_12px_40px_rgba(0,0,0,0.22)] ring-1 ring-[#4BA0FA]/20 backdrop-blur-md transition-colors hover:bg-[#0e2038]/90 hover:ring-[#4BA0FA]/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/45",
+ dmSansClassName(),
+ className,
+ )}
+ >
+
+
+ Review suggestions
+
+
+ {count} {count === 1 ? "memory" : "memories"} we inferred for you
+
+
+
+
+ {count}
+
+
+
+
+ )}
+
+ {(open || liveCount > 0) && (
+
+ )}
+ >
+ )
+}
diff --git a/apps/web/components/review-memories-modal.tsx b/apps/web/components/review-memories-modal.tsx
new file mode 100644
index 00000000..37a36fec
--- /dev/null
+++ b/apps/web/components/review-memories-modal.tsx
@@ -0,0 +1,477 @@
+"use client"
+
+import { dmSansClassName } from "@/lib/fonts"
+import {
+ type InferredMemory,
+ useInferredMemories,
+ useInferredMemoryCache,
+ useReviewInferredMemory,
+} from "@/hooks/use-inferred-memories"
+import { cn } from "@lib/utils"
+import { Dialog, DialogContent } from "@ui/components/dialog"
+import { Check, Undo2, X } from "lucide-react"
+import {
+ AnimatePresence,
+ motion,
+ useMotionValue,
+ useReducedMotion,
+ useTransform,
+} from "motion/react"
+import { useCallback, useEffect, useRef, useState } from "react"
+
+// Distance / velocity past which a drag commits to a decision.
+const SWIPE_OFFSET = 120
+const SWIPE_VELOCITY = 600
+const STACK_DEPTH = 3 // how many cards are visible at once
+
+type Decision = "approve" | "decline" | "skip"
+
+export function ReviewMemoriesModal({
+ open,
+ onOpenChange,
+ containerTag,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ containerTag: string | undefined
+}) {
+ const { data: memories = [] } = useInferredMemories(containerTag)
+ const { mutate: review } = useReviewInferredMemory(containerTag)
+ const queue = useInferredMemoryCache(containerTag)
+ const reduceMotion = useReducedMotion()
+
+ // Snapshot the queue when the modal opens so cache updates from the
+ // mutation don't reshuffle the stack mid-session.
+ const [cards, setCards] = useState([])
+ const [index, setIndex] = useState(0)
+ const [exitDir, setExitDir] = useState(1)
+ const [approved, setApproved] = useState(0)
+ const [history, setHistory] = useState<{ id: string; decision: Decision }[]>(
+ [],
+ )
+ // Synchronous source of truth so rapid swipes/undos don't read stale state.
+ const indexRef = useRef(0)
+ const historyRef = useRef<{ id: string; decision: Decision }[]>([])
+
+ // Intentionally keyed on `open` only: including `memories` would reset the
+ // stack every time the review mutation trims the cache. The queue is loaded
+ // by the time the trigger renders, so a snapshot on open is sufficient.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: snapshot-on-open
+ useEffect(() => {
+ if (!open) return
+ setCards(memories)
+ setIndex(0)
+ indexRef.current = 0
+ setApproved(0)
+ setHistory([])
+ historyRef.current = []
+ }, [open])
+
+ const current = cards[index]
+ const done = cards.length > 0 && index >= cards.length
+
+ const decide = useCallback(
+ (decision: Decision) => {
+ const card = cards[indexRef.current]
+ if (!card) return
+ // 1 = fly right (approve), -1 = fly left (decline), 0 = drop down (skip)
+ setExitDir(decision === "approve" ? 1 : decision === "decline" ? -1 : 0)
+ if (decision === "approve") {
+ setApproved((n) => n + 1)
+ review({ memoryId: card.id, action: "approve" })
+ } else if (decision === "decline") {
+ review({ memoryId: card.id, action: "decline" })
+ } else {
+ // Skip persists nothing server-side, but drop it from the cached queue
+ // so the trigger's live count falls and the prompt can be dismissed.
+ queue.drop(card.id)
+ }
+ historyRef.current = [...historyRef.current, { id: card.id, decision }]
+ setHistory(historyRef.current)
+ indexRef.current += 1
+ setIndex(indexRef.current)
+ },
+ [cards, review, queue.drop],
+ )
+
+ const canUndo = history.length > 0
+
+ // Step back one card and revert its server-side review (skip had none).
+ // Reads from refs so consecutive/rapid undos each target the correct card.
+ const undo = useCallback(() => {
+ const last = historyRef.current[historyRef.current.length - 1]
+ if (!last) return
+ historyRef.current = historyRef.current.slice(0, -1)
+ setHistory(historyRef.current)
+ if (last.decision === "approve") {
+ setApproved((n) => Math.max(0, n - 1))
+ review({ memoryId: last.id, action: "undo" })
+ } else if (last.decision === "decline") {
+ review({ memoryId: last.id, action: "undo" })
+ } else {
+ const card = cards.find((c) => c.id === last.id)
+ if (card) queue.restore(card)
+ }
+ indexRef.current = Math.max(0, indexRef.current - 1)
+ setIndex(indexRef.current)
+ }, [review, cards, queue.restore])
+
+ // Keyboard: ← decline, → approve, ↓/space skip.
+ useEffect(() => {
+ if (!open) return
+ const onKey = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
+ e.preventDefault()
+ undo()
+ return
+ }
+ if (done || !current) return
+ if (e.key === "ArrowRight") decide("approve")
+ else if (e.key === "ArrowLeft") decide("decline")
+ else if (e.key === "ArrowDown" || e.key === " ") {
+ e.preventDefault()
+ decide("skip")
+ }
+ }
+ window.addEventListener("keydown", onKey)
+ return () => window.removeEventListener("keydown", onKey)
+ }, [open, done, current, decide, undo])
+
+ const visible = cards.slice(index, index + STACK_DEPTH)
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ Suggested memories
+
+
+ Memories we inferred — keep the ones that fit
+
+
+
onOpenChange(false)}
+ className="-mr-1 -mt-1 rounded-full p-1.5 text-fg-faint transition-colors hover:bg-white/[0.06] hover:text-fg-muted"
+ aria-label="Close"
+ >
+
+
+
+
+ {/* Progress dots + undo + counter */}
+ {cards.length > 0 && (
+
+
+ {cards.map((c, i) => (
+
+ ))}
+
+
+ {canUndo && (
+
+
+ Undo
+
+ )}
+
+ {Math.min(index + 1, cards.length)}/{cards.length}
+
+
+
+ )}
+
+ {/* Card deck */}
+
+
+ {/* Nova glow halo behind the deck */}
+ {!done && (
+
+ )}
+ {done ? (
+
+ ) : (
+
+ {visible.map((mem, i) => (
+
+ ))}
+
+ )}
+
+
+
+ {/* Controls */}
+ {!done && current && (
+
+ decide("decline")}
+ whileHover={{ scale: 1.07 }}
+ whileTap={{ scale: 0.9 }}
+ transition={{ duration: 0.15 }}
+ className="flex size-14 items-center justify-center rounded-full border border-[#ff6b6b]/30 bg-[#ff6b6b]/10 text-[#ff6b6b] transition-colors hover:bg-[#ff6b6b]/20"
+ >
+
+
+ decide("skip")}
+ whileTap={{ scale: 0.94 }}
+ transition={{ duration: 0.15 }}
+ className="px-4 text-[13px] font-medium text-fg-faint transition-colors hover:text-fg-primary"
+ >
+ Skip
+
+ decide("approve")}
+ whileHover={{ scale: 1.07 }}
+ whileTap={{ scale: 0.9 }}
+ transition={{ duration: 0.15 }}
+ className="flex size-14 items-center justify-center rounded-full border border-[#4ade80]/30 bg-[#4ade80]/10 text-[#4ade80] transition-colors hover:bg-[#4ade80]/20"
+ >
+
+
+
+ )}
+
+
+ )
+}
+
+function SwipeCard({
+ memory,
+ depth,
+ globalIndex,
+ isTop,
+ reduceMotion,
+ onDecide,
+}: {
+ memory: InferredMemory
+ depth: number
+ globalIndex: number
+ isTop: boolean
+ reduceMotion: boolean
+ onDecide: (d: Decision) => void
+}) {
+ const x = useMotionValue(0)
+ const rotate = useTransform(x, [-200, 200], reduceMotion ? [0, 0] : [-8, 8])
+
+ // Slack-style: the whole card washes green (keep) / red (decline) and the
+ // border + verdict pill intensify the closer you get to committing.
+ const wash = useTransform(
+ x,
+ [-150, -30, 0, 30, 150],
+ [
+ "rgba(239,68,68,0.34)",
+ "rgba(239,68,68,0.04)",
+ "rgba(255,255,255,0)",
+ "rgba(74,222,128,0.04)",
+ "rgba(74,222,128,0.34)",
+ ],
+ )
+ const borderColor = useTransform(
+ x,
+ [-150, -30, 0, 30, 150],
+ [
+ "rgba(239,68,68,0.7)",
+ "rgba(255,255,255,0.08)",
+ "rgba(255,255,255,0.08)",
+ "rgba(255,255,255,0.08)",
+ "rgba(74,222,128,0.7)",
+ ],
+ )
+ const keepOpacity = useTransform(x, [30, 120], [0, 1])
+ const keepScale = useTransform(x, [30, 120], [0.7, 1])
+ const declineOpacity = useTransform(x, [-120, -30], [1, 0])
+ const declineScale = useTransform(x, [-120, -30], [1, 0.7])
+
+ const scale = 1 - depth * 0.055
+ const y = depth * 16
+
+ return (
+ {
+ if (reduceMotion)
+ return { opacity: 0, transition: { duration: 0.25 } }
+ // dir 0 = skip → drop straight down; ±1 = fly out sideways.
+ if (dir === 0)
+ return {
+ y: 480,
+ opacity: 0,
+ scale: 0.9,
+ transition: { duration: 0.45, ease: [0.4, 0, 0.2, 1] },
+ }
+ return {
+ x: dir * 540,
+ opacity: 0,
+ rotate: dir * 12,
+ transition: { duration: 0.42, ease: [0.4, 0, 0.2, 1] },
+ }
+ },
+ }}
+ exit="exit"
+ initial={{ scale: scale - 0.04, y: y + 10, opacity: 0 }}
+ animate={{
+ scale,
+ y,
+ opacity: depth === 0 ? 1 : depth === 1 ? 0.8 : 0.45,
+ }}
+ transition={{ duration: 0.28, ease: [0.4, 0, 0.2, 1] }}
+ drag={isTop ? "x" : false}
+ dragSnapToOrigin
+ dragElastic={0.7}
+ whileDrag={{ scale: 1.02 }}
+ onDragEnd={(_e, info) => {
+ if (!isTop) return
+ const { offset, velocity } = info
+ if (offset.x > SWIPE_OFFSET || velocity.x > SWIPE_VELOCITY)
+ onDecide("approve")
+ else if (offset.x < -SWIPE_OFFSET || velocity.x < -SWIPE_VELOCITY)
+ onDecide("decline")
+ }}
+ >
+
+ {/* Full-card color wash that tracks the swipe */}
+ {isTop && (
+
+ )}
+
+ {/* Verdict pills — slide/scale in toward the swipe direction */}
+ {isTop && (
+ <>
+
+ Keep
+
+
+ Decline
+
+ >
+ )}
+
+
+
+
+ Inferred
+
+
+
+
+
+ {memory.parentCount > 0 && (
+
+ {memory.parentCount === 1
+ ? "from 1 memory"
+ : `from ${memory.parentCount} memories`}
+
+ )}
+ {relativeTime(memory.createdAt)}
+
+
+
+
+ )
+}
+
+function DoneState({ approved, total }: { approved: number; total: number }) {
+ return (
+
+
+
+
+
+
All caught up
+
+ {approved > 0
+ ? `You kept ${approved} of ${total} suggested ${total === 1 ? "memory" : "memories"}.`
+ : "Nothing kept this time — they'll stay tucked away."}
+
+
+
+ )
+}
+
+function relativeTime(iso: string): string {
+ const then = new Date(iso).getTime()
+ if (Number.isNaN(then)) return ""
+ const diff = Date.now() - then
+ const day = 86_400_000
+ if (diff < day) return "today"
+ if (diff < 2 * day) return "yesterday"
+ if (diff < 7 * day) return `${Math.floor(diff / day)}d ago`
+ if (diff < 30 * day) return `${Math.floor(diff / (7 * day))}w ago`
+ return `${Math.floor(diff / (30 * day))}mo ago`
+}
diff --git a/apps/web/components/select-spaces-modal.tsx b/apps/web/components/select-spaces-modal.tsx
index 528a2f86..03b4ae76 100644
--- a/apps/web/components/select-spaces-modal.tsx
+++ b/apps/web/components/select-spaces-modal.tsx
@@ -3,8 +3,9 @@
import { useState, useMemo, useEffect, useCallback, useRef } from "react"
import Image from "next/image"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
-import { Dialog, DialogContent } from "@repo/ui/components/dialog"
+import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { Drawer, DrawerContent, DrawerTitle } from "@repo/ui/components/drawer"
+import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import { cn } from "@lib/utils"
import { useIsMobile } from "@hooks/use-mobile"
import * as DialogPrimitive from "@radix-ui/react-dialog"
@@ -21,10 +22,11 @@ import {
Loader,
Pencil,
Check,
+ Lock,
} from "lucide-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
-import { DEFAULT_PROJECT_ID } from "@lib/constants"
+import { DEFAULT_PROJECT_ID, SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import type { ContainerTagListType } from "@lib/types"
@@ -50,6 +52,7 @@ import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import NovaOrb from "@/components/nova/nova-orb"
import { AutoSpaceIcon } from "@/components/nova/auto-space-icon"
import { SpaceGlyph } from "./space-glyph"
+import { useHasCompanyBrain } from "@/hooks/use-company-brain"
interface SelectSpacesModalProps {
isOpen: boolean
@@ -130,8 +133,15 @@ export function SelectSpacesModal({
)
const pluginMetaMap = usePluginSpaceMeta(pluginTags)
+ const hasCompanyBrain = useHasCompanyBrain()
const allSpaces = useMemo(() => {
+ const rest = projects
+ .filter((p) => p.containerTag !== DEFAULT_PROJECT_ID)
+ .sort(compareSpacesUserFirst)
+ // Company brain orgs use real Private + Team Brain spaces; skip the
+ // synthetic "My Space" default that would otherwise duplicate Private.
+ if (hasCompanyBrain) return rest
const defaultSpace = {
id: "default",
name: "My Space",
@@ -142,11 +152,8 @@ export function SelectSpacesModal({
createdAt: "",
updatedAt: "",
} as ContainerTagListType
- const rest = projects
- .filter((p) => p.containerTag !== DEFAULT_PROJECT_ID)
- .sort(compareSpacesUserFirst)
return [defaultSpace, ...rest]
- }, [projects])
+ }, [projects, hasCompanyBrain])
const { categories, connectedCatalogIds } = useMemo<{
categories: Category[]
@@ -588,6 +595,20 @@ export function SelectSpacesModal({
)
const isDefault = project.containerTag === DEFAULT_PROJECT_ID
const isOwnSpace = isOwnConversationSpace(project, user?.id)
+ const isCbSpace =
+ hasCompanyBrain && !plugin && !isOwnSpace && !!project.visibility
+ const isShared = project.visibility === "public"
+ const orgName = org?.name ?? "your team"
+ const orgMembers = org?.members ?? []
+ const memberCount = orgMembers.length
+ const isDefaultBrain = project.containerTag === SHARED_TEAM_BRAIN_TAG
+ const descriptor = isCbSpace
+ ? isShared
+ ? `${orgName} · ${memberCount} ${
+ memberCount === 1 ? "member" : "members"
+ }`
+ : "Only you"
+ : null
const canEdit = !isDefault && !plugin && !isOwnSpace
const canBulkDelete = enableDelete && !isDefault
const isEditing = editingProject?.containerTag === project.containerTag
@@ -716,6 +737,54 @@ export function SelectSpacesModal({
)
) : isOwnSpace ? (
+ ) : isCbSpace ? (
+ isShared ? (
+
+ {orgMembers.slice(0, 3).map((m, i) => (
+ 0 && "-ml-2",
+ )}
+ >
+
+
+ {(m.user?.name ?? m.user?.email ?? "U")
+ .charAt(0)
+ .toUpperCase()}
+
+
+ ))}
+ {memberCount > 3 && (
+
+ +{memberCount - 3}
+
+ )}
+
+ ) : (
+
+
+
+
+ {(user?.name ?? user?.email ?? "U")
+ .charAt(0)
+ .toUpperCase()}
+
+
+
+
+
+
+ )
) : (
)}
-
- {plugin ? (
- <>
- {plugin.label}
- {pluginIdLabel && (
-
- · {pluginIdLabel}
-
- )}
- >
- ) : (
- displayName
+
+
+ {plugin ? (
+ <>
+ {plugin.label}
+ {pluginIdLabel && (
+
+ · {pluginIdLabel}
+
+ )}
+ >
+ ) : (
+ displayName
+ )}
+
+ {descriptor && (
+
+ {descriptor}
+
)}
+ {isCbSpace && isDefaultBrain && (
+
+ Default
+
+ )}
)}
{canEdit && !isEditing && !isBulkDeleteMode && (
@@ -787,6 +868,7 @@ export function SelectSpacesModal({
enableDelete,
handleEditKeyDown,
handleSelect,
+ hasCompanyBrain,
isBulkDeleteMode,
onDeleteRequest,
pluginMetaMap,
@@ -795,6 +877,11 @@ export function SelectSpacesModal({
toggleBulkDeleteTag,
updateProjectMutation.isPending,
user?.id,
+ org?.name,
+ org?.members,
+ user?.email,
+ user?.image,
+ user?.name,
],
)
@@ -960,7 +1047,36 @@ export function SelectSpacesModal({
>
)}
- {mainList.map(renderRow)}
+ {hasCompanyBrain && recentProjects.length === 0
+ ? (() => {
+ const shared = mainList.filter(
+ (p) => p.visibility === "public",
+ )
+ const personal = mainList.filter(
+ (p) => p.visibility !== "public",
+ )
+ return (
+ <>
+ {shared.length > 0 && (
+ <>
+
+ Shared
+
+ {shared.map(renderRow)}
+ >
+ )}
+ {personal.length > 0 && (
+ <>
+
+ Personal
+
+ {personal.map(renderRow)}
+ >
+ )}
+ >
+ )
+ })()
+ : mainList.map(renderRow)}
)}
@@ -1128,14 +1244,14 @@ export function SelectSpacesModal({
>
-
Select Space
-
+
{isBulkDeleteMode
? "Choose spaces to permanently delete"
diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx
index f4ab9a52..d054e608 100644
--- a/apps/web/components/settings/account.tsx
+++ b/apps/web/components/settings/account.tsx
@@ -4,15 +4,8 @@ import { dmSans125ClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { useAuth } from "@lib/auth-context"
import { authClient } from "@lib/auth"
-import { useOrgSummaries } from "@/hooks/use-org-summaries"
-import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
-import {
- PLAN_RANK,
- useTokenUsage,
- type PlanType,
-} from "@/hooks/use-token-usage"
-import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
+import { Popover, PopoverContent } from "@ui/components/popover"
import {
Select,
SelectContent,
@@ -28,33 +21,39 @@ import {
} from "@ui/components/dropdown-menu"
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
import * as DialogPrimitive from "@radix-ui/react-dialog"
-import { useCustomer } from "autumn-js/react"
import { useMutation, useQuery } from "@tanstack/react-query"
import {
- Check,
LoaderIcon,
ChevronDown,
- Building2,
Users,
UserPlus,
Mail,
MoreHorizontal,
UserMinus,
X,
+ Pencil,
Tag,
Plus,
} from "lucide-react"
-import { useMemo, useRef, useState } from "react"
+import { useQueryState } from "nuqs"
+import { useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { useContainerTags } from "@/hooks/use-container-tags"
import { PopoverAnchor } from "@ui/components/popover"
+import { OrgContext } from "@/components/settings/org-context"
+import { OrgPlanBadge } from "@/components/org-plan-badge"
+import { useTokenUsage } from "@/hooks/use-token-usage"
+import { useOrgSummaries } from "@/hooks/use-org-summaries"
+import { useCustomer } from "autumn-js/react"
+import { FileText, Layers, Plug, Search } from "lucide-react"
+import { $fetch } from "@lib/api"
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
{children}
@@ -134,16 +133,11 @@ function isPendingInvitation(invitation: {
}
export default function Account() {
- const {
- user,
- org,
- organizations: allOrgs,
- setActiveOrg,
- refetchActiveOrg,
- } = useAuth()
+ const { user, org, refetchActiveOrg, refetchOrganizations } = useAuth()
const autumn = useCustomer()
- const [switchingOrgId, setSwitchingOrgId] = useState(null)
- const [orgMenuOpen, setOrgMenuOpen] = useState(false)
+ const { currentPlan, searchesUsed } = useTokenUsage(autumn)
+ const { data: orgSummaries } = useOrgSummaries()
+ const orgSummary = orgSummaries?.find((s) => s.orgId === org?.id)
const [inviteDialogOpen, setInviteDialogOpen] = useState(false)
const [inviteEmail, setInviteEmail] = useState("")
const [inviteRole, setInviteRole] = useState("member")
@@ -155,6 +149,8 @@ export default function Account() {
>([])
const [tagQuery, setTagQuery] = useState("")
const [tagDropdownOpen, setTagDropdownOpen] = useState(false)
+ const [isEditingOrgName, setIsEditingOrgName] = useState(false)
+ const [orgNameDraft, setOrgNameDraft] = useState("")
const tagInputRef = useRef(null)
const tagAnchorRef = useRef(null)
const { allProjects: allContainerTags } = useContainerTags()
@@ -173,28 +169,21 @@ export default function Account() {
const showAccessType = inviteRole === "member"
const showTagPicker =
inviteRole === "member" && inviteAccessType === "restricted"
- const canSwitchOrg = (allOrgs?.length ?? 0) > 1
- const { data: orgSummaries } = useOrgSummaries()
- const handleOrgSwitch = async (orgSlug: string, orgId: string) => {
- if (orgId === org?.id) return
- setSwitchingOrgId(orgId)
- try {
- await setActiveOrg(orgSlug)
- window.location.reload()
- } catch (error) {
- console.error("Failed to switch organization:", error)
- setSwitchingOrgId(null)
- }
- }
+ useEffect(() => {
+ setOrgNameDraft(org?.name ?? "")
+ setIsEditingOrgName(false)
+ }, [org?.name])
- const { currentPlan } = useTokenUsage(autumn)
+ // Deep link: ?invite=1 (e.g. from the dashboard) opens the invite dialog.
+ // Consumed below, once the role is known and only for admins/owners.
+ const [inviteParam, setInviteParam] = useQueryState("invite")
const activeMemberRoleQuery = useQuery({
queryKey: ["organization", org?.id, "active-member-role"],
queryFn: async () => {
if (!org?.id) return null
- const result = await authClient.organization.getActiveMemberRole({
+ const result = await authClient.organization.getActiveMember({
query: { organizationId: org.id },
})
if (result.error) {
@@ -210,24 +199,43 @@ export default function Account() {
() => org?.members?.find((member) => member.userId === user?.id) ?? null,
[org?.members, user?.id],
)
+ // Only treat as a personal single-member org when members are actually loaded —
+ // otherwise default to least privilege (member), never owner.
+ const membersLoaded = Array.isArray(org?.members)
const isSingleMemberPersonalOrg =
+ membersLoaded &&
(org?.members?.length ?? 0) <= 1 &&
(!org?.members?.[0]?.userId || org.members[0].userId === user?.id)
- const currentRole = isSingleMemberPersonalOrg
- ? "owner"
- : (
- activeMemberRoleQuery.data ??
- currentMember?.role ??
- "member"
- ).toLowerCase()
+ const currentRole = (
+ activeMemberRoleQuery.data ??
+ currentMember?.role ??
+ (isSingleMemberPersonalOrg ? "owner" : "member")
+ ).toLowerCase()
const canManageTeam = currentRole === "owner" || currentRole === "admin"
const isOwner = currentRole === "owner"
+ // Consume ?invite=1 only after the role resolves, and only for managers.
+ useEffect(() => {
+ if (inviteParam !== "1" || activeMemberRoleQuery.isLoading) return
+ if (canManageTeam) setInviteDialogOpen(true)
+ setInviteParam(null)
+ }, [
+ inviteParam,
+ activeMemberRoleQuery.isLoading,
+ canManageTeam,
+ setInviteParam,
+ ])
+
const pendingInvitations = useMemo(
() => (org?.invitations ?? []).filter(isPendingInvitation),
[org?.invitations],
)
+ const showTeamCard =
+ !canManageTeam ||
+ pendingInvitations.length > 0 ||
+ (org?.members?.length ?? 0) > 1
+
const resetInviteForm = () => {
setInviteEmail("")
setInviteRole("member")
@@ -345,40 +353,50 @@ export default function Account() {
},
})
+ const updateOrgNameMutation = useMutation({
+ mutationFn: async (name: string) => {
+ if (!org?.id) throw new Error("No active organization")
+ const trimmed = name.trim()
+ if (!trimmed) throw new Error("Enter an organization name")
+ const result = await authClient.organization.update({
+ organizationId: org.id,
+ data: { name: trimmed },
+ })
+ if (result.error) {
+ throw new Error(
+ result.error.message ?? "Failed to update organization name",
+ )
+ }
+ return trimmed
+ },
+ onSuccess: async (name) => {
+ setOrgNameDraft(name)
+ setIsEditingOrgName(false)
+ await Promise.all([refetchActiveOrg(), refetchOrganizations()])
+ toast.success("Organization name updated")
+ },
+ onError: (error) => {
+ toast.error(getErrorMessage(error, "Failed to update organization name"))
+ },
+ })
+
const handleInviteSubmit = (event: React.FormEvent) => {
event.preventDefault()
if (!canManageTeam || inviteMemberMutation.isPending) return
inviteMemberMutation.mutate()
}
- const planByOrgId = useMemo(() => {
- const map = new Map()
- for (const summary of orgSummaries ?? []) {
- map.set(summary.orgId, summary.plan)
+ const handleOrgNameSubmit = (event: React.FormEvent) => {
+ event.preventDefault()
+ if (!canManageTeam || updateOrgNameMutation.isPending) return
+ const trimmed = orgNameDraft.trim()
+ if (!trimmed || trimmed === org?.name) {
+ setOrgNameDraft(org?.name ?? "")
+ setIsEditingOrgName(false)
+ return
}
- return map
- }, [orgSummaries])
-
- const sortedOrgsForMenu = useMemo(() => {
- if (!allOrgs?.length) return []
- return [...allOrgs].sort((a, b) => {
- const planA = resolveOrgPlan(
- a.id,
- a.id === org?.id,
- currentPlan,
- planByOrgId,
- )
- const planB = resolveOrgPlan(
- b.id,
- b.id === org?.id,
- currentPlan,
- planByOrgId,
- )
- const rankDiff = PLAN_RANK[planB] - PLAN_RANK[planA]
- if (rankDiff !== 0) return rankDiff
- return a.name.localeCompare(b.name)
- })
- }, [allOrgs, org?.id, currentPlan, planByOrgId])
+ updateOrgNameMutation.mutate(trimmed)
+ }
const memberSince = user?.createdAt
? new Date(user.createdAt).toLocaleDateString("en-US", {
@@ -388,30 +406,29 @@ export default function Account() {
: "—"
return (
-
-
- Profile Details
+
+
-
- {/* Avatar + Name/Email */}
-
-
+
+ {/* Identity */}
+
+
-
+
{user?.name?.charAt(0) ?? "U"}
-
+
{user?.name ?? "—"}
@@ -419,136 +436,208 @@ export default function Account() {
{user?.email ?? "—"}
+
+
+
-
-
-
+
+
Organization
-
- {
- if (canSwitchOrg) setOrgMenuOpen(open)
- }}
- >
-
+ {isEditingOrgName ? (
+
+ ) : (
+
{org?.name ?? "Personal"}
- {canSwitchOrg && (
-
- )}
-
- {canSwitchOrg && (
-
- {sortedOrgsForMenu.map((organization) => {
- const isCurrent = organization.id === org?.id
- const isSwitching = switchingOrgId === organization.id
- const plan = resolveOrgPlan(
- organization.id,
- isCurrent,
- currentPlan,
- planByOrgId,
- )
- return (
-
- handleOrgSwitch(
- organization.slug,
- organization.id,
- )
- }
- className={cn(
- "w-full flex items-center gap-3 px-3 py-2.5 rounded-[8px] text-left transition-colors",
- isCurrent
- ? "bg-white/5"
- : "hover:bg-white/5 cursor-pointer",
- "disabled:opacity-60 disabled:cursor-default",
- dmSans125ClassName(),
- )}
- >
-
-
- {organization.name}
-
- {isSwitching ? (
-
- ) : isCurrent ? (
-
- ) : (
-
- )}
-
-
- )
- })}
-
- )}
-
+ {currentRole}
+
+ {canManageTeam ? (
+
{
+ setOrgNameDraft(org?.name ?? "")
+ setIsEditingOrgName(true)
+ }}
+ className="inline-flex size-5 shrink-0 items-center justify-center rounded-md text-[#737373] transition-colors hover:bg-white/5 hover:text-[#FAFAFA]"
+ >
+
+
+ ) : null}
+
+ )}
-
-
+
Member since
-
-
+
{memberSince}
-
+
-
-
+
+ Overview
+
+ {[
+ {
+ label: "Memories",
+ value: orgSummary?.documentCount,
+ icon: FileText,
+ },
+ {
+ label: "Spaces",
+ value: orgSummary?.containerTagCount,
+ icon: Layers,
+ },
+ {
+ label: "Connections",
+ value: orgSummary?.activeConnectors,
+ icon: Plug,
+ },
+ { label: "Searches", value: searchesUsed, icon: Search },
+ ].map(({ label, value, icon: Icon }) => (
+
+
+
+
+
+
+ {typeof value === "number" ? value.toLocaleString() : "—"}
+
+
+ {label}
+
+
+
+ ))}
+
+
+
+ {canManageTeam &&
}
+
+
+
+
+
-
Team members
+
+ Team members
+ {(org?.members?.length ?? 0) > 0 && (
+
+ {org?.members?.length}
+
+ )}
+
Invite people into {org?.name ?? "your organization"} and manage
@@ -556,17 +645,6 @@ export default function Account() {
- {(org?.members?.length ?? 0) > 0 && (
-
- {org?.members?.length}{" "}
- {org?.members?.length === 1 ? "member" : "members"}
-
- )}
{canManageTeam && (
-
-
- {!canManageTeam && (
-
-
-
+ {showTeamCard && (
+
+
+ {!canManageTeam && (
+
+
+
+
+
+ Only organization owners and admins can invite teammates or
+ change roles.
+
-
- Only organization owners and admins can invite teammates or
- change roles.
-
-
- )}
+ )}
- {pendingInvitations.length > 0 && (
-
-
- Pending invitations
-
-
-
- )}
-
- {org?.members && org.members.length > 0 ? (
-
- {[...org.members]
- .sort((a, b) => {
- const rolePriority = (r: string) =>
- r === "owner" ? 0 : r === "admin" ? 1 : 2
- const diff =
- rolePriority(a.role.toLowerCase()) -
- rolePriority(b.role.toLowerCase())
- if (diff !== 0) return diff
- return (a.user?.name ?? "").localeCompare(
- b.user?.name ?? "",
- )
- })
- .map((m, idx) => {
- const isYou = m.userId === user?.id
- const memberRole = m.role.toLowerCase()
- const name = m.user?.name ?? m.user?.email ?? "Unknown"
- const canEditMember =
- canManageTeam && !isYou && memberRole !== "owner"
- return (
+ {pendingInvitations.length > 0 && (
+
+
+ Pending invitations
+
+
+ {pendingInvitations.map((invitation) => (
0 && "border-t border-white/[0.04]",
- )}
+ key={invitation.id}
+ className="flex items-center gap-3 px-3 py-2.5 border-t border-white/[0.04] first:border-t-0 bg-white/[0.015]"
>
-
-
-
- {(name.charAt(0) || "U").toUpperCase()}
-
-
-
-
-
+
+
+
+
+ {invitation.email}
+
+
+ Invited as {formatRole(invitation.role)}
+
+
+ {canManageTeam && (
+
+
+ cancelInvitationMutation.mutate(invitation.id)
+ }
+ className="flex size-8 items-center justify-center rounded-[8px] text-[#8A5247] hover:bg-[#1A0F0C]/60 hover:text-[#C73B1B] disabled:opacity-50"
+ aria-label={`Cancel invitation for ${invitation.email}`}
+ title="Cancel invitation"
>
- {name}
-
- {isYou && (
+
+
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {org?.members && org.members.length > 0 && (
+
+ {[...org.members]
+ .sort((a, b) => {
+ const rolePriority = (r: string) =>
+ r === "owner" ? 0 : r === "admin" ? 1 : 2
+ const diff =
+ rolePriority(a.role.toLowerCase()) -
+ rolePriority(b.role.toLowerCase())
+ if (diff !== 0) return diff
+ return (a.user?.name ?? "").localeCompare(
+ b.user?.name ?? "",
+ )
+ })
+ .map((m) => {
+ const isYou = m.userId === user?.id
+ const memberRole = m.role.toLowerCase()
+ const name = m.user?.name ?? m.user?.email ?? "Unknown"
+ const canEditMember =
+ canManageTeam && !isYou && memberRole !== "owner"
+ return (
+
+
+
+
+ {(name.charAt(0) || "U").toUpperCase()}
+
+
+
+
- You
+ {name}
+
+ {isYou && (
+
+ You
+
+ )}
+
+ {m.user?.email && (
+
+ {m.user.email}
)}
- {m.user?.email && (
- {
+ if (value === memberRole) return
+ updateMemberRoleMutation.mutate({
+ memberId: m.id,
+ role: value as InviteRole,
+ })
+ }}
>
- {m.user.email}
-
+
+
+
+
+ Member
+ Admin
+
+
+ ) : (
+
)}
-
- {canEditMember ? (
- {
- if (value === memberRole) return
- updateMemberRoleMutation.mutate({
- memberId: m.id,
- role: value as InviteRole,
- })
- }}
- >
-
-
-
-
- Member
- Admin
-
-
- ) : (
-
- )}
- {canEditMember && (
-
-
-
-
-
-
-
-
- removeMemberMutation.mutate(m.id)
- }
- disabled={
- removeMemberMutation.isPending || !isOwner
- }
- >
-
- Remove member
-
-
-
- )}
-
- )
- })}
-
- ) : (
-
-
-
-
-
-
- Just you for now
-
-
- Invite teammates to start collaborating.
-
-
-
- )}
+ {canEditMember && (
+
+
+
+
+
+
+
+
+ removeMemberMutation.mutate(m.id)
+ }
+ disabled={
+ removeMemberMutation.isPending || !isOwner
+ }
+ >
+
+ Remove member
+
+
+
+ )}
+
+ )
+ })}
+
+ )}
+
-
+ )}
setInviteDialogOpen(false)}
className={cn(
dmSans125ClassName(),
- "h-9 rounded-full border border-[#161F2C] bg-[#0D121A] px-4 text-[13px] font-medium text-[#737373] transition-colors hover:bg-[#14161A] hover:text-white",
+ "h-9 rounded-full px-4 text-[13px] font-medium text-[#737373] transition-colors hover:bg-white/[0.04] hover:text-white",
)}
>
Cancel
@@ -1176,6 +1229,13 @@ export default function Account() {
inviteMemberMutation.isPending ||
(showTagPicker && inviteAssignments.length === 0)
}
+ title={
+ showTagPicker && inviteAssignments.length === 0
+ ? "Select at least one space for restricted access"
+ : !inviteEmail.trim()
+ ? "Enter an email address to send an invite"
+ : undefined
+ }
className={cn(
dmSans125ClassName(),
"inline-flex h-9 items-center justify-center gap-2 rounded-full bg-[#14161A] px-4 text-[13px] font-semibold text-[#FAFAFA] shadow-inside-out transition-colors hover:bg-[#121820] disabled:cursor-not-allowed disabled:opacity-45",
@@ -1195,3 +1255,70 @@ export default function Account() {
)
}
+
+function DigestPreferences() {
+ const { data, isLoading } = useQuery({
+ queryKey: ["digest-preferences"],
+ queryFn: async () => {
+ const res = await $fetch("@get/digests/preferences")
+ if (res.error) throw new Error("Failed")
+ return res.data as { digestOptOut: boolean }
+ },
+ })
+
+ const mutation = useMutation({
+ mutationFn: async (digestOptOut: boolean) => {
+ const res = await $fetch("@post/digests/preferences", {
+ body: { digestOptOut },
+ })
+ if (res.error) throw new Error("Failed")
+ return res.data as { digestOptOut: boolean }
+ },
+ onError: () => toast.error("Failed to update preference"),
+ })
+
+ const optOut = mutation.data?.digestOptOut ?? data?.digestOptOut ?? false
+
+ return (
+
+ Notifications
+
+
+
+
+ Weekly digest
+
+
+ Personalized weekly recap of your memories, delivered every Monday
+
+
+
mutation.mutate(!optOut)}
+ className={cn(
+ "relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
+ !optOut ? "bg-[#2563FF]" : "bg-white/10",
+ )}
+ >
+
+
+
+
+
+ )
+}
diff --git a/apps/web/components/settings/billing.tsx b/apps/web/components/settings/billing.tsx
index 07d0fdb1..93909fc2 100644
--- a/apps/web/components/settings/billing.tsx
+++ b/apps/web/components/settings/billing.tsx
@@ -4,15 +4,26 @@ import { dmSans125ClassName } from "@/lib/fonts"
import { PLAN_DISPLAY_NAMES, useTokenUsage } from "@/hooks/use-token-usage"
import { cn } from "@lib/utils"
import { useAuth } from "@lib/auth-context"
+import { getCanceledSubscription } from "@lib/queries"
import {
Dialog,
DialogClose,
DialogContent,
DialogTrigger,
} from "@ui/components/dialog"
+import { Logo } from "@ui/assets/Logo"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useCustomer } from "autumn-js/react"
+import { usePostHog } from "@lib/posthog"
import {
+ CANCEL_REASONS,
+ cancelReasonNeedsDetail,
+ type CancelReasonValue,
+} from "./cancel-reasons"
+import {
+ Check,
+ ChevronLeft,
+ ChevronRight,
Coins,
ExternalLink,
LoaderIcon,
@@ -21,15 +32,52 @@ import {
Settings,
X,
} from "lucide-react"
-import { useEffect, useMemo, useState } from "react"
+import { useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
const API_BASE =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+const BOOK_CALL_HREF = "https://cal.com/maheshthedev/15min"
+
+function GoogleMeetIcon({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
const CREDIT_FEATURE_ID = "usd_credits"
const TOP_UP_PLAN_ID = "credits_topup"
const TOP_UP_AMOUNTS = [10, 25, 50, 100] as const
+const PLAN_CARD_ACTION_CLASS =
+ "inline-flex h-10 w-full items-center justify-center gap-2 rounded-[10px] text-[14px] font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-60"
+
+const SURFACE_SHADOW =
+ "0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset"
type BillingInvoice = {
planIds?: string[]
@@ -61,6 +109,106 @@ type AutoTopupsResponse =
}
| { ok: false; reason: string; message?: string }
+type PlanCardDefinition = {
+ id: "free" | "pro" | "max" | "scale" | "enterprise"
+ name: string
+ price: string
+ period: string
+ credits: string
+ productId: "api_free" | "api_pro" | "api_max" | "api_scale" | "api_enterprise"
+ description: string
+ includesFrom?: string
+ features: string[]
+ isContactSales?: boolean
+ mostPopular?: boolean
+}
+
+const PLAN_CARDS: PlanCardDefinition[] = [
+ {
+ id: "free",
+ name: "Free",
+ price: "$0",
+ period: "",
+ credits: "$5",
+ productId: "api_free",
+ description: "Try supermemory with no commitment",
+ features: [
+ "Pay-as-you-go after $5 runs out",
+ "Full search and memory access",
+ "Email support",
+ ],
+ },
+ {
+ id: "pro",
+ name: "Pro",
+ price: "$19",
+ period: "/mo",
+ credits: "$20",
+ productId: "api_pro",
+ description: "For people building with AI memory",
+ features: [
+ "Auto top-up when balance runs low",
+ "Google Drive, Notion, OneDrive & Granola connectors",
+ "All plugins (Claude Code, Cursor, Hermes...)",
+ "Priority support",
+ ],
+ },
+]
+
+const ADVANCED_PLAN_CARDS: PlanCardDefinition[] = [
+ {
+ id: "max",
+ name: "Max",
+ price: "$100",
+ period: "/mo",
+ credits: "$130",
+ productId: "api_max",
+ description: "For power users who outgrow Pro",
+ includesFrom: "Pro",
+ mostPopular: true,
+ features: ["6× the credits of Pro", "Gmail connector", "Priority support"],
+ },
+ {
+ id: "scale",
+ name: "Scale",
+ price: "$399",
+ period: "/mo",
+ credits: "$600",
+ productId: "api_scale",
+ description: "For teams and production workloads",
+ includesFrom: "Max",
+ features: [
+ "Auto top-up & spend caps",
+ "S3 & Web Crawler connectors",
+ "Dedicated support",
+ ],
+ },
+ {
+ id: "enterprise",
+ name: "Enterprise",
+ price: "Custom",
+ period: "",
+ credits: "Unlimited",
+ productId: "api_enterprise",
+ description: "Custom deployments with dedicated engineering",
+ includesFrom: "Scale",
+ features: [
+ "Custom metering & billing",
+ "Custom integrations & SSO",
+ "Forward-deployed engineer",
+ ],
+ isContactSales: true,
+ },
+]
+
+const PLAN_RANK: Record
= {
+ free: 0,
+ pro: 1,
+ max: 2,
+ scale: 3,
+ enterprise: 4,
+}
+
function SectionTitle({
children,
aside,
@@ -103,6 +251,102 @@ function SettingsCard({
)
}
+function PlanCard({
+ action,
+ plan,
+}: {
+ action: React.ReactNode
+ plan: PlanCardDefinition
+}) {
+ return (
+
+ {plan.mostPopular ? (
+
+ Most popular
+
+ ) : null}
+
+ {plan.name}
+
+
+
+
+ {plan.price}
+
+ {plan.period ? (
+ {plan.period}
+ ) : null}
+
+
+
+ {plan.description}
+
+
+ {plan.isContactSales ? null : (
+
+
+
+
+ {plan.credits}
+
+
+ of usage included
+
+
+
+ )}
+
+ {plan.includesFrom ? (
+
+
+
+ Everything in {plan.includesFrom}, plus
+
+
+
+ ) : null}
+
+
+ {plan.features.map((feature) => (
+
+
+ {feature}
+
+ ))}
+
+
+ {action}
+
+ )
+}
+
function Pill({
children,
tone = "muted",
@@ -214,6 +458,7 @@ function getInvoiceProductLabel(productId: string | undefined): string {
const planMap: Record = {
api_free: "Free",
api_pro: "Pro",
+ api_max: "Max",
api_scale: "Scale",
api_enterprise: "Enterprise",
memory_free: "Free",
@@ -231,11 +476,19 @@ function getInvoiceProductLabel(productId: string | undefined): string {
export default function Billing() {
const queryClient = useQueryClient()
const { user, org } = useAuth()
- const autumn = useCustomer({ expand: ["payment_method"] })
+ const autumn = useCustomer()
+ const posthog = usePostHog()
const [isUpgrading, setIsUpgrading] = useState(false)
const [isCancelling, setIsCancelling] = useState(false)
+ const [isResuming, setIsResuming] = useState(false)
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false)
+ const [cancelReason, setCancelReason] = useState(
+ null,
+ )
+ const [cancelDetail, setCancelDetail] = useState("")
const [isCreditsDialogOpen, setIsCreditsDialogOpen] = useState(false)
+ const [isPlanCarouselActive, setIsPlanCarouselActive] = useState(false)
+ const [planPage, setPlanPage] = useState<0 | 1>(0)
const [topUpAmount, setTopUpAmount] = useState(25)
const [customTopUpAmount, setCustomTopUpAmount] = useState("")
const [topUpPendingAmount, setTopUpPendingAmount] = useState(
@@ -262,6 +515,17 @@ export default function Billing() {
daysRemaining,
} = useTokenUsage(autumn)
+ // Open the carousel to the page holding the current plan (Max/Scale/Enterprise live on page 2).
+ const didAutoOpenPlanPage = useRef(false)
+ useEffect(() => {
+ if (didAutoOpenPlanPage.current || isCheckingStatus) return
+ didAutoOpenPlanPage.current = true
+ if (ADVANCED_PLAN_CARDS.some((p) => p.id === currentPlan)) {
+ setIsPlanCarouselActive(true)
+ setPlanPage(1)
+ }
+ }, [isCheckingStatus, currentPlan])
+
const balance = autumn.data?.balances?.[CREDIT_FEATURE_ID]
const creditRemaining =
balance?.remaining ?? Math.max(usdIncluded - usdSpent, 0)
@@ -333,11 +597,11 @@ export default function Billing() {
const planDisplayNames = PLAN_DISPLAY_NAMES
- const handleUpgrade = async () => {
+ const handleUpgrade = async (planId: "api_pro" | "api_max" | "api_scale") => {
setIsUpgrading(true)
try {
const result = await autumn.attach({
- planId: "api_pro",
+ planId,
successUrl: `${window.location.origin}/settings#billing`,
})
if ((result as { paymentUrl?: string })?.paymentUrl) {
@@ -354,10 +618,59 @@ export default function Billing() {
}
const cancellablePlanId =
- currentPlan === "pro" || currentPlan === "scale"
+ currentPlan === "pro" || currentPlan === "max" || currentPlan === "scale"
? (`api_${currentPlan}` as const)
: null
+ const cancelNeedsDetail =
+ cancelReason != null && cancelReasonNeedsDetail(cancelReason)
+ const canConfirmCancel =
+ cancelReason != null &&
+ (!cancelNeedsDetail || cancelDetail.trim().length > 0)
+
+ const canceledSub = getCanceledSubscription(autumn.data?.subscriptions)
+ const isPlanCanceling = canceledSub != null
+ const cancelEndsAt =
+ canceledSub?.endsAt != null ? normalizeTimestamp(canceledSub.endsAt) : null
+ const cancelEndsLabel =
+ cancelEndsAt != null
+ ? new Date(cancelEndsAt).toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })
+ : "the end of your billing period"
+ const cancelEndsDays =
+ cancelEndsAt != null
+ ? Math.max(
+ 0,
+ Math.ceil((cancelEndsAt - Date.now()) / (1000 * 60 * 60 * 24)),
+ )
+ : null
+
+ const handleResumeSubscription = async () => {
+ if (!canceledSub || isResuming) return
+ setIsResuming(true)
+ try {
+ await autumn.updateSubscription({
+ planId: canceledSub.planId,
+ cancelAction: "uncancel",
+ })
+ autumn.refetch?.()
+ toast.success(`${planDisplayNames[currentPlan]} subscription resumed.`)
+ } catch (error) {
+ console.error(error)
+ toast.error("Failed to resume subscription. Please try again.")
+ } finally {
+ setIsResuming(false)
+ }
+ }
+
+ const resetCancelForm = () => {
+ setCancelReason(null)
+ setCancelDetail("")
+ }
+
const handleCancelSubscription = async () => {
if (!cancellablePlanId) return
setIsCancelling(true)
@@ -366,8 +679,18 @@ export default function Billing() {
planId: cancellablePlanId,
cancelAction: "cancel_end_of_cycle",
})
+ if (posthog?.__loaded) {
+ posthog.capture("subscription_cancelled", {
+ reason: cancelReason,
+ reason_detail: cancelDetail.trim() || null,
+ plan: currentPlan,
+ plan_id: cancellablePlanId,
+ surface: "nova",
+ })
+ }
autumn.refetch?.()
setIsCancelDialogOpen(false)
+ resetCancelForm()
toast.success(
`Subscription cancelled. ${planDisplayNames[currentPlan]} features remain active until the end of your billing period.`,
)
@@ -490,6 +813,99 @@ export default function Billing() {
})
}
+ const getPlanCardAction = (plan: PlanCardDefinition) => {
+ const disabled = isUpgrading || isCheckingStatus || autumn.isLoading
+ const isCurrentPlan = plan.id === currentPlan
+ const isIncludedPlan = PLAN_RANK[currentPlan] > PLAN_RANK[plan.id]
+
+ if (plan.id === "free") {
+ return (
+
+ {hasPaidPlan ? "Included with current plan" : "Your current plan"}
+
+ )
+ }
+
+ if (isCurrentPlan) {
+ return (
+
+ Your current plan
+
+ )
+ }
+
+ if (isIncludedPlan) {
+ return (
+
+ Included with {planDisplayNames[currentPlan]}
+
+ )
+ }
+
+ if (plan.isContactSales) {
+ return (
+
+ Contact sales
+
+ )
+ }
+
+ const checkoutPlanId =
+ plan.productId === "api_pro" ||
+ plan.productId === "api_max" ||
+ plan.productId === "api_scale"
+ ? plan.productId
+ : null
+ if (!checkoutPlanId) return null
+
+ return (
+ handleUpgrade(checkoutPlanId)}
+ disabled={disabled}
+ className={cn(
+ dmSans125ClassName(),
+ PLAN_CARD_ACTION_CLASS,
+ "bg-[#0054AD] text-[#FAFAFA] hover:bg-[#0B65C9]",
+ )}
+ >
+ {disabled ? : null}
+ Upgrade to {plan.name}
+
+ )
+ }
+
return (
@@ -509,8 +925,20 @@ export default function Billing() {
? `${planDisplayNames[currentPlan]} plan`
: "Free plan"}
-
- {hasPaidPlan ? "Active" : "Free"}
+
+ {isPlanCanceling
+ ? "Cancelling"
+ : hasPaidPlan
+ ? "Active"
+ : "Free"}
- {hasPaidPlan
- ? "Expanded memory, connections, and usage for this workspace."
- : "Upgrade when you need more workspace usage and integrations."}
+ {isPlanCanceling
+ ? `Cancels on ${cancelEndsLabel}${cancelEndsDays !== null ? ` · ${cancelEndsDays} day${cancelEndsDays !== 1 ? "s" : ""} left` : ""}. You'll move to Free after that.`
+ : hasPaidPlan
+ ? "Expanded memory, connections, and usage for this workspace."
+ : "Upgrade when you need more workspace usage and integrations."}
@@ -537,10 +967,28 @@ export default function Billing() {
Manage
- {cancellablePlanId ? (
+ {isPlanCanceling ? (
+ void handleResumeSubscription()}
+ disabled={isResuming}
+ className={cn(
+ dmSans125ClassName(),
+ "inline-flex h-9 items-center gap-2 rounded-[9px] bg-[#0054AD] px-3 text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#0B65C9] disabled:cursor-not-allowed disabled:opacity-50",
+ )}
+ >
+ {isResuming ? (
+
+ ) : null}
+ Resume plan
+
+ ) : cancellablePlanId ? (
{
+ setIsCancelDialogOpen(open)
+ if (!open) resetCancelForm()
+ }}
>
@@ -590,32 +1038,177 @@ export default function Billing() {
-
-
-
- Keep plan
-
-
-
void handleCancelSubscription()}
- disabled={isCancelling}
- className={cn(
- dmSans125ClassName(),
- "inline-flex h-9 items-center gap-2 rounded-[9px] bg-[#290F0A] px-3 text-[13px] font-medium text-[#C73B1B] transition-opacity disabled:cursor-not-allowed disabled:opacity-50",
- )}
- >
- {isCancelling ? (
-
- ) : null}
- Cancel subscription
-
+
+
+
+
+
+
+
+ or
+
+
+
+
+
+ Why are you leaving?
+
+
+ {CANCEL_REASONS.map((option) => {
+ const selected = cancelReason === option.value
+ return (
+
+ setCancelReason(option.value)
+ }
+ className={cn(
+ dmSans125ClassName(),
+ "flex items-center gap-2.5 rounded-[8px] px-2 py-1.5 text-left text-[13px] transition-colors",
+ selected
+ ? "text-[#FAFAFA]"
+ : "text-[#A3A3A3] hover:bg-white/[0.03]",
+ )}
+ >
+
+ {selected ? (
+
+ ) : null}
+
+ {option.label}
+
+ )
+ })}
+
+ {cancelReason !== null ? (
+
+
+ {cancelNeedsDetail
+ ? "Tell us more (required)"
+ : "Anything else? (optional)"}
+
+
+ ) : null}
+
+
+
+ Keep plan
+
+
+ void handleCancelSubscription()}
+ disabled={isCancelling || !canConfirmCancel}
+ className={cn(
+ dmSans125ClassName(),
+ "mt-1.5 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[10px] border border-[#C73B1B]/25 bg-[#290F0A] text-[13px] font-semibold text-[#E5604A] transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50",
+ )}
+ >
+ {isCancelling ? (
+
+ ) : null}
+ Cancel subscription
+
+
+
+
@@ -668,343 +1261,322 @@ export default function Billing() {
: "Usage resets with your billing cycle"}
-
- {!hasPaidPlan ? (
-
- {isUpgrading || isCheckingStatus || autumn.isLoading ? (
-
- ) : null}
- Upgrade to Pro - $19/month
-
- ) : null}
-
- Credits
-
-
-
-
-
- Usage this period
-
-
-
- {planUsagePct < 1 && planUsagePct > 0
- ? "< 1"
- : Math.round(planUsagePct)}
- %
-
- of monthly usage
-
- {daysRemaining !== null
- ? `· resets in ${daysRemaining} day${daysRemaining !== 1 ? "s" : ""}`
- : "· resets with your billing cycle"}
-
-
-
-
- {planUsagePct > 0
- ? `${formatUsd(usdSpent)} used this period`
- : "No usage yet this period"}
-
-
-
-
-
-
-
-
- Buy credits
-
-
-
+
+ setPlanPage(0)}
+ disabled={planPage === 0}
+ className="flex size-8 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.02] text-[#A3A3A3] transition-colors hover:bg-white/[0.05] hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-35"
+ aria-label="Show Free and Pro plans"
>
-
-
-
- Buy Credits
-
-
- Add USD to your balance for metered usage.
-
-
-
-
-
-
-
-
-
-
-
-
- Choose an amount
-
-
{
- setTopUpAmount(value)
- setCustomTopUpAmount("")
- }}
- disabled={topUpPendingAmount !== null}
- />
-
-
- Custom amount (USD)
-
-
- setCustomTopUpAmount(event.target.value)
- }
- placeholder="e.g. 75"
- type="number"
- value={customTopUpAmount}
- className="h-11 rounded-[10px] border border-white/10 bg-[#080B10] px-3 text-[14px] text-[#FAFAFA] outline-none placeholder:text-[#737373] focus:border-[#0054AD]"
- />
-
-
-
-
-
-
-
-
- Auto reload
-
-
- {autoTopUpEnabled ? "on" : "off"}
-
-
-
-
-
- Auto reload is{" "}
- {autoTopUpEnabled ? "enabled" : "disabled"}
-
-
- handleAutoReloadToggle(!autoTopUpEnabled)
- }
- className={cn(
- dmSans125ClassName(),
- "inline-flex h-9 min-w-[96px] items-center justify-center rounded-[9px] border border-white/10 bg-[#0D121A] px-3 text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24] disabled:cursor-not-allowed disabled:opacity-45",
- )}
- >
- {autoTopUpEnabled ? "Disable" : "Enable"}
-
-
-
- {!hasPaymentMethod && !activeAutoTopUp?.enabled ? (
-
- Save a card in Manage Billing to enable automatic
- reloads.
-
- ) : null}
-
-
-
-
- Threshold (USD)
-
- {
- const value = Number.parseFloat(
- event.target.value,
- )
- setAutoTopUpThreshold(
- Number.isFinite(value) ? value : 0,
- )
- }}
- type="number"
- value={
- Number.isFinite(autoTopUpThreshold)
- ? autoTopUpThreshold
- : ""
- }
- className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60"
- />
-
-
-
- Reload amount (USD)
-
- {
- const value = Number.parseFloat(
- event.target.value,
- )
- setAutoTopUpAmount(
- Number.isFinite(value) ? value : 0,
- )
- }}
- type="number"
- value={
- Number.isFinite(autoTopUpAmount)
- ? autoTopUpAmount
- : ""
- }
- className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60"
- />
-
-
- void handleSaveAutoTopUp()}
- disabled={isSavingAutoTopUp || !isAdmin}
- className={cn(
- dmSans125ClassName(),
- "inline-flex h-9 w-full items-center justify-center gap-2 rounded-[8px] border border-white/10 bg-[#080B10] text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24] disabled:cursor-not-allowed disabled:opacity-60",
- )}
- >
- {isSavingAutoTopUp ? (
-
- ) : null}
- Save threshold & reload amount
-
-
-
-
-
-
void handleTopUp(selectedTopUpAmount)}
- disabled={
- topUpPendingAmount !== null ||
- !isAdmin ||
- selectedTopUpAmount <= 0
- }
- className={cn(
- dmSans125ClassName(),
- "inline-flex h-11 w-full items-center justify-center gap-2 rounded-[10px] bg-[#0054AD] text-[14px] font-bold text-[#FAFAFA] transition-colors hover:bg-[#0B65C9] disabled:cursor-not-allowed disabled:opacity-60",
- )}
- >
- {topUpPendingAmount !== null ? (
-
- ) : null}
- Buy {formatUsd(selectedTopUpAmount)} in credits
-
-
- {!isAdmin ? (
-
- Only owners/admins can purchase credits.
-
- ) : null}
-
-
-
-
setIsCreditsDialogOpen(true)}
- disabled={!isAdmin}
- className="inline-flex h-8 items-center justify-center gap-2 rounded-[8px] text-[12px] font-medium text-[#A3A3A3] transition-colors hover:bg-white/[0.04] disabled:cursor-not-allowed disabled:opacity-50"
- >
-
+
+
setPlanPage(1)}
+ disabled={planPage === 1}
+ className="flex size-8 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.02] text-[#A3A3A3] transition-colors hover:bg-white/[0.05] hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-35"
+ aria-label="Show Max, Scale and Enterprise plans"
+ >
+
+
+
+ ) : undefined
+ }
+ >
+ Plans
+
+
+
+
+ {PLAN_CARDS.map((plan) => (
+
- Auto reload: {autoTopUpEnabled ? "on" : "off"}
-
+ ))}
+
+
+ {ADVANCED_PLAN_CARDS.map((plan) => (
+
+ ))}
-
+
+ {isPlanCarouselActive ? null : (
+
+ {
+ setIsPlanCarouselActive(true)
+ setPlanPage(1)
+ }}
+ className={cn(
+ dmSans125ClassName(),
+ "inline-flex items-center justify-center gap-2 text-[13px] font-semibold text-[#A3A3A3] transition-colors hover:text-[#FAFAFA]",
+ )}
+ >
+
+ Other plans
+
+
+ →
+
+
+
+ )}
+
- {hasPaidPlan ? (
+
+
+
+
+
+ Buy Credits
+
+
+ Add USD to your balance for metered usage.
+
+
+
+
+
+
+
+
+
+
+
+
+ Choose an amount
+
+
{
+ setTopUpAmount(value)
+ setCustomTopUpAmount("")
+ }}
+ disabled={topUpPendingAmount !== null}
+ />
+
+
+ Custom amount (USD)
+
+ setCustomTopUpAmount(event.target.value)}
+ placeholder="e.g. 75"
+ type="number"
+ value={customTopUpAmount}
+ className="h-11 rounded-[10px] border border-white/10 bg-[#080B10] px-3 text-[14px] text-[#FAFAFA] outline-none placeholder:text-[#737373] focus:border-[#0054AD]"
+ />
+
+
+
+
+
+
+
+
+ Auto reload
+
+
+ {autoTopUpEnabled ? "on" : "off"}
+
+
+
+
+
+ Auto reload is {autoTopUpEnabled ? "enabled" : "disabled"}
+
+
handleAutoReloadToggle(!autoTopUpEnabled)}
+ className={cn(
+ dmSans125ClassName(),
+ "inline-flex h-9 min-w-[96px] items-center justify-center rounded-[9px] border border-white/10 bg-[#0D121A] px-3 text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24] disabled:cursor-not-allowed disabled:opacity-45",
+ )}
+ >
+ {autoTopUpEnabled ? "Disable" : "Enable"}
+
+
+
+ {!hasPaymentMethod && !activeAutoTopUp?.enabled ? (
+
+ Save a card in Manage Billing to enable automatic reloads.
+
+ ) : null}
+
+
+
+
+ Threshold (USD)
+
+ {
+ const value = Number.parseFloat(event.target.value)
+ setAutoTopUpThreshold(Number.isFinite(value) ? value : 0)
+ }}
+ type="number"
+ value={
+ Number.isFinite(autoTopUpThreshold)
+ ? autoTopUpThreshold
+ : ""
+ }
+ className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60"
+ />
+
+
+
+ Reload amount (USD)
+
+ {
+ const value = Number.parseFloat(event.target.value)
+ setAutoTopUpAmount(Number.isFinite(value) ? value : 0)
+ }}
+ type="number"
+ value={
+ Number.isFinite(autoTopUpAmount) ? autoTopUpAmount : ""
+ }
+ className="h-10 rounded-[8px] border border-white/10 bg-[#080B10] px-3 text-[13px] text-[#FAFAFA] outline-none focus:border-[#0054AD] disabled:opacity-60"
+ />
+
+
+ void handleSaveAutoTopUp()}
+ disabled={isSavingAutoTopUp || !isAdmin}
+ className={cn(
+ dmSans125ClassName(),
+ "inline-flex h-9 w-full items-center justify-center gap-2 rounded-[8px] border border-white/10 bg-[#080B10] text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#121A24] disabled:cursor-not-allowed disabled:opacity-60",
+ )}
+ >
+ {isSavingAutoTopUp ? (
+
+ ) : null}
+ Save threshold & reload amount
+
+
+
+
+
+
void handleTopUp(selectedTopUpAmount)}
+ disabled={
+ topUpPendingAmount !== null ||
+ !isAdmin ||
+ selectedTopUpAmount <= 0
+ }
+ className={cn(
+ dmSans125ClassName(),
+ "inline-flex h-11 w-full items-center justify-center gap-2 rounded-[10px] bg-[#0054AD] text-[14px] font-bold text-[#FAFAFA] transition-colors hover:bg-[#0B65C9] disabled:cursor-not-allowed disabled:opacity-60",
+ )}
+ >
+ {topUpPendingAmount !== null ? (
+
+ ) : null}
+ Buy {formatUsd(selectedTopUpAmount)} in credits
+
+
+ {!isAdmin ? (
+
+ Only owners/admins can purchase credits.
+
+ ) : null}
+
+
+
+
+ {hasPaidPlan ? (
+
+ Credits
- Top-up credits{" "}
-
- (optional)
-
+ Top-up credits
- {creditRemaining > 0 ? "Add more" : "Add credits"}
+ {creditRemaining > 0 ? "Add more" : "Buy credits"}
- ) : null}
-
+
+ ) : null}
Invoice history
diff --git a/apps/web/components/settings/cancel-reasons.ts b/apps/web/components/settings/cancel-reasons.ts
new file mode 100644
index 00000000..9378193b
--- /dev/null
+++ b/apps/web/components/settings/cancel-reasons.ts
@@ -0,0 +1,19 @@
+export const CANCEL_REASONS = [
+ { value: "too_expensive", label: "Too expensive" },
+ { value: "missing_features", label: "Missing features I need" },
+ { value: "switching", label: "Found a better alternative" },
+ { value: "not_using", label: "Not using it enough" },
+ { value: "other", label: "Other" },
+] as const
+
+export type CancelReasonValue = (typeof CANCEL_REASONS)[number]["value"]
+
+const NEEDS_DETAIL: CancelReasonValue[] = [
+ "missing_features",
+ "switching",
+ "other",
+]
+
+export function cancelReasonNeedsDetail(value: CancelReasonValue): boolean {
+ return NEEDS_DETAIL.includes(value)
+}
diff --git a/apps/web/components/settings/company-brain-automations.tsx b/apps/web/components/settings/company-brain-automations.tsx
new file mode 100644
index 00000000..26d1fda3
--- /dev/null
+++ b/apps/web/components/settings/company-brain-automations.tsx
@@ -0,0 +1,1084 @@
+"use client"
+
+import { authClient } from "@lib/auth"
+import { useAuth } from "@lib/auth-context"
+import { cn } from "@lib/utils"
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import type { LucideIcon } from "lucide-react"
+import {
+ CalendarClock,
+ ChevronDown,
+ ChevronUp,
+ FilePlus2,
+ FileText,
+ GitPullRequest,
+ Info,
+ LifeBuoy,
+ ListTodo,
+ Loader2,
+ Lock,
+ MessageCircleQuestion,
+ Plus,
+ Trash2,
+} from "lucide-react"
+import { useRef, useState } from "react"
+import { toast } from "sonner"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@ui/components/dropdown-menu"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@ui/components/select"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@ui/components/tooltip"
+import { useHasCompanyBrain } from "@/hooks/use-company-brain"
+import { dmSans125ClassName } from "@/lib/fonts"
+
+const BACKEND =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+const BASE = `${BACKEND}/brain/automations`
+
+type Automation = {
+ id: string
+ enabled: boolean
+ title: string
+ channelId: string
+ deliverTo: "channel" | "dm"
+ prompt: string
+ cron: string
+ timezone: string | null
+ createdBy: string | null
+}
+type Channel = { id: string; name: string; isPrivate: boolean }
+type Frequency = "daily" | "weekly"
+
+const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
+
+const DEFAULT_PROMPT =
+ "Summarize what's happened recently across the connected tools and channels: open items, unanswered questions, decisions, and anything the team should know. Keep it a short, scannable recap."
+
+// Local day/time -> UTC cron; the Date roundtrip carries any day rollover.
+function toUtcCron(
+ time: string,
+ frequency: Frequency,
+ weekday: number,
+): string | null {
+ const [hh, mm] = time.split(":").map(Number)
+ if (
+ hh === undefined ||
+ mm === undefined ||
+ Number.isNaN(hh) ||
+ Number.isNaN(mm)
+ )
+ return null
+ const d = new Date()
+ d.setHours(hh, mm, 0, 0)
+ if (frequency === "weekly")
+ d.setDate(d.getDate() + ((weekday - d.getDay() + 7) % 7))
+ const m = d.getUTCMinutes()
+ const h = d.getUTCHours()
+ return frequency === "daily"
+ ? `${m} ${h} * * *`
+ : `${m} ${h} * * ${d.getUTCDay()}`
+}
+
+function fromUtcCron(
+ cron: string,
+): { frequency: Frequency; weekday: number; time: string } | null {
+ const parts = cron.trim().split(/\s+/)
+ if (parts.length !== 5) return null
+ const [min, hr, , , dow] = parts
+ const mm = Number(min)
+ const hh = Number(hr)
+ if (Number.isNaN(mm) || Number.isNaN(hh)) return null
+ const d = new Date()
+ d.setUTCHours(hh, mm, 0, 0)
+ const time = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
+ if (dow === "*") return { frequency: "daily", weekday: 1, time }
+ const targetDow = Number(dow)
+ if (Number.isNaN(targetDow)) return null
+ d.setUTCDate(d.getUTCDate() + ((targetDow - d.getUTCDay() + 7) % 7))
+ return { frequency: "weekly", weekday: d.getDay(), time }
+}
+
+type Draft = {
+ title: string
+ channelId: string
+ deliverTo: "channel" | "dm"
+ prompt: string
+ frequency: Frequency
+ weekday: number
+ time: string
+ enabled: boolean
+}
+
+function toDraft(a: Automation): Draft {
+ const parsed = fromUtcCron(a.cron)
+ return {
+ title: a.title,
+ channelId: a.channelId,
+ deliverTo: a.deliverTo === "dm" ? "dm" : "channel",
+ prompt: a.prompt,
+ frequency: parsed?.frequency ?? "daily",
+ weekday: parsed?.weekday ?? 1,
+ time: parsed?.time ?? "09:00",
+ enabled: a.enabled,
+ }
+}
+
+const emptyDraft = (): Draft => ({
+ title: "",
+ channelId: "",
+ deliverTo: "channel",
+ prompt: DEFAULT_PROMPT,
+ frequency: "daily",
+ weekday: 1,
+ time: "09:00",
+ enabled: true,
+})
+
+type Category = "team" | "engineering" | "support" | "product"
+
+type Preset = {
+ id: string
+ label: string
+ description: string
+ icon: LucideIcon
+ category: Category
+ requiresApps?: string[]
+ prompt: string
+ frequency: Frequency
+ weekday?: number
+ time: string
+}
+
+const PRESETS: Preset[] = [
+ {
+ id: "standup",
+ category: "team",
+ label: "Daily standup",
+ description:
+ "Shipped work, decisions, blockers & open questions from the last 24h.",
+ icon: CalendarClock,
+ prompt:
+ "Give a short standup for this channel: what happened in the last 24 hours across our connected tools and this channel — work shipped, decisions made, blockers, and open questions. Keep it tight and scannable.",
+ frequency: "daily",
+ time: "09:00",
+ },
+ {
+ id: "weekly-recap",
+ category: "team",
+ label: "Weekly team recap",
+ description:
+ "Decisions, shipped work & unresolved threads from the past week.",
+ icon: CalendarClock,
+ prompt:
+ "Weekly recap for the team: decisions made, work shipped, and unresolved threads across our connected tools and channels over the past 7 days.",
+ frequency: "weekly",
+ weekday: 1,
+ time: "09:00",
+ },
+ {
+ id: "unanswered",
+ category: "team",
+ label: "Unanswered questions",
+ description: "Questions in this channel from the last 24h with no reply.",
+ icon: MessageCircleQuestion,
+ prompt:
+ "Surface questions asked in this channel in the last 24 hours that haven't gotten a reply yet, so nothing slips through.",
+ frequency: "daily",
+ time: "16:00",
+ },
+ {
+ id: "prs-review",
+ category: "engineering",
+ label: "PRs awaiting review",
+ description: "Open PRs waiting on review; flags stale ones.",
+ icon: GitPullRequest,
+ requiresApps: ["github"],
+ prompt:
+ "List open pull requests awaiting review. Flag any with no activity for 2+ days. Group by repository.",
+ frequency: "daily",
+ time: "09:30",
+ },
+ {
+ id: "issue-triage",
+ category: "engineering",
+ label: "Issue triage",
+ description: "New or unassigned issues that need a response.",
+ icon: ListTodo,
+ requiresApps: ["github", "linear"],
+ prompt:
+ "Summarize new or unassigned issues from the last 24 hours that need triage or a response.",
+ frequency: "daily",
+ time: "09:00",
+ },
+ {
+ id: "customer-signal",
+ category: "support",
+ label: "Customer signal",
+ description: "Recent customer issues & feedback and their status.",
+ icon: LifeBuoy,
+ requiresApps: ["plain", "linear"],
+ prompt:
+ "Recap customer issues and feedback raised recently across our tools and channels, with their current status.",
+ frequency: "daily",
+ time: "09:00",
+ },
+ {
+ id: "release-notes",
+ category: "product",
+ label: "Release notes draft",
+ description: "Draft notes from PRs merged since the last digest.",
+ icon: FileText,
+ requiresApps: ["github"],
+ prompt:
+ "Draft release notes from pull requests merged since the last digest, grouped into features, fixes, and chores.",
+ frequency: "weekly",
+ weekday: 5,
+ time: "16:00",
+ },
+]
+
+function presetToDraft(p: Preset): Draft {
+ return {
+ title: p.label,
+ channelId: "",
+ deliverTo: "channel",
+ prompt: p.prompt,
+ frequency: p.frequency,
+ weekday: p.weekday ?? 1,
+ time: p.time,
+ enabled: true,
+ }
+}
+
+// Connected-app presets first, universal next, unconnected-app presets last.
+function sortPresets(connected: Set): Preset[] {
+ const rank = (p: Preset) => {
+ if (!p.requiresApps) return 1
+ return p.requiresApps.some((a) => connected.has(a)) ? 0 : 2
+ }
+ return [...PRESETS].sort((a, b) => rank(a) - rank(b))
+}
+
+// Up to 3 presets spanning distinct categories (connection-preferred order).
+function galleryPresets(sorted: Preset[]): Preset[] {
+ const seen = new Set()
+ const out: Preset[] = []
+ for (const p of sorted) {
+ if (seen.has(p.category)) continue
+ seen.add(p.category)
+ out.push(p)
+ if (out.length === 3) break
+ }
+ return out
+}
+
+function cadenceLabel(p: Preset): string {
+ if (p.frequency === "weekly")
+ return `Weekly · ${WEEKDAYS[p.weekday ?? 1] ?? "Mon"} ${p.time}`
+ return `Daily · ${p.time}`
+}
+
+function SectionTitle({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+const controlClass = cn(
+ dmSans125ClassName(),
+ "h-9 w-full rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-3 text-[13px] text-[#FAFAFA] outline-none disabled:opacity-50",
+)
+const fieldLabel = cn(dmSans125ClassName(), "text-[12px] text-[#9A9A9A]")
+const selectContentClass = cn(
+ dmSans125ClassName(),
+ "rounded-[10px] border-white/[0.08] bg-[#1B1F24] text-[#FAFAFA] shadow-[0px_8px_24px_rgba(0,0,0,0.5)]",
+)
+const selectItemClass =
+ "cursor-pointer rounded-[8px] text-[13px] text-[#FAFAFA] hover:bg-white/10 hover:text-white data-[highlighted]:bg-white/10 data-[highlighted]:text-white focus:bg-white/10 focus:text-white"
+const DM_VALUE = "__dm__"
+
+const menuItemClass =
+ "cursor-pointer gap-2.5 rounded-lg px-2.5 py-2 text-[13px] font-medium text-white/85 hover:bg-white/[0.06] hover:text-white data-[highlighted]:bg-white/[0.06] data-[highlighted]:text-white focus:bg-white/[0.06] focus:text-white"
+
+function AutomationCard({
+ initial,
+ id,
+ channels,
+ ownerLabel,
+ onDone,
+ onCancelNew,
+ onCollapse,
+}: {
+ initial: Draft
+ id: string | null
+ channels: Channel[]
+ ownerLabel?: string
+ onDone: () => void
+ onCancelNew?: () => void
+ onCollapse?: () => void
+}) {
+ const [draft, setDraft] = useState(initial)
+ const set = (k: K, v: Draft[K]) =>
+ setDraft((d) => ({ ...d, [k]: v }))
+
+ const body = () => {
+ if (!draft.title.trim()) throw new Error("Give the automation a name.")
+ if (draft.deliverTo === "channel" && !draft.channelId)
+ throw new Error("Pick a channel to post to.")
+ const cron = toUtcCron(draft.time, draft.frequency, draft.weekday)
+ if (!cron) throw new Error("Pick a valid time.")
+ return {
+ title: draft.title.trim(),
+ deliverTo: draft.deliverTo,
+ channelId: draft.deliverTo === "dm" ? null : draft.channelId,
+ prompt: draft.prompt.trim() || DEFAULT_PROMPT,
+ cron,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ enabled: draft.enabled,
+ }
+ }
+
+ const save = useMutation({
+ mutationFn: async () => {
+ const payload = body()
+ const res = await fetch(id ? `${BASE}/${id}` : `${BASE}/`, {
+ method: id ? "PUT" : "POST",
+ credentials: "include",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(payload),
+ })
+ if (res.status === 403)
+ throw new Error("You can only manage automations you created.")
+ if (!res.ok) {
+ const b = (await res.json().catch(() => ({}))) as { error?: string }
+ throw new Error(b.error ?? "Couldn't save.")
+ }
+ },
+ onSuccess: () => {
+ toast.success("Automation saved.")
+ onDone()
+ },
+ onError: (err) =>
+ toast.error(err instanceof Error ? err.message : "Couldn't save."),
+ })
+
+ const trigger = useMutation({
+ mutationFn: async () => {
+ if (!id) throw new Error("Save the automation first.")
+ const res = await fetch(`${BASE}/${id}/run-now`, {
+ method: "POST",
+ credentials: "include",
+ })
+ const b = (await res.json().catch(() => ({}))) as {
+ ok?: boolean
+ reason?: string
+ error?: string
+ }
+ if (!res.ok || b.ok === false)
+ throw new Error(b.reason ?? b.error ?? "Couldn't run.")
+ },
+ onSuccess: () => toast.success("Automation triggered — check the channel."),
+ onError: (err) =>
+ toast.error(err instanceof Error ? err.message : "Couldn't run."),
+ })
+
+ const remove = useMutation({
+ mutationFn: async () => {
+ if (!id) return
+ const res = await fetch(`${BASE}/${id}`, {
+ method: "DELETE",
+ credentials: "include",
+ })
+ if (!res.ok) throw new Error("Couldn't delete.")
+ },
+ onSuccess: () => {
+ toast.success("Automation removed.")
+ onDone()
+ },
+ onError: (err) =>
+ toast.error(err instanceof Error ? err.message : "Couldn't delete."),
+ })
+
+ const busy = save.isPending || trigger.isPending || remove.isPending
+ const disabled = busy
+
+ return (
+
+
+
+ set("title", e.target.value)}
+ />
+ {ownerLabel ? (
+
+ {ownerLabel}
+
+ ) : null}
+
+ {onCollapse ? (
+
+
+
+ ) : null}
+
set("enabled", !draft.enabled)}
+ className={cn(
+ "relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
+ draft.enabled ? "bg-[#2563FF]" : "bg-white/10",
+ )}
+ >
+
+
+
+
+
+
+ Prompt
+
+
+
+
+
+ Deliver to
+
+
+
+
+
+
+
+
+ Only channels Company Brain has been added to are listed —
+ invite the bot to a channel to use it. A DM goes privately
+ to you and can also read your personal connections.
+
+
+
+
+
+ setDraft((d) =>
+ v === DM_VALUE
+ ? { ...d, deliverTo: "dm", channelId: "" }
+ : { ...d, deliverTo: "channel", channelId: v },
+ )
+ }
+ disabled={disabled}
+ >
+
+
+
+
+
+ 📩 Direct message to me
+
+ {channels.map((ch) => (
+
+ {ch.isPrivate ? "🔒 " : "# "}
+ {ch.name}
+
+ ))}
+
+
+
+
+
+
+ Frequency
+ set("frequency", v as Frequency)}
+ disabled={disabled}
+ >
+
+
+
+
+
+ Daily
+
+
+ Weekly
+
+
+
+
+
+ {draft.frequency === "weekly" ? (
+
+ Day
+ set("weekday", Number(v))}
+ disabled={disabled}
+ >
+
+
+
+
+ {WEEKDAYS.map((label, i) => (
+
+ {label}
+
+ ))}
+
+
+
+ ) : null}
+
+
+ Time
+ set("time", e.target.value)}
+ />
+
+
+
+
+
+
+
+ {id ? (
+ remove.mutate()}
+ className={cn(
+ dmSans125ClassName(),
+ "inline-flex size-9 items-center justify-center rounded-full text-[#8A5247] transition-colors hover:bg-[#1A0F0C]/60 hover:text-[#C73B1B] disabled:cursor-not-allowed disabled:opacity-45",
+ )}
+ title="Delete automation"
+ >
+ {remove.isPending ? (
+
+ ) : (
+
+ )}
+
+ ) : onCancelNew ? (
+
+ Cancel
+
+ ) : null}
+
+
+ {id ? (
+ trigger.mutate()}
+ className={cn(
+ dmSans125ClassName(),
+ "inline-flex h-9 items-center justify-center gap-2 rounded-full border border-white/10 bg-transparent px-4 text-[13px] font-medium text-[#9A9A9A] transition-colors hover:bg-white/[0.04] hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-45",
+ )}
+ >
+ {trigger.isPending ? (
+
+ ) : null}
+ {trigger.isPending ? "Running…" : "Run now"}
+
+ ) : null}
+ save.mutate()}
+ className={cn(
+ dmSans125ClassName(),
+ "inline-flex h-9 items-center justify-center gap-2 rounded-full bg-[#14161A] px-4 text-[13px] font-semibold text-[#FAFAFA] shadow-inside-out transition-colors hover:bg-[#121820] disabled:cursor-not-allowed disabled:opacity-45",
+ )}
+ >
+ {save.isPending ? (
+
+ ) : null}
+ {save.isPending ? "Saving…" : "Save"}
+
+
+
+
+ )
+}
+
+function AutomationRow({
+ automation,
+ channels,
+ ownerLabel,
+ onExpand,
+ onChanged,
+}: {
+ automation: Automation
+ channels: Channel[]
+ ownerLabel?: string
+ onExpand: () => void
+ onChanged: () => void
+}) {
+ const parsed = fromUtcCron(automation.cron)
+ const target =
+ automation.deliverTo === "dm"
+ ? "DM to you"
+ : `#${channels.find((c) => c.id === automation.channelId)?.name ?? "channel"}`
+ const cadence = parsed
+ ? parsed.frequency === "weekly"
+ ? `Weekly ${WEEKDAYS[parsed.weekday] ?? "Mon"} ${parsed.time}`
+ : `Daily ${parsed.time}`
+ : "Not scheduled"
+
+ const toggle = useMutation({
+ mutationFn: async () => {
+ const res = await fetch(`${BASE}/${automation.id}`, {
+ method: "PUT",
+ credentials: "include",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ title: automation.title,
+ deliverTo: automation.deliverTo,
+ channelId:
+ automation.deliverTo === "dm" ? null : automation.channelId,
+ prompt: automation.prompt,
+ cron: automation.cron,
+ timezone: automation.timezone,
+ enabled: !automation.enabled,
+ }),
+ })
+ if (res.status === 403)
+ throw new Error("You can only manage automations you created.")
+ if (!res.ok) throw new Error("Couldn't update.")
+ },
+ onSuccess: onChanged,
+ onError: (err) =>
+ toast.error(err instanceof Error ? err.message : "Couldn't update."),
+ })
+
+ const trigger = useMutation({
+ mutationFn: async () => {
+ const res = await fetch(`${BASE}/${automation.id}/run-now`, {
+ method: "POST",
+ credentials: "include",
+ })
+ const b = (await res.json().catch(() => ({}))) as {
+ ok?: boolean
+ reason?: string
+ error?: string
+ }
+ if (!res.ok || b.ok === false)
+ throw new Error(b.reason ?? b.error ?? "Couldn't run.")
+ },
+ onSuccess: () => toast.success("Automation triggered — check the channel."),
+ onError: (err) =>
+ toast.error(err instanceof Error ? err.message : "Couldn't run."),
+ })
+
+ return (
+
+ toggle.mutate()}
+ title={automation.enabled ? "Disable" : "Enable"}
+ className={cn(
+ "relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none disabled:opacity-50",
+ automation.enabled ? "bg-[#2563FF]" : "bg-white/10",
+ )}
+ >
+
+
+
+
+
+
+ {automation.title}
+
+ {ownerLabel ? (
+
+ {ownerLabel}
+
+ ) : null}
+
+
+ {target} · {cadence}
+
+
+
+ trigger.mutate()}
+ className={cn(
+ dmSans125ClassName(),
+ "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full border border-white/10 px-3 text-[12px] font-medium text-[#9A9A9A] transition-colors hover:bg-white/[0.04] hover:text-[#FAFAFA] disabled:opacity-45",
+ )}
+ >
+ {trigger.isPending ? (
+
+ ) : null}
+ Run now
+
+
+
+
+
+
+ )
+}
+
+function PresetTile({
+ preset,
+ onPick,
+}: {
+ preset: Preset
+ onPick: () => void
+}) {
+ const Icon = preset.icon
+ return (
+
+
+
+
+ {preset.label}
+
+
+ {cadenceLabel(preset)}
+
+
+
+ )
+}
+
+export default function CompanyBrainAutomations() {
+ const isCompanyBrain = useHasCompanyBrain()
+ const { user, org } = useAuth()
+ const queryClient = useQueryClient()
+ const [drafts, setDrafts] = useState<{ key: number; draft: Draft }[]>([])
+ const [openId, setOpenId] = useState(null)
+ const draftKey = useRef(0)
+ const addDraft = (draft: Draft) =>
+ setDrafts((d) => [...d, { key: draftKey.current++, draft }])
+ const removeDraft = (key: number) =>
+ setDrafts((d) => d.filter((x) => x.key !== key))
+
+ const roleQuery = useQuery({
+ queryKey: ["company-brain-automations", "role"],
+ queryFn: async () =>
+ (await authClient.organization.getActiveMember()).data?.role ?? null,
+ staleTime: 60_000,
+ enabled: isCompanyBrain,
+ })
+ const isAdmin = ["owner", "admin"].includes(
+ (roleQuery.data ?? "").toLowerCase(),
+ )
+
+ const listQuery = useQuery({
+ queryKey: ["company-brain-automations", "list", org?.id],
+ queryFn: async () => {
+ const res = await fetch(`${BASE}/`, { credentials: "include" })
+ if (!res.ok) throw new Error("failed")
+ return ((await res.json()) as { automations: Automation[] }).automations
+ },
+ enabled: isCompanyBrain,
+ })
+
+ const channelsQuery = useQuery({
+ queryKey: ["company-brain-automations", "channels", org?.id],
+ queryFn: async () => {
+ const res = await fetch(`${BASE}/channels`, { credentials: "include" })
+ if (!res.ok) return [] as Channel[]
+ return ((await res.json()) as { channels: Channel[] }).channels ?? []
+ },
+ enabled: isCompanyBrain,
+ })
+
+ const appsQuery = useQuery({
+ queryKey: ["company-brain-automations", "apps", org?.id],
+ queryFn: async () => {
+ const res = await fetch(`${BACKEND}/brain/mcp-connections/`, {
+ credentials: "include",
+ })
+ if (!res.ok) return [] as string[]
+ const body = (await res.json()) as {
+ connections?: { serverSlug: string }[]
+ }
+ return (body.connections ?? []).map((c) => c.serverSlug)
+ },
+ enabled: isCompanyBrain,
+ })
+
+ if (!isCompanyBrain) return null
+
+ const channels = channelsQuery.data ?? []
+ const automations = listQuery.data ?? []
+ const presets = sortPresets(new Set(appsQuery.data ?? []))
+ const nameFor = (userId: string | null): string | undefined => {
+ if (!userId) return undefined
+ if (userId === user?.id) return "You"
+ const m = org?.members?.find((mem) => mem.userId === userId)
+ return m?.user?.name ?? m?.user?.email ?? "A teammate"
+ }
+ const refresh = () => {
+ void queryClient.invalidateQueries({
+ queryKey: ["company-brain-automations", "list"],
+ })
+ }
+ const showGallery = automations.length === 0 && drafts.length === 0
+
+ return (
+
+
+
+
Channel automations
+
+
+
+
+ New automation
+
+
+
+ addDraft(emptyDraft())}
+ className={menuItemClass}
+ >
+
+ Blank automation
+
+ {presets.map((p) => {
+ const Icon = p.icon
+ return (
+ addDraft(presetToDraft(p))}
+ className={menuItemClass}
+ >
+
+ {p.label}
+
+ )
+ })}
+
+
+
+
+
+
+ Read-only scheduled summaries posted to a channel.{" "}
+ {isAdmin
+ ? "You manage all across the org."
+ : "You manage the ones you create."}
+
+
+
+
+ {automations.map((a) =>
+ openId === a.id ? (
+
{
+ setOpenId(null)
+ refresh()
+ }}
+ onCollapse={() => setOpenId(null)}
+ />
+ ) : (
+ setOpenId(a.id)}
+ onChanged={refresh}
+ />
+ ),
+ )}
+
+ {drafts.map(({ key, draft }) => (
+ {
+ removeDraft(key)
+ refresh()
+ }}
+ onCancelNew={() => removeDraft(key)}
+ />
+ ))}
+
+ {showGallery ? (
+
+
+ Start from a template:
+
+
+ {galleryPresets(presets).map((p) => (
+
addDraft(presetToDraft(p))}
+ />
+ ))}
+ addDraft(emptyDraft())}
+ className={cn(
+ dmSans125ClassName(),
+ "flex items-center justify-center gap-2 rounded-[10px] border border-dashed border-white/[0.1] bg-transparent px-3 py-2.5 text-[13px] text-[#9A9A9A] transition-colors hover:border-white/20 hover:text-[#FAFAFA]",
+ )}
+ >
+
+ Start from scratch
+
+
+
+ More templates in the New automation menu.
+
+
+ ) : null}
+
+
+ )
+}
diff --git a/apps/web/components/settings/company-brain-connections.tsx b/apps/web/components/settings/company-brain-connections.tsx
new file mode 100644
index 00000000..a6fb1ff8
--- /dev/null
+++ b/apps/web/components/settings/company-brain-connections.tsx
@@ -0,0 +1,605 @@
+"use client"
+
+import { authClient } from "@lib/auth"
+import { cn } from "@lib/utils"
+import { useQuery } from "@tanstack/react-query"
+import { Loader2, Lock } from "lucide-react"
+import { useCallback, useEffect, useState } from "react"
+import { toast } from "sonner"
+import { dmSans125ClassName } from "@/lib/fonts"
+import { useHasCompanyBrain } from "@/hooks/use-company-brain"
+import { useAuth } from "@lib/auth-context"
+import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
+import { PillButton } from "../integrations/install-steps"
+
+const BACKEND =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+
+const MCP_BASE = `${BACKEND}/brain/mcp-connections`
+
+type AuthType = "oauth" | "static" | "none"
+type CatalogEntry = {
+ slug: string
+ name: string
+ category: string
+ authType: AuthType
+ tokenHint?: string
+}
+type ConnRow = {
+ serverSlug: string
+ serverUrl?: string
+ authType: AuthType
+ status: "active" | "pending" | "error"
+ userId: string | null
+}
+type SlackStatus = { connected: boolean; teamName: string | null }
+type Scope = "org" | "user"
+
+function titleCase(s: string) {
+ return s.replace(/\b\w/g, (c) => c.toUpperCase())
+}
+
+function slugifyMcpName(value: string) {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 63)
+}
+
+function SecondaryButton({
+ children,
+ href,
+}: {
+ children: React.ReactNode
+ href: string
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+function StatusDot({ connected }: { connected: boolean }) {
+ return (
+
+
+ {connected ? "Connected" : "Not connected"}
+
+ )
+}
+
+function AppCard({
+ name,
+ subtitle,
+ icon,
+ connected,
+ canConnect,
+ canDisconnect,
+ lockedHint,
+ busy,
+ onConnect,
+ onDisconnect,
+}: {
+ name: string
+ subtitle: string
+ icon: React.ReactNode
+ connected: boolean
+ canConnect: boolean
+ canDisconnect: boolean
+ lockedHint?: string
+ busy: boolean
+ onConnect: () => void
+ onDisconnect: () => void
+}) {
+ return (
+
+
+
+ {icon}
+
+
+
+ {name}
+
+
+ {subtitle}
+
+
+
+
+
+ {connected && canDisconnect ? (
+
+ {busy && }
+ Disconnect
+
+ ) : (
+ !connected &&
+ (canConnect ? (
+
+ {busy && }
+ Connect
+
+ ) : lockedHint ? (
+
+
+ {lockedHint}
+
+ ) : null)
+ )}
+
+
+ )
+}
+
+function ScopeToggle({
+ scope,
+ onChange,
+}: {
+ scope: Scope
+ onChange: (s: Scope) => void
+}) {
+ const items: { id: Scope; label: string }[] = [
+ { id: "org", label: "Organization" },
+ { id: "user", label: "Personal" },
+ ]
+ return (
+
+ {items.map((it) => (
+ onChange(it.id)}
+ className={cn(
+ dmSans125ClassName(),
+ "rounded-full px-4 h-8 text-[13px] font-medium transition-colors",
+ scope === it.id
+ ? "bg-[#1E293B] text-[#FAFAFA]"
+ : "text-[#737373] hover:text-[#FAFAFA]",
+ )}
+ >
+ {it.label}
+
+ ))}
+
+ )
+}
+
+function RowSkeleton() {
+ return (
+
+ )
+}
+
+export default function CompanyBrainConnections() {
+ const isCompanyBrain = useHasCompanyBrain()
+ const { user } = useAuth()
+ const [catalog, setCatalog] = useState(null)
+ const [catalogLoaded, setCatalogLoaded] = useState(false)
+ const [rows, setRows] = useState([])
+ const [slackStatus, setSlackStatus] = useState(null)
+ const [busy, setBusy] = useState(null)
+ const [scope, setScope] = useState("user")
+ const [customName, setCustomName] = useState("")
+ const [customServerUrl, setCustomServerUrl] = useState("")
+
+ const roleQuery = useQuery({
+ queryKey: ["company-brain-connections", "role"],
+ queryFn: async () =>
+ (await authClient.organization.getActiveMember()).data?.role ?? null,
+ staleTime: 60_000,
+ enabled: isCompanyBrain,
+ })
+ const role = (roleQuery.data ?? "").toLowerCase()
+ const isAdmin = role === "owner" || role === "admin"
+
+ const load = useCallback(async () => {
+ const [catRes, connRes, slackRes] = await Promise.all([
+ fetch(`${MCP_BASE}/catalog`, { credentials: "include" }),
+ fetch(`${MCP_BASE}/`, { credentials: "include" }),
+ fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
+ ])
+ if (catRes.ok) {
+ const data = (await catRes.json()) as { catalog?: CatalogEntry[] }
+ setCatalog(Array.isArray(data.catalog) ? data.catalog : [])
+ setCatalogLoaded(true)
+ } else {
+ setCatalog([])
+ setCatalogLoaded(false)
+ toast.error("Couldn't load the app catalog.")
+ }
+ if (connRes.ok) {
+ const data = (await connRes.json()) as { connections?: ConnRow[] }
+ setRows(Array.isArray(data.connections) ? data.connections : [])
+ } else {
+ setRows([])
+ }
+ if (slackRes.ok) {
+ setSlackStatus((await slackRes.json()) as SlackStatus)
+ } else {
+ setSlackStatus({ connected: false, teamName: null })
+ }
+ }, [])
+
+ useEffect(() => {
+ if (!isCompanyBrain) return
+ void load()
+ const onFocus = () => void load()
+ window.addEventListener("focus", onFocus)
+ return () => window.removeEventListener("focus", onFocus)
+ }, [isCompanyBrain, load])
+
+ // A row with userId === null is the org-shared connection; any other row
+ // returned to the caller is their own personal one.
+ const isConnected = (slug: string, shared: boolean) =>
+ rows.some(
+ (r) =>
+ r.serverSlug === slug &&
+ r.status === "active" &&
+ (shared ? r.userId === null : r.userId !== null),
+ )
+
+ const isStaff =
+ user?.email?.toLowerCase().endsWith("@supermemory.com") ?? false
+
+ const connect = async (entry: CatalogEntry, shared: boolean) => {
+ const key = `${entry.slug}:${shared ? "org" : "user"}`
+ setBusy(key)
+ try {
+ if (entry.authType === "static") {
+ const token = window.prompt(
+ `Paste a token for ${entry.name}.${entry.tokenHint ? `\n${entry.tokenHint}` : ""}`,
+ )
+ if (!token) return
+ const res = await fetch(`${MCP_BASE}/${entry.slug}/connect-static`, {
+ method: "POST",
+ credentials: "include",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ token, shared }),
+ })
+ if (res.status === 403) {
+ toast.error("Only admins can connect the shared org account.")
+ return
+ }
+ if (!res.ok) {
+ toast.error("Couldn't connect.")
+ return
+ }
+ toast.success(`${entry.name} connected.`)
+ await load()
+ return
+ }
+ const res = await fetch(`${MCP_BASE}/${entry.slug}/connect`, {
+ method: "POST",
+ credentials: "include",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ shared, redirectUrl: window.location.href }),
+ })
+ if (res.status === 403) {
+ toast.error("Only admins can connect the shared org account.")
+ return
+ }
+ if (!res.ok) {
+ toast.error("Couldn't start the connection.")
+ return
+ }
+ const data = (await res.json()) as {
+ authUrl?: string
+ ok?: boolean
+ error?: string
+ }
+ if (data.authUrl) {
+ window.open(data.authUrl, "_blank", "noopener")
+ } else if (data.ok) {
+ toast.success(`${entry.name} connected.`)
+ await load()
+ } else {
+ toast.error(data.error ?? "Couldn't start the connection.")
+ }
+ } catch {
+ toast.error("Couldn't start the connection.")
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ const connectCustom = async (event: React.FormEvent) => {
+ event.preventDefault()
+ const slug = slugifyMcpName(customName)
+ const serverUrl = customServerUrl.trim()
+ if (!slug) {
+ toast.error("Enter a custom MCP name.")
+ return
+ }
+ if (!serverUrl) {
+ toast.error("Enter an OAuth MCP URL.")
+ return
+ }
+ if (apps.some((entry) => entry.slug === slug)) {
+ toast.error("That name is already used by a catalog app.")
+ return
+ }
+
+ const key = `custom:${slug}`
+ setBusy(key)
+ try {
+ const res = await fetch(`${MCP_BASE}/${slug}/connect`, {
+ method: "POST",
+ credentials: "include",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ serverUrl,
+ shared: false,
+ redirectUrl: window.location.href,
+ }),
+ })
+ if (res.status === 403) {
+ toast.error("Custom MCP URLs are staff-only.")
+ return
+ }
+ const data = (await res.json().catch(() => ({}))) as {
+ authUrl?: string
+ ok?: boolean
+ error?: string
+ }
+ if (!res.ok) {
+ toast.error(data.error ?? "Couldn't start the custom connection.")
+ return
+ }
+ if (data.authUrl) {
+ window.open(data.authUrl, "_blank", "noopener")
+ } else if (data.ok) {
+ toast.success(`${slug} connected.`)
+ setCustomName("")
+ setCustomServerUrl("")
+ await load()
+ } else {
+ toast.error(data.error ?? "Couldn't start the custom connection.")
+ }
+ } catch {
+ toast.error("Couldn't start the custom connection.")
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ const disconnect = async (entry: CatalogEntry, shared: boolean) => {
+ if (
+ !window.confirm(
+ `Disconnect ${entry.name} from ${shared ? "the shared org account" : "your personal account"}?`,
+ )
+ )
+ return
+ const key = `${entry.slug}:${shared ? "org" : "user"}`
+ setBusy(key)
+ try {
+ const res = await fetch(
+ `${MCP_BASE}/${entry.slug}?shared=${shared ? "true" : "false"}`,
+ { method: "DELETE", credentials: "include" },
+ )
+ if (res.status === 403) {
+ toast.error("Only admins can disconnect the shared org account.")
+ return
+ }
+ if (!res.ok) {
+ toast.error("Couldn't disconnect.")
+ return
+ }
+ toast.success(`${entry.name} disconnected.`)
+ await load()
+ } catch {
+ toast.error("Couldn't disconnect.")
+ } finally {
+ setBusy(null)
+ }
+ }
+
+ if (!isCompanyBrain) {
+ return (
+
+ Company Brain isn't enabled for this organization.
+
+ )
+ }
+
+ const loading = catalog === null
+ const apps = catalog ?? []
+ const catalogSlugs = new Set(apps.map((entry) => entry.slug))
+ const canClassifyCustomRows = catalogLoaded && apps.length > 0
+ const customRows = canClassifyCustomRows
+ ? rows.filter(
+ (row) =>
+ row.userId !== null &&
+ row.status === "active" &&
+ typeof row.serverUrl === "string" &&
+ row.serverUrl.length > 0 &&
+ !catalogSlugs.has(row.serverSlug),
+ )
+ : []
+ const shared = scope === "org"
+ const description = shared
+ ? "Connected by admins. Used for reads when you haven't connected your own."
+ : "Your personal accounts, used for your actions and your reads."
+
+ return (
+
+
+
+ {slackStatus?.connected && slackStatus.teamName ? (
+
+ Slack · {slackStatus.teamName}
+
+ ) : null}
+ {isAdmin ? (
+
+
+ Reconnect Slack
+
+ ) : null}
+
+
+
+ {description}
+
+
+
+ {loading ? (
+ <>
+
+
+
+ >
+ ) : (
+ <>
+ {!shared && isStaff ? (
+
+ ) : null}
+
+ {apps.map((entry) => (
+
connect(entry, shared)}
+ onDisconnect={() => disconnect(entry, shared)}
+ />
+ ))}
+ {!shared &&
+ customRows.map((row) => (
+ {}}
+ onDisconnect={() =>
+ disconnect(
+ {
+ slug: row.serverSlug,
+ name: titleCase(row.serverSlug.replace(/-/g, " ")),
+ category: "Custom OAuth MCP",
+ authType: "oauth",
+ },
+ false,
+ )
+ }
+ />
+ ))}
+ >
+ )}
+
+
+ )
+}
diff --git a/apps/web/components/settings/connections-mcp.tsx b/apps/web/components/settings/connections-mcp.tsx
index 9b7c9e08..b0cae6cb 100644
--- a/apps/web/components/settings/connections-mcp.tsx
+++ b/apps/web/components/settings/connections-mcp.tsx
@@ -4,7 +4,7 @@ import { dmSans125ClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { $fetch } from "@lib/api"
import { hasActivePlan } from "@lib/queries"
-import { GoogleDrive, Notion, OneDrive, Zoom } from "@ui/assets/icons"
+import { GoogleDrive, Granola, Notion, OneDrive, Zoom } from "@ui/assets/icons"
import { useCustomer } from "autumn-js/react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
@@ -23,8 +23,8 @@ import { useQueryState } from "nuqs"
import type { ConnectionResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { analytics } from "@/lib/analytics"
-import { ConnectAIModal } from "@/components/connect-ai-modal"
import { AddDocumentModal } from "@/components/add-document"
+import { useRouter } from "next/navigation"
import { RemoveConnectionDialog } from "@/components/remove-connection-dialog"
import { addDocumentParam } from "@/lib/search-params"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
@@ -33,7 +33,10 @@ import { SyncStatusBadge } from "@/components/settings/sync-status-badge"
import { SyncHistoryPanel } from "@/components/settings/sync-history-panel"
import { useConnectionHealth } from "@/hooks/use-connection-health"
import { useTriggerSync } from "@/hooks/use-trigger-sync"
-import { formatRelativeTime } from "@/components/settings/sync-utils"
+import {
+ formatRelativeTime,
+ getConnectionSubtitle,
+} from "@/components/settings/sync-utils"
import type { ImportProvider } from "@/components/settings/sync-utils"
type Connection = z.infer
@@ -74,6 +77,12 @@ const CONNECTORS = {
icon: Zoom,
documentLabel: "meetings",
},
+ granola: {
+ title: "Granola",
+ description: "Sync AI meeting notes and transcripts",
+ icon: Granola,
+ documentLabel: "notes",
+ },
} as const
type ConnectorProvider = keyof typeof CONNECTORS
@@ -237,7 +246,7 @@ function ConnectionRow({
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
)}
>
- {connection.email || "Unknown"}
+ {getConnectionSubtitle(connection)}
@@ -418,7 +427,7 @@ export default function ConnectionsMCP() {
const queryClient = useQueryClient()
const autumn = useCustomer()
const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam)
- const [mcpModalOpen, setMcpModalOpen] = useState(false)
+ const router = useRouter()
const [removeDialog, setRemoveDialog] = useState<{
open: boolean
connection: Connection | null
@@ -706,14 +715,14 @@ export default function ConnectionsMCP() {
-
- setMcpModalOpen(true)}>
-
-
- Connect your AI to Supermemory
-
-
-
+
router.push("/integrations?cat=ai-clients")}
+ >
+
+
+ Connect your AI to Supermemory
+
+
diff --git a/apps/web/components/settings/integrations.tsx b/apps/web/components/settings/integrations.tsx
index 125cec90..355a51e1 100644
--- a/apps/web/components/settings/integrations.tsx
+++ b/apps/web/components/settings/integrations.tsx
@@ -84,7 +84,7 @@ function PillButton({
className={cn(
"relative flex items-center justify-center gap-2",
"bg-[#0D121A]",
- "rounded-full h-11 px-4 flex-1",
+ "rounded-full h-11 min-w-0 px-3 flex-1 sm:px-4",
"cursor-pointer transition-opacity hover:opacity-80",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
"disabled:opacity-50 disabled:cursor-not-allowed",
@@ -244,6 +244,15 @@ export default function Integrations() {
analytics.extensionInstallClicked()
}
+ const addShortcutLabel =
+ createApiKeyMutation.isPending && selectedShortcutType === "add"
+ ? "Creating..."
+ : "Add shortcut"
+ const searchShortcutLabel =
+ createApiKeyMutation.isPending && selectedShortcutType === "search"
+ ? "Creating..."
+ : "Search shortcut"
+
const handleDialogClose = (open: boolean) => {
setShowApiKeyModal(open)
if (!open) {
@@ -331,7 +340,7 @@ export default function Integrations() {
-
+
handleShortcutClick("add")}
disabled={createApiKeyMutation.isPending}
@@ -342,11 +351,14 @@ export default function Integrations() {
) : (
)}
-
- {createApiKeyMutation.isPending &&
- selectedShortcutType === "add"
- ? "Creating..."
- : "Add memory shortcut"}
+
+ {addShortcutLabel}
+
+ {createApiKeyMutation.isPending &&
+ selectedShortcutType === "add"
+ ? "Creating..."
+ : "Add memory shortcut"}
+
)}
-
- {createApiKeyMutation.isPending &&
- selectedShortcutType === "search"
- ? "Creating..."
- : "Search memory shortcut"}
+
+ {searchShortcutLabel}
+
+ {createApiKeyMutation.isPending &&
+ selectedShortcutType === "search"
+ ? "Creating..."
+ : "Search memory shortcut"}
+
@@ -397,7 +412,7 @@ export default function Integrations() {
-
+
)}
-
+
{createRaycastApiKeyMutation.isPending
? "Generating..."
: "Get API key"}
@@ -415,7 +430,7 @@ export default function Integrations() {
-
+
Install extension
diff --git a/apps/web/components/settings/org-context.tsx b/apps/web/components/settings/org-context.tsx
new file mode 100644
index 00000000..0aeb7c9f
--- /dev/null
+++ b/apps/web/components/settings/org-context.tsx
@@ -0,0 +1,127 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { LoaderIcon } from "lucide-react"
+import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
+import { useOrgSettings, useUpdateOrgSettings } from "@/hooks/use-org-settings"
+import { cn } from "@lib/utils"
+
+function SectionTitle({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function OrgContext() {
+ const { data: settings, isLoading, isError } = useOrgSettings()
+ const updateSettings = useUpdateOrgSettings()
+ const [prompt, setPrompt] = useState("")
+
+ const enabled = settings?.shouldLLMFilter ?? false
+ const savedPrompt = settings?.filterPrompt ?? ""
+ const dirty = prompt.trim() !== savedPrompt.trim()
+ const settingsReady = !isLoading && !isError
+
+ useEffect(() => {
+ setPrompt(settings?.filterPrompt ?? "")
+ }, [settings?.filterPrompt])
+
+ const handleToggle = (next: boolean) => {
+ updateSettings.mutate({ shouldLLMFilter: next })
+ }
+
+ const handleSave = () => {
+ updateSettings.mutate({
+ shouldLLMFilter: true,
+ filterPrompt: prompt.trim() ? prompt.trim() : null,
+ })
+ }
+
+ return (
+
+
+
+
Organization Context
+
+ Guide how Supermemory processes and remembers your content.
+
+
+
handleToggle(!enabled)}
+ className={cn(
+ dmSansClassName(),
+ "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-full px-4 text-[12px] font-semibold transition-[color,opacity] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
+ enabled
+ ? "text-[#737373] hover:bg-white/[0.04] hover:text-[#A3A3A3]"
+ : "bg-[#0D121A] text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] hover:opacity-80",
+ )}
+ >
+ {updateSettings.isPending && (
+
+ )}
+ {enabled ? "DISABLE" : "ENABLE"}
+
+
+
+ {enabled && (
+
+ )}
+
+ )
+}
diff --git a/apps/web/components/settings/proactiveness-icon.tsx b/apps/web/components/settings/proactiveness-icon.tsx
new file mode 100644
index 00000000..550730f1
--- /dev/null
+++ b/apps/web/components/settings/proactiveness-icon.tsx
@@ -0,0 +1,29 @@
+export function ProactivenessIcon({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/apps/web/components/settings/proactiveness.tsx b/apps/web/components/settings/proactiveness.tsx
new file mode 100644
index 00000000..60c10fc3
--- /dev/null
+++ b/apps/web/components/settings/proactiveness.tsx
@@ -0,0 +1,26 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import { useHasCompanyBrain } from "@/hooks/use-company-brain"
+import { dmSans125ClassName } from "@/lib/fonts"
+import CompanyBrainAutomations from "./company-brain-automations"
+
+export default function Proactiveness() {
+ const isCompanyBrain = useHasCompanyBrain()
+
+ if (!isCompanyBrain) {
+ return (
+
+
+ Company Brain isn't enabled for this organization.
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/web/components/settings/settings-content.tsx b/apps/web/components/settings/settings-content.tsx
new file mode 100644
index 00000000..fa1db533
--- /dev/null
+++ b/apps/web/components/settings/settings-content.tsx
@@ -0,0 +1,796 @@
+"use client"
+
+import { Logo } from "@ui/assets/Logo"
+import { useAuth } from "@lib/auth-context"
+import NovaOrb from "@/components/nova/nova-orb"
+import { useEffect, useRef, useState } from "react"
+import { cn } from "@lib/utils"
+import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
+import Account from "@/components/settings/account"
+import Billing from "@/components/settings/billing"
+import Integrations from "@/components/settings/integrations"
+import ConnectionsMCP from "@/components/settings/connections-mcp"
+import CompanyBrainConnections from "@/components/settings/company-brain-connections"
+import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
+import Proactiveness from "@/components/settings/proactiveness"
+import Support from "@/components/settings/support"
+import { ErrorBoundary } from "@/components/error-boundary"
+import { useRouter } from "next/navigation"
+import { useQuery } from "@tanstack/react-query"
+import { useIsMobile } from "@hooks/use-mobile"
+import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
+import { useHasCompanyBrain } from "@/hooks/use-company-brain"
+import {
+ LogOut,
+ RotateCcw,
+ Trash2,
+ Sun,
+ LoaderIcon,
+ User as UserIcon,
+ Zap,
+ HelpCircle,
+ CreditCard,
+ ShieldAlert,
+ ChevronRight,
+ ArrowUpRight,
+ Building2,
+ X,
+} from "lucide-react"
+import { authClient } from "@lib/auth"
+import {
+ Dialog,
+ DialogContent,
+ DialogClose,
+ DialogTitle,
+} from "@ui/components/dialog"
+import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
+import { useResetOrganization } from "@/hooks/use-reset-organization"
+import { useDeleteUserAccount } from "@/hooks/use-account-settings"
+import { useDeleteOrganization } from "@/hooks/use-delete-organization"
+import { SettingsOrgSwitcher } from "@/components/settings/settings-org-switcher"
+
+export const TABS = [
+ "account",
+ "billing",
+ "integrations",
+ "connections",
+ "company-brain",
+ "proactiveness",
+ "support",
+] as const
+export type SettingsTab = (typeof TABS)[number]
+
+type NavItem = {
+ id: SettingsTab
+ label: string
+ description: string
+ icon: React.ReactNode
+}
+
+const NAV_ITEMS: NavItem[] = [
+ {
+ id: "account",
+ label: "Account",
+ description: "Your profile and organization",
+ icon: ,
+ },
+ {
+ id: "billing",
+ label: "Billing",
+ description: "Plan, usage and payments",
+ icon: ,
+ },
+ {
+ id: "integrations",
+ label: "Integrations",
+ description: "Save, sync and search across tools",
+ icon: ,
+ },
+ {
+ id: "connections",
+ label: "Connections & MCP",
+ description: "Drive, Notion, OneDrive, MCP",
+ icon: ,
+ },
+ {
+ id: "company-brain",
+ label: "Company Brain",
+ description: "Connect apps to your brain — org and personal",
+ icon: ,
+ },
+ {
+ id: "proactiveness",
+ label: "Proactiveness",
+ description: "Scheduled digests and unprompted actions",
+ icon: ,
+ },
+ {
+ id: "support",
+ label: "Support & Help",
+ description: "Get help or share feedback",
+ icon: ,
+ },
+]
+
+const MODAL_SURFACE_SHADOW =
+ "0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset"
+
+const INSET_SHADOW = "inset 1.313px 1.313px 3.938px rgba(0,0,0,0.7)"
+
+export function parseHashToTab(hash: string): SettingsTab {
+ const cleaned = hash.replace("#", "").toLowerCase()
+ return TABS.includes(cleaned as SettingsTab)
+ ? (cleaned as SettingsTab)
+ : "account"
+}
+
+function IdentityCard({ displayName }: { displayName: string }) {
+ const firstName = displayName?.split(" ")[0] || ""
+
+ return (
+
+
+
+
+
+
+ {firstName ? `${firstName}'s` : "Your"}
+
+
+ supermemory
+
+
+
+
+ )
+}
+
+export function SettingsContent({
+ activeTab,
+ onTabChange,
+ dialogPortalContainer,
+ className,
+ showIdentity = true,
+}: {
+ activeTab: SettingsTab
+ onTabChange: (tab: SettingsTab) => void
+ dialogPortalContainer?: HTMLElement | null
+ className?: string
+ showIdentity?: boolean
+}) {
+ const { user, org, organizations, setActiveOrg, clearActiveOrg } = useAuth()
+
+ const isCompanyBrain = useHasCompanyBrain()
+ // Company Brain orgs manage tools inside Company Brain; hide the generic tabs.
+ const navItems = isCompanyBrain
+ ? NAV_ITEMS.filter(
+ (item) => item.id !== "integrations" && item.id !== "connections",
+ )
+ : NAV_ITEMS
+ const router = useRouter()
+ const isMobile = useIsMobile()
+ const localStorageUsername = useLocalStorageUsername()
+
+ const [isResetDialogOpen, setIsResetDialogOpen] = useState(false)
+ const [resetConfirmation, setResetConfirmation] = useState("")
+ const resetOrganization = useResetOrganization()
+
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
+ const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("")
+ const deleteUserAccount = useDeleteUserAccount()
+
+ const [isDeleteOrgDialogOpen, setIsDeleteOrgDialogOpen] = useState(false)
+ const [deleteOrgConfirm, setDeleteOrgConfirm] = useState("")
+ const deleteOrgInputRef = useRef(null)
+ const deleteOrgDialogTimerRef = useRef | null>(
+ null,
+ )
+ const deleteOrganization = useDeleteOrganization()
+ const activeOrgId = org?.id
+ const previousOrgIdRef = useRef(activeOrgId)
+
+ // Only owners can delete the organization.
+ const activeMemberRoleQuery = useQuery({
+ queryKey: ["organization", org?.id, "active-member-role"],
+ queryFn: async () => {
+ const result = await authClient.organization.getActiveMember()
+ if (result.error) {
+ throw new Error(result.error.message ?? "Failed to load team role")
+ }
+ return result.data?.role ?? null
+ },
+ enabled: !!org?.id,
+ staleTime: 60_000,
+ })
+ const isOwner = (activeMemberRoleQuery.data ?? "").toLowerCase() === "owner"
+
+ const [dangerMenuOpen, setDangerMenuOpen] = useState(false)
+
+ useEffect(() => {
+ if (previousOrgIdRef.current === activeOrgId) return
+ previousOrgIdRef.current = activeOrgId
+ setDangerMenuOpen(false)
+ setIsDeleteOrgDialogOpen(false)
+ setDeleteOrgConfirm("")
+ }, [activeOrgId])
+
+ useEffect(() => {
+ if (!isDeleteOrgDialogOpen) return
+
+ document.body.style.pointerEvents = ""
+ const focusTimer = setTimeout(() => {
+ deleteOrgInputRef.current?.focus()
+ }, 0)
+
+ return () => clearTimeout(focusTimer)
+ }, [isDeleteOrgDialogOpen])
+
+ useEffect(() => {
+ return () => {
+ if (deleteOrgDialogTimerRef.current) {
+ clearTimeout(deleteOrgDialogTimerRef.current)
+ }
+ }
+ }, [])
+
+ const openDeleteOrganizationDialog = () => {
+ setDangerMenuOpen(false)
+ setDeleteOrgConfirm("")
+ if (deleteOrgDialogTimerRef.current) {
+ clearTimeout(deleteOrgDialogTimerRef.current)
+ }
+ deleteOrgDialogTimerRef.current = setTimeout(() => {
+ document.body.style.pointerEvents = ""
+ setIsDeleteOrgDialogOpen(true)
+ deleteOrgDialogTimerRef.current = null
+ }, 120)
+ }
+
+ const displayName =
+ user?.displayUsername ||
+ localStorageUsername ||
+ user?.name ||
+ user?.email?.split("@")[0] ||
+ ""
+
+ const handleLogout = async () => {
+ await authClient.signOut()
+ router.push("/login")
+ }
+
+ const handleIntegrations = () => {
+ void router.push("/integrations")
+ }
+
+ const handleDeleteAccount = async () => {
+ if (deleteEmailConfirm !== user?.email) return
+ deleteUserAccount.mutate(
+ { confirmation: deleteEmailConfirm },
+ {
+ onSuccess: () => {
+ setIsDeleteDialogOpen(false)
+ setDeleteEmailConfirm("")
+ router.push("/login")
+ },
+ },
+ )
+ }
+
+ const handleDeleteOrganization = async () => {
+ if (!org || deleteOrgConfirm !== org.name) return
+ deleteOrganization.mutate(org.id, {
+ onSuccess: async () => {
+ setIsDeleteOrgDialogOpen(false)
+ setDeleteOrgConfirm("")
+ const remaining = (organizations ?? []).filter((o) => o.id !== org.id)
+ if (remaining[0]) {
+ await setActiveOrg(remaining[0].slug)
+ window.location.href = "/"
+ } else {
+ await clearActiveOrg()
+ window.location.href = "/onboarding"
+ }
+ },
+ })
+ }
+
+ return (
+ <>
+
+ {/* Left rail */}
+
+ {showIdentity && !isMobile && (
+
+
+
+ )}
+
+
+
+
+
+
+ {navItems.map((item) => {
+ const isExternal = item.id === "integrations"
+ const isActive = !isExternal && activeTab === item.id
+ return (
+
+ isExternal ? handleIntegrations() : onTabChange(item.id)
+ }
+ className={cn(
+ "relative rounded-xl transition-colors flex items-center gap-3 shrink-0 group",
+ isMobile
+ ? "px-3 py-2 text-sm border border-white/[0.06]"
+ : "text-left px-3 py-2",
+ isActive
+ ? "bg-[#14161A] text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
+ : "text-white/60 hover:text-white hover:bg-white/[0.025]",
+ )}
+ >
+ {!isMobile && (
+
+ )}
+
+ {item.icon}
+
+
+ {item.label}
+
+ {isExternal && (
+
+ )}
+
+ )
+ })}
+
+ {!isMobile &&
}
+
+
+
+
+ Log out
+
+
+
+
+
+
+
+
+ Danger zone
+
+
+
+
+
+ {
+ setDangerMenuOpen(false)
+ setIsResetDialogOpen(true)
+ }}
+ className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#A37A2E] hover:text-[#C7991B] hover:bg-[#1A1200]/60 transition-colors cursor-pointer"
+ >
+
+ Reset data
+
+
+ {isOwner && (
+ <>
+
+
+
+
+
+ Delete organization
+
+
+ >
+ )}
+
+
+
+ {
+ setDangerMenuOpen(false)
+ setIsDeleteDialogOpen(true)
+ }}
+ className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#C73B1B] hover:bg-[#290F0A]/60 transition-colors cursor-pointer"
+ >
+
+
+ Delete account
+
+
+
+
+
+
+
+ {/* Content */}
+
+
+ Something went wrong loading this section.{" "}
+ window.location.reload()}
+ >
+ Reload
+
+
+ }
+ >
+ {activeTab === "account" && }
+ {activeTab === "billing" && }
+ {activeTab === "integrations" && }
+ {activeTab === "connections" && }
+ {activeTab === "company-brain" && }
+ {activeTab === "proactiveness" && }
+ {activeTab === "support" && }
+
+
+
+
+ {/* Reset data dialog */}
+ {(() => {
+ const confirmText = org?.name || user?.name || ""
+ return (
+ {
+ setIsResetDialogOpen(open)
+ if (!open) setResetConfirmation("")
+ }}
+ >
+
+
+
+
+ Reset all data?
+
+
+ This permanently removes:
+
+
+ All documents and memories
+ All connections (Google Drive, Notion, etc.)
+ All custom spaces (default space stays)
+ Organization settings and filters
+
+
+ Your account and billing plan stay intact.{" "}
+
+ This cannot be undone.
+
+
+
+
+
+ Type{" "}
+
+ {confirmText || "your name"}
+ {" "}
+ to confirm:
+
+
setResetConfirmation(e.target.value)}
+ placeholder={confirmText || "Your name"}
+ autoComplete="off"
+ className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C7991B]/50 transition-colors"
+ />
+
+
+
+
+ Cancel
+
+
+
+ resetOrganization.mutate(
+ { confirmation: confirmText },
+ {
+ onSuccess: () => {
+ setIsResetDialogOpen(false)
+ setResetConfirmation("")
+ },
+ },
+ )
+ }
+ className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#1A1200] text-[#C7991B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
+ >
+ {resetOrganization.isPending ? (
+
+ ) : (
+
+ )}
+ {resetOrganization.isPending
+ ? "Resetting…"
+ : "Reset organization"}
+
+
+
+
+
+ )
+ })()}
+
+ {/* Delete account dialog */}
+ {
+ setIsDeleteDialogOpen(open)
+ if (!open) setDeleteEmailConfirm("")
+ }}
+ >
+
+
+
+
+ Delete your account?
+
+
+ Permanently deletes all your data and cancels any active
+ subscriptions.{" "}
+
+ This cannot be undone.
+
+
+
+
+
+ Type your email{" "}
+ {user?.email} to
+ confirm:
+
+
setDeleteEmailConfirm(e.target.value)}
+ placeholder={user?.email ?? "your@email.com"}
+ className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C73B1B]/50 transition-colors"
+ />
+
+
+
+
+ Cancel
+
+
+
+ {deleteUserAccount.isPending ? (
+
+ ) : (
+
+ )}
+ {deleteUserAccount.isPending ? "Deleting…" : "Delete account"}
+
+
+
+
+
+
+ {/* Delete organization dialog */}
+ {
+ setIsDeleteOrgDialogOpen(open)
+ if (!open) setDeleteOrgConfirm("")
+ }}
+ >
+ {
+ event.preventDefault()
+ deleteOrgInputRef.current?.focus()
+ }}
+ >
+
+
+
+ Delete this organization?
+
+
+ This action permanently removes the selected workspace.
+
+
+
+
+
+
+
+
+
+
+
+ Permanently deletes{" "}
+
+ {org?.name || "this organization"}
+ {" "}
+ and all of its documents, spaces, connections, and members.{" "}
+
+ This cannot be undone.
+
+
+
+
+ Type{" "}
+
+ {org?.name}
+ {" "}
+ to confirm
+
+ setDeleteOrgConfirm(e.target.value)}
+ placeholder={org?.name ?? "Organization name"}
+ autoComplete="off"
+ className="h-11 w-full rounded-[12px] border border-white/[0.08] bg-[#0D121A] px-4 text-[14px] text-white placeholder:text-[#525D6E] transition-colors focus:border-[#C73B1B]/55 focus:outline-none focus:ring-2 focus:ring-[#C73B1B]/15"
+ style={{ boxShadow: INSET_SHADOW }}
+ />
+
+
+
+
+
+
+ Cancel
+
+
+
+ {deleteOrganization.isPending ? (
+
+ ) : null}
+ {deleteOrganization.isPending
+ ? "Deleting..."
+ : "Delete organization"}
+
+
+
+
+ >
+ )
+}
diff --git a/apps/web/components/settings/settings-modal.tsx b/apps/web/components/settings/settings-modal.tsx
new file mode 100644
index 00000000..f94b889d
--- /dev/null
+++ b/apps/web/components/settings/settings-modal.tsx
@@ -0,0 +1,147 @@
+"use client"
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react"
+import { useQueryState } from "nuqs"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogTitle,
+} from "@ui/components/dialog"
+import { X } from "lucide-react"
+import { cn } from "@lib/utils"
+import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
+import { analytics } from "@/lib/analytics"
+import {
+ SettingsContent,
+ TABS,
+ type SettingsTab,
+} from "@/components/settings/settings-content"
+
+const SETTINGS_PARAM = "settings"
+
+/** Integrations is a link-out, not a modal tab, so it falls back to account. */
+function parseTab(value: string | null): SettingsTab {
+ if (
+ value &&
+ value !== "integrations" &&
+ TABS.includes(value as SettingsTab)
+ ) {
+ return value as SettingsTab
+ }
+ return "account"
+}
+
+type SettingsModalContextValue = {
+ open: boolean
+ openSettings: (tab?: SettingsTab) => void
+ close: () => void
+}
+
+const SettingsModalContext = createContext(
+ null,
+)
+
+export function SettingsModalProvider({ children }: { children: ReactNode }) {
+ const [param, setParam] = useQueryState(SETTINGS_PARAM)
+ const [settingsDialogContent, setSettingsDialogContent] =
+ useState(null)
+ const open = param !== null
+ const tab = parseTab(param)
+
+ const openSettings = useCallback(
+ (next?: SettingsTab) => {
+ setParam(next ?? "account")
+ },
+ [setParam],
+ )
+
+ const close = useCallback(() => setParam(null), [setParam])
+
+ const handleTabChange = useCallback(
+ (next: SettingsTab) => {
+ setParam(next)
+ analytics.settingsTabChanged({ tab: next })
+ },
+ [setParam],
+ )
+
+ const value = useMemo(
+ () => ({ open, openSettings, close }),
+ [open, openSettings, close],
+ )
+
+ return (
+
+ {children}
+ {
+ if (!next) setParam(null)
+ }}
+ >
+
+
+
+ Settings
+
+
+
+ Close
+
+
+
+
+
+
+
+ )
+}
+
+export function useSettingsModal() {
+ const ctx = useContext(SettingsModalContext)
+ if (!ctx) {
+ throw new Error(
+ "useSettingsModal must be used within a SettingsModalProvider",
+ )
+ }
+ return ctx
+}
diff --git a/apps/web/components/settings/settings-org-switcher.tsx b/apps/web/components/settings/settings-org-switcher.tsx
new file mode 100644
index 00000000..1e2dcd82
--- /dev/null
+++ b/apps/web/components/settings/settings-org-switcher.tsx
@@ -0,0 +1,156 @@
+"use client"
+
+import { useMemo, useState } from "react"
+import { useRouter } from "next/navigation"
+import { useCustomer } from "autumn-js/react"
+import { toast } from "sonner"
+import {
+ Building2,
+ Check,
+ ChevronsUpDown,
+ LoaderIcon,
+ Plus,
+} from "lucide-react"
+import { cn } from "@lib/utils"
+import { dmSansClassName } from "@/lib/fonts"
+import { useAuth } from "@lib/auth-context"
+import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
+import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
+import { useOrgSummaries } from "@/hooks/use-org-summaries"
+import { useTokenUsage, type PlanType } from "@/hooks/use-token-usage"
+
+export function SettingsOrgSwitcher() {
+ const { org, organizations, setActiveOrg } = useAuth()
+ const router = useRouter()
+ const autumn = useCustomer()
+ const { currentPlan } = useTokenUsage(autumn)
+ const { data: orgSummaries } = useOrgSummaries()
+
+ const [open, setOpen] = useState(false)
+ const [switchingId, setSwitchingId] = useState(null)
+
+ const planByOrgId = useMemo(() => {
+ const map = new Map()
+ for (const summary of orgSummaries ?? []) {
+ map.set(summary.orgId, summary.plan)
+ }
+ return map
+ }, [orgSummaries])
+
+ const activeOrgPlan = org?.id
+ ? resolveOrgPlan(org.id, true, currentPlan, planByOrgId)
+ : currentPlan
+
+ const sortedOrgs = useMemo(
+ () =>
+ [...(organizations ?? [])].sort((a, b) => a.name.localeCompare(b.name)),
+ [organizations],
+ )
+
+ const handleSwitch = async (slug: string, id: string) => {
+ if (id === org?.id) {
+ setOpen(false)
+ return
+ }
+ setSwitchingId(id)
+ try {
+ await setActiveOrg(slug)
+ window.location.reload()
+ } catch (error) {
+ console.error("Failed to switch organization:", error)
+ setSwitchingId(null)
+ toast.error("Failed to switch organization")
+ }
+ }
+
+ const handleCreate = () => {
+ setOpen(false)
+ router.push("/onboarding?new=1")
+ }
+
+ return (
+
+
+
+
+
+
+
+ {org?.name ?? "Personal"}
+
+
+
+
+
+
+ event.stopPropagation()}
+ onTouchMoveCapture={(event) => event.stopPropagation()}
+ >
+ {sortedOrgs.map((organization) => {
+ const isCurrent = organization.id === org?.id
+ const isSwitching = switchingId === organization.id
+ const plan = resolveOrgPlan(
+ organization.id,
+ isCurrent,
+ currentPlan,
+ planByOrgId,
+ )
+ return (
+ handleSwitch(organization.slug, organization.id)}
+ className={cn(
+ "w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left transition-colors",
+ isCurrent ? "bg-white/5" : "hover:bg-white/5 cursor-pointer",
+ "disabled:cursor-default",
+ )}
+ >
+
+
+ {organization.name}
+
+ {isSwitching ? (
+
+ ) : isCurrent ? (
+
+ ) : null}
+
+
+ )
+ })}
+
+
+
+
+
+
+ Create organization
+
+
+
+ )
+}
diff --git a/apps/web/components/settings/sync-utils.ts b/apps/web/components/settings/sync-utils.ts
index 247d06fc..b22e1e70 100644
--- a/apps/web/components/settings/sync-utils.ts
+++ b/apps/web/components/settings/sync-utils.ts
@@ -43,9 +43,17 @@ export const PROVIDER_DISPLAY_NAMES: Record = {
"web-crawler": "Web Crawler",
s3: "S3",
zoom: "Zoom",
+ granola: "Granola",
+}
+
+export function getConnectionSubtitle(conn: {
+ provider: string
+ email?: string | null
+}): string {
+ if (conn.provider === "granola") return "Granola workspace"
+ return conn.email || "Unknown"
}
-/** Provider type union matching the backend import endpoint */
export type ImportProvider =
| "google-drive"
| "notion"
@@ -55,3 +63,4 @@ export type ImportProvider =
| "web-crawler"
| "s3"
| "zoom"
+ | "granola"
diff --git a/apps/web/components/slack-connect-card.tsx b/apps/web/components/slack-connect-card.tsx
new file mode 100644
index 00000000..2fc6de2c
--- /dev/null
+++ b/apps/web/components/slack-connect-card.tsx
@@ -0,0 +1,106 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { useHasCompanyBrain } from "@/hooks/use-company-brain"
+
+const BACKEND =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+
+type SlackStatus = { connected: boolean; teamName: string | null }
+
+function SlackMark({ className }: { className?: string }) {
+ return (
+
+ Slack
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export function SlackConnectCard() {
+ const isCompanyBrain = useHasCompanyBrain()
+ const [status, setStatus] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ if (!isCompanyBrain) return
+ let active = true
+ ;(async () => {
+ try {
+ const res = await fetch(`${BACKEND}/brain/slack/status`, {
+ credentials: "include",
+ })
+ if (active && res.ok) setStatus((await res.json()) as SlackStatus)
+ } finally {
+ if (active) setLoading(false)
+ }
+ })()
+ return () => {
+ active = false
+ }
+ }, [isCompanyBrain])
+
+ if (!isCompanyBrain || loading) return null
+
+ const connected = status?.connected
+
+ return (
+
+
+
+ Add Supermemory to your Slack
+
+
+ {connected
+ ? `Connected to ${status?.teamName ?? "your workspace"}.`
+ : "Answer from your company brain and act on connected apps — right inside Slack."}
+
+
+ {connected ? (
+
+
+ Connected
+
+ ) : (
+
+
+ Add to Slack
+
+ )}
+
+ )
+}
diff --git a/apps/web/components/space-profile-modal.tsx b/apps/web/components/space-profile-modal.tsx
new file mode 100644
index 00000000..b9fe14b0
--- /dev/null
+++ b/apps/web/components/space-profile-modal.tsx
@@ -0,0 +1,39 @@
+"use client"
+
+import { dmSansClassName } from "@/lib/fonts"
+import { SpaceProfileContent } from "@/components/space-profile-panel"
+import { cn } from "@lib/utils"
+import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
+
+type SpaceProfileModalProps = {
+ containerTag: string
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function SpaceProfileModal({
+ containerTag,
+ open,
+ onOpenChange,
+}: SpaceProfileModalProps) {
+ return (
+
+
+ Space Profile
+
+ onOpenChange(false)}
+ />
+
+
+
+ )
+}
diff --git a/apps/web/components/space-profile-panel.tsx b/apps/web/components/space-profile-panel.tsx
new file mode 100644
index 00000000..9e8ef119
--- /dev/null
+++ b/apps/web/components/space-profile-panel.tsx
@@ -0,0 +1,255 @@
+"use client"
+
+import { Brain, Loader, X } from "lucide-react"
+import { motion } from "motion/react"
+import { useEffect, useState } from "react"
+import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
+import { useSpaceProfile } from "@/hooks/use-space-profile"
+import {
+ useSpaceContext,
+ useUpdateSpaceContext,
+} from "@/hooks/use-space-context"
+import { cn } from "@lib/utils"
+
+type SpaceProfilePanelProps = {
+ containerTag: string
+ isOpen: boolean
+ onClose: () => void
+}
+
+type SpaceProfileContentProps = {
+ containerTag: string
+ onClose?: () => void
+}
+
+function CountBadge({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function LoadingState() {
+ return (
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+ ))}
+
+ )
+}
+
+function EmptyState() {
+ return (
+
+
+
+
+
No profile yet
+
+ Add more memories and Nova will learn about this space.
+
+
+ )
+}
+
+function ProfileSection({ label, items }: { label: string; items: string[] }) {
+ if (items.length === 0) return null
+
+ return (
+
+
+
+ {label}
+
+ {items.length}
+
+
+ {items.map((item, index) => (
+
+ ))}
+
+
+ )
+}
+
+function SpaceContextEditor({ containerTag }: { containerTag: string }) {
+ const { data, isLoading } = useSpaceContext(containerTag)
+ const update = useUpdateSpaceContext()
+ const [value, setValue] = useState("")
+ const saved = data?.entityContext ?? ""
+
+ useEffect(() => {
+ setValue(data?.entityContext ?? "")
+ }, [data?.entityContext])
+
+ const dirty = value.trim() !== saved.trim()
+
+ const handleSave = () => {
+ update.mutate({
+ containerTag,
+ entityContext: value.trim() ? value.trim() : null,
+ })
+ }
+
+ return (
+
+
+
+ What to remember
+
+
+ Tell Nova what matters in this space — it shapes which memories get
+ extracted.
+
+
+
+ )
+}
+
+export function SpaceProfileContent({
+ containerTag,
+ onClose,
+}: SpaceProfileContentProps) {
+ const { data, isLoading, error } = useSpaceProfile(containerTag)
+ const keyFacts = data?.static ?? []
+ const recentContext = data?.dynamic ?? []
+ const totalCount = keyFacts.length + recentContext.length
+
+ return (
+
+
+
+
+ Space Profile
+
+
+ What Nova knows in this space
+
+
+ {onClose ? (
+
+
+
+ ) : null}
+
+
+
+
+
+ {isLoading ? (
+
+ ) : error ? (
+
+ Failed to load space profile.
+
+ ) : totalCount === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
+
+export function SpaceProfilePanel({
+ containerTag,
+ isOpen,
+ onClose,
+}: SpaceProfilePanelProps) {
+ if (!isOpen) return null
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/web/components/space-selector.tsx b/apps/web/components/space-selector.tsx
index f74d57da..8bba3f38 100644
--- a/apps/web/components/space-selector.tsx
+++ b/apps/web/components/space-selector.tsx
@@ -6,11 +6,13 @@ import { useQuery } from "@tanstack/react-query"
import { cn } from "@lib/utils"
import { $fetch } from "@lib/api"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
-import { DEFAULT_PROJECT_ID } from "@lib/constants"
-import { ChevronDownIcon, XIcon, Loader2, Trash2 } from "lucide-react"
+import { DEFAULT_PROJECT_ID, SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
+import { useHasCompanyBrain } from "@/hooks/use-company-brain"
+import { ChevronDownIcon, Pencil, XIcon, Loader2, Trash2 } from "lucide-react"
import type { ContainerTagListType } from "@lib/types"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import { AddSpaceModal } from "./add-space-modal"
+import { EditSpaceModal } from "./edit-space-modal"
import { SelectSpacesModal } from "./select-spaces-modal"
import { SpaceGlyph } from "./space-glyph"
import { useProjectMutations } from "@/hooks/use-project-mutations"
@@ -54,12 +56,13 @@ export interface SpaceSelectorProps {
compact?: boolean
includeAuto?: boolean
hideCount?: boolean
+ enableEdit?: boolean
}
const triggerVariants = {
default:
- "h-10 min-h-10 shrink-0 rounded-full border border-[#161F2C] bg-muted px-3 gap-2 " +
- "hover:bg-white/5 hover:border-[#2261CA33] " +
+ "h-10 min-h-10 shrink-0 rounded-full bg-muted px-3 gap-2 " +
+ "hover:bg-white/5 " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2261CA33]/35",
insideOut:
"h-10 min-h-10 gap-2 px-3 rounded-full bg-[#0D121A] shadow-inside-out hover:bg-[#121820]",
@@ -114,9 +117,11 @@ export function SpaceSelector({
compact = false,
includeAuto = false,
hideCount = false,
+ enableEdit = false,
}: SpaceSelectorProps) {
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [showSelectSpacesModal, setShowSelectSpacesModal] = useState(false)
+ const [showEditDialog, setShowEditDialog] = useState(false)
const [recents, setRecents] = useState([])
const [deleteDialog, setDeleteDialog] = useState<{
open: boolean
@@ -143,12 +148,16 @@ export function SpaceSelector({
useProjectMutations()
const { allProjects, isLoading } = useContainerTags()
const { user } = useAuth()
+ const hasCompanyBrain = useHasCompanyBrain()
+ const defaultTag = hasCompanyBrain
+ ? SHARED_TEAM_BRAIN_TAG
+ : DEFAULT_PROJECT_ID
useEffect(() => {
setRecents(readRecents())
}, [])
- const activeTag = selectedProjects[0] ?? DEFAULT_PROJECT_ID
+ const activeTag = selectedProjects[0] ?? defaultTag
const { data: spaceCountData } = useQuery({
queryKey: ["space-selector-count", activeTag],
queryFn: async (): Promise => {
@@ -190,7 +199,7 @@ export function SpaceSelector({
isAuto: boolean
isOwnSpace: boolean
}>(() => {
- const containerTag = selectedProjects[0] ?? ""
+ const containerTag = selectedProjects[0] ?? defaultTag
if (includeAuto && containerTag === AUTO_CHAT_SPACE_ID) {
return {
name: "Auto",
@@ -229,7 +238,20 @@ export function SpaceSelector({
isAuto: false,
isOwnSpace,
}
- }, [allProjects, selectedProjects, pluginMetaMap, includeAuto, user?.id])
+ }, [
+ allProjects,
+ selectedProjects,
+ pluginMetaMap,
+ includeAuto,
+ user?.id,
+ defaultTag,
+ ])
+
+ const canEditCurrent =
+ enableEdit &&
+ !displayInfo.isAuto &&
+ !displayInfo.plugin &&
+ !displayInfo.isOwnSpace
const pushRecent = useCallback((tag: string) => {
setRecents((prev) => {
@@ -371,85 +393,115 @@ export function SpaceSelector({
return (
<>
-
-
+
+
+
+ setShowSelectSpacesModal(true)}
+ aria-label={
+ isLoading
+ ? "Loading spaces"
+ : `Space: ${displayInfo.name}. Open selector.`
+ }
+ className={cn(
+ "flex min-w-0 max-w-full items-center cursor-pointer transition-colors",
+ triggerVariants[variant],
+ variant === "default" &&
+ compact &&
+ "h-9 min-h-9 gap-1.5 px-2.5",
+ dmSansClassName(),
+ triggerClassName,
+ )}
+ >
+
+ {displayInfo.isAuto ? (
+
+ ) : displayInfo.isOwnSpace ? (
+
+ ) : displayInfo.plugin ? (
+ displayInfo.plugin.iconSrc ? (
+
+ ) : (
+
+ {pluginInitial(displayInfo.plugin.label)}
+
+ )
+ ) : (
+
+ )}
+
+
+ {isLoading ? "…" : displayInfo.name}
+
+ {!compact &&
+ !hideCount &&
+ spaceCountData !== undefined &&
+ spaceCountData > 0 && (
+
+ · {formatCount(spaceCountData)}
+
+ )}
+
+
+
+
+ Switch space
+
+
+ {canEditCurrent && (
setShowSelectSpacesModal(true)}
- aria-label={
- isLoading
- ? "Loading spaces"
- : `Space: ${displayInfo.name}. Open selector.`
- }
+ onClick={(event) => {
+ event.stopPropagation()
+ setShowEditDialog(true)
+ }}
+ aria-label={`Edit ${displayInfo.name}`}
className={cn(
- "flex min-w-0 max-w-full items-center cursor-pointer transition-colors",
- triggerVariants[variant],
- variant === "default" && compact && "h-9 min-h-9 gap-1.5 px-2.5",
- dmSansClassName(),
- triggerClassName,
+ "pointer-events-none absolute top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-md text-[#A1A1AA] opacity-0 transition-opacity hover:text-white group-hover:pointer-events-auto group-hover:opacity-100",
+ compact ? "left-2.5 size-4" : "left-3 size-[18px]",
)}
>
- {displayInfo.isAuto ? (
-
- ) : displayInfo.isOwnSpace ? (
-
- ) : displayInfo.plugin ? (
- displayInfo.plugin.iconSrc ? (
-
- ) : (
-
- {pluginInitial(displayInfo.plugin.label)}
-
- )
- ) : (
-
- )}
-
- {isLoading ? "…" : displayInfo.name}
-
- {!compact &&
- !hideCount &&
- spaceCountData !== undefined &&
- spaceCountData > 0 && (
-
- · {formatCount(spaceCountData)}
-
- )}
-
+
-
-
- Switch space
-
-
+ )}
+
+ {canEditCurrent && (
+
+ )}
+
{
diff --git a/apps/web/components/superloader.tsx b/apps/web/components/superloader.tsx
index 5cac100f..2c40152f 100644
--- a/apps/web/components/superloader.tsx
+++ b/apps/web/components/superloader.tsx
@@ -47,7 +47,7 @@ export function SuperLoader({
{label}
diff --git a/apps/web/components/timeline-view.tsx b/apps/web/components/timeline-view.tsx
index 18437d77..c0975c89 100644
--- a/apps/web/components/timeline-view.tsx
+++ b/apps/web/components/timeline-view.tsx
@@ -6,6 +6,7 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
+import { isYouTubeUrl } from "@/lib/url-helpers"
import { SyncLogoIcon } from "@ui/assets/icons"
import { DocumentIcon } from "@/components/document-icon"
import { CheckIcon, ChevronDownIcon } from "lucide-react"
@@ -41,7 +42,7 @@ type CategoryInfo = { label: string; singularLabel: string; key: string }
function getDocumentTypeInfo(doc: DocumentWithMemories): CategoryInfo {
if (doc.source === "mcp")
return { label: "MCP Items", singularLabel: "MCP Item", key: "mcp" }
- if (doc.url?.includes("youtube.com") || doc.url?.includes("youtu.be"))
+ if (isYouTubeUrl(doc.url))
return {
label: "YouTube Videos",
singularLabel: "YouTube Video",
diff --git a/apps/web/components/user-profile-menu.tsx b/apps/web/components/user-profile-menu.tsx
index ec21e8fc..d4ba056f 100644
--- a/apps/web/components/user-profile-menu.tsx
+++ b/apps/web/components/user-profile-menu.tsx
@@ -12,11 +12,23 @@ import {
} from "@ui/components/dropdown-menu"
import { authClient } from "@lib/auth"
import { useRouter } from "next/navigation"
-import { LogOut, Settings, RotateCcw, HelpCircle, LifeBuoy } from "lucide-react"
+import {
+ LogOut,
+ Settings,
+ RotateCcw,
+ HelpCircle,
+ LifeBuoy,
+ Building2,
+ Sun,
+} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
import { useTokenUsage } from "@/hooks/use-token-usage"
+import { useSettingsModal } from "@/components/settings/settings-modal"
+import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
+import { useHasCompanyBrain } from "@/hooks/use-company-brain"
+import { useViewMode } from "@/lib/view-mode-context"
export function UserProfileMenu({
className,
@@ -29,12 +41,21 @@ export function UserProfileMenu({
}) {
const { user } = useAuth()
const router = useRouter()
+ const { openSettings } = useSettingsModal()
+ const { setViewMode } = useViewMode()
+ const isCompanyBrain = useHasCompanyBrain()
const { resetOrgOnboarded } = useOrgOnboarding()
const autumn = useCustomer()
const { currentPlan, isLoading: planLoading } = useTokenUsage(autumn)
const planBadgeLabel =
- currentPlan === "pro" ? "PRO" : currentPlan === "scale" ? "SCALE" : null
+ currentPlan === "pro"
+ ? "PRO"
+ : currentPlan === "max"
+ ? "MAX"
+ : currentPlan === "scale"
+ ? "SCALE"
+ : null
const handleTryOnboarding = () => {
resetOrgOnboarded()
@@ -48,7 +69,7 @@ export function UserProfileMenu({
} catch {
// still navigate if the request fails (offline, etc.)
}
- router.push("/login/new")
+ router.push("/login")
})()
}
@@ -138,12 +159,37 @@ export function UserProfileMenu({
router.push("/settings")}
+ onClick={() => openSettings()}
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
Settings
+
openSettings("company-brain")}
+ className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
+ >
+
+ Company Brain
+
+ {isCompanyBrain ? (
+
openSettings("proactiveness")}
+ className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
+ >
+
+ Proactiveness
+
+ ) : null}
+ {isCompanyBrain ? (
+
void setViewMode("integrations")}
+ className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
+ >
+
+ Integrations
+
+ ) : null}
whose top margin detaches the bullet */
+.chat-markdown-content li > p:first-child {
+ margin-top: 0;
+}
+
+/* Model spams `---` between every section; headings already separate them */
+.chat-markdown-content hr {
+ display: none;
+}
+
.shadow-inside-out {
box-shadow:
inset 0 2px 4px rgba(0, 0, 0, 0.3),
@@ -236,3 +247,319 @@
width: 92%;
}
}
+
+@keyframes login-marquee {
+ from {
+ transform: translateX(0);
+ }
+ to {
+ transform: translateX(-50%);
+ }
+}
+
+.login-page-grid {
+ display: grid;
+ min-height: 100dvh;
+ height: 100dvh;
+ overflow-x: hidden;
+ overflow-y: auto;
+ grid-template-columns: 1fr;
+ grid-template-rows: auto minmax(0, 1fr);
+}
+
+@media (min-width: 1024px) {
+ .login-page-grid {
+ height: 100dvh;
+ overflow: hidden;
+ grid-template-columns: 65% 35%;
+ grid-template-rows: minmax(0, 1fr);
+ }
+}
+
+.login-page-mobile-bg {
+ pointer-events: none;
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+}
+
+@media (min-width: 1024px) {
+ .login-page-mobile-bg {
+ display: none;
+ }
+}
+
+.login-tools-panel-inner {
+ flex: 1;
+ min-height: 0;
+}
+
+.login-panel-orb {
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(
+ ellipse 130% 100% at 50% 45%,
+ rgba(70, 155, 255, 0.24) 0%,
+ rgba(35, 100, 210, 0.14) 38%,
+ rgba(8, 22, 45, 0.55) 68%,
+ #030912 100%
+ ),
+ radial-gradient(
+ ellipse 90% 70% at 50% 100%,
+ rgba(50, 130, 240, 0.16) 0%,
+ transparent 65%
+ );
+}
+
+.login-panel-orb-image {
+ position: absolute;
+ inset: -10% -15% -5% -15%;
+ background-image: url("/onboarding/bg-gradient-0.png");
+ background-size: cover;
+ background-position: center 35%;
+ background-repeat: no-repeat;
+ opacity: 0.22;
+ pointer-events: none;
+}
+
+.login-panel-orb-image-alt {
+ position: absolute;
+ inset: -10% -15% -5% -15%;
+ background-image: url("/onboarding/bg-gradient-1.png");
+ background-size: cover;
+ background-position: center 35%;
+ background-repeat: no-repeat;
+ opacity: 0.11;
+ pointer-events: none;
+}
+
+.login-tools-section {
+ position: relative;
+ flex-shrink: 0;
+ padding: 1.5rem 0 2rem;
+}
+
+@media (min-width: 1024px) {
+ .login-tools-section {
+ padding: 1.75rem 0 2.5rem;
+ }
+}
+
+.login-tools-section::before {
+ content: "";
+ position: absolute;
+ inset-inline: 10%;
+ top: 0;
+ height: 1px;
+ background: linear-gradient(
+ to right,
+ transparent,
+ rgb(255 255 255 / 0.12),
+ transparent
+ );
+}
+
+.login-tools-marquee {
+ position: relative;
+ overflow: hidden;
+ mask-image: linear-gradient(
+ to right,
+ transparent,
+ black 10%,
+ black 90%,
+ transparent
+ );
+}
+
+.login-tools-marquee-track {
+ display: flex;
+ width: max-content;
+ gap: 0.625rem;
+ animation: login-marquee var(--marquee-duration, 40s) linear infinite;
+ will-change: transform;
+}
+
+.login-tools-marquee-track-reverse {
+ animation-direction: reverse;
+}
+
+.login-tools-marquee:hover .login-tools-marquee-track {
+ animation-play-state: paused;
+}
+
+.login-tools-chip {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 0.5rem;
+ border-radius: 9999px;
+ background: rgb(255 255 255 / 0.05);
+ padding: 0.375rem 0.875rem 0.375rem 0.375rem;
+ box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
+ backdrop-filter: blur(8px);
+}
+
+.login-tools-chip-icon {
+ display: flex;
+ width: 1.625rem;
+ height: 1.625rem;
+ align-items: center;
+ justify-content: center;
+ border-radius: 9999px;
+ background: rgb(255 255 255 / 0.07);
+}
+
+.login-tools-chip-icon svg,
+.login-tools-chip-icon img {
+ width: 0.875rem;
+ height: 0.875rem;
+ opacity: 0.92;
+}
+
+.login-tools-chip-label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: rgb(255 255 255 / 0.82);
+ white-space: nowrap;
+}
+
+.login-tool-node-wrap {
+ position: absolute;
+ z-index: 2;
+ transform: translate(-50%, -50%);
+}
+
+.login-tool-node-label {
+ position: absolute;
+ top: calc(100% + 0.4rem);
+ left: 50%;
+ max-width: 5.5rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ transform: translateX(-50%);
+ font-size: 0.6875rem;
+ font-weight: 500;
+ color: rgb(200 225 255 / 0.95);
+ text-shadow: 0 1px 8px rgb(0 0 0 / 0.65);
+ opacity: 0;
+ transition: opacity 0.25s ease;
+ pointer-events: none;
+}
+
+.login-tool-node-label-visible {
+ opacity: 1;
+}
+
+.login-tool-node {
+ display: flex;
+ width: 2.625rem;
+ height: 2.625rem;
+ align-items: center;
+ justify-content: center;
+ border-radius: 0.75rem;
+ border: 1px solid rgb(255 255 255 / 0.12);
+ background: rgb(12 22 40 / 0.82);
+ backdrop-filter: blur(12px);
+ box-shadow:
+ 0 1px 0 0 rgb(255 255 255 / 0.1) inset,
+ 0 6px 20px rgb(0 0 0 / 0.22);
+ transition:
+ border-color 0.35s ease,
+ background 0.35s ease,
+ box-shadow 0.35s ease,
+ transform 0.35s cubic-bezier(0.34, 1.2, 0.64, 1);
+}
+
+@media (min-width: 640px) {
+ .login-tool-node {
+ width: 2.875rem;
+ height: 2.875rem;
+ border-radius: 0.8125rem;
+ }
+}
+
+.login-tool-node-icon {
+ width: 1.25rem;
+ height: 1.25rem;
+ opacity: 0.88;
+ transition: opacity 0.35s ease;
+}
+
+@media (min-width: 640px) {
+ .login-tool-node-icon {
+ width: 1.375rem;
+ height: 1.375rem;
+ }
+}
+
+.login-tool-node-source {
+ border-color: rgb(100 180 255 / 0.55);
+ background: rgb(14 28 50 / 0.92);
+ box-shadow:
+ 0 0 0 1px rgb(100 180 255 / 0.25),
+ 0 0 24px rgb(75 160 250 / 0.28),
+ 0 1px 0 0 rgb(255 255 255 / 0.1) inset;
+ transform: scale(1.05);
+}
+
+.login-tool-node-source .login-tool-node-icon {
+ opacity: 1;
+}
+
+.login-tool-node-destination {
+ border-color: rgb(130 210 255 / 0.65);
+ background: rgb(16 32 58 / 0.95);
+ box-shadow:
+ 0 0 0 1px rgb(130 210 255 / 0.3),
+ 0 0 32px rgb(100 180 255 / 0.35),
+ 0 1px 0 0 rgb(255 255 255 / 0.12) inset;
+ transform: scale(1.08);
+}
+
+.login-tool-node-destination .login-tool-node-icon {
+ opacity: 1;
+}
+
+.login-context-chip {
+ display: flex;
+ width: 2.75rem;
+ height: 1.625rem;
+ align-items: center;
+ justify-content: center;
+ border-radius: 0.375rem;
+ border: 1px solid rgb(180 220 255 / 0.35);
+ background: rgb(12 24 44 / 0.95);
+ box-shadow:
+ 0 0 16px rgb(100 180 255 / 0.4),
+ 0 1px 0 0 rgb(255 255 255 / 0.12) inset;
+}
+
+.login-context-chip-lines {
+ display: flex;
+ width: 1.5rem;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+
+.login-context-chip-lines span {
+ display: block;
+ height: 0.125rem;
+ border-radius: 9999px;
+ background: rgb(180 220 255 / 0.55);
+}
+
+.login-context-chip-lines span:nth-child(1) {
+ width: 100%;
+}
+
+.login-context-chip-lines span:nth-child(2) {
+ width: 78%;
+ opacity: 0.75;
+}
+
+.login-context-chip-lines span:nth-child(3) {
+ width: 52%;
+ opacity: 0.5;
+}
diff --git a/apps/web/hooks/use-company-brain.ts b/apps/web/hooks/use-company-brain.ts
new file mode 100644
index 00000000..fb80f7be
--- /dev/null
+++ b/apps/web/hooks/use-company-brain.ts
@@ -0,0 +1,16 @@
+import { useAuth } from "@lib/auth-context"
+import {
+ getBrainMode,
+ getCompanyBrainOverride,
+ hasCompanyBrain,
+} from "@/lib/billing-utils"
+
+export function useHasCompanyBrain(): boolean {
+ const { org } = useAuth()
+ const metadata = org?.metadata as Record | string | undefined
+ // An explicit concierge override wins over the team-onboarding fallback.
+ const override = getCompanyBrainOverride(metadata)
+ if (override !== undefined) return override
+ // Team-brain orgs use brain spaces even before the add-on webhook lands.
+ return hasCompanyBrain(metadata) || getBrainMode(metadata) === "team"
+}
diff --git a/apps/web/hooks/use-connector-access.ts b/apps/web/hooks/use-connector-access.ts
new file mode 100644
index 00000000..9f2599c3
--- /dev/null
+++ b/apps/web/hooks/use-connector-access.ts
@@ -0,0 +1,19 @@
+import { useCustomer } from "autumn-js/react"
+import { hasActivePlan } from "@lib/queries"
+import { useHasCompanyBrain } from "@/hooks/use-company-brain"
+
+// Connector entitlement (pro tier or company_brain) — mirrors backend canAccessConnector. Not for plugins.
+export function useConnectorAccess(opts?: { enabled?: boolean }) {
+ const enabled = opts?.enabled ?? true
+ const autumn = useCustomer({ queryOptions: { enabled } })
+ const hasCompanyBrain = useHasCompanyBrain()
+ const hasPro = enabled && hasActivePlan(autumn.data?.subscriptions, "api_pro")
+ const hasMax = enabled && hasActivePlan(autumn.data?.subscriptions, "api_max")
+ return {
+ hasPro,
+ hasMax,
+ hasCompanyBrain,
+ connectorAccess: hasPro || hasCompanyBrain,
+ loading: enabled && autumn.isLoading,
+ }
+}
diff --git a/apps/web/hooks/use-delete-organization.ts b/apps/web/hooks/use-delete-organization.ts
new file mode 100644
index 00000000..b829d20b
--- /dev/null
+++ b/apps/web/hooks/use-delete-organization.ts
@@ -0,0 +1,26 @@
+"use client"
+
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { toast } from "sonner"
+import { authClient } from "@lib/auth"
+
+export function useDeleteOrganization() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (organizationId: string) => {
+ const res = await authClient.organization.delete({ organizationId })
+ if (res?.error) {
+ throw new Error(res.error.message || "Failed to delete organization")
+ }
+ return res?.data
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries()
+ toast.success("Organization deleted.")
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Failed to delete organization.")
+ },
+ })
+}
diff --git a/apps/web/hooks/use-digests.ts b/apps/web/hooks/use-digests.ts
new file mode 100644
index 00000000..4814a4ca
--- /dev/null
+++ b/apps/web/hooks/use-digests.ts
@@ -0,0 +1,72 @@
+"use client"
+
+import { $fetch } from "@lib/api"
+import { useQuery } from "@tanstack/react-query"
+
+export type DigestSummary = {
+ id: string
+ isoWeek: string
+ emailSubject: string | null
+ title: string | null
+ status: "pending" | "processing" | "completed" | "failed"
+ sentAt: string | null
+ generatedAt: string
+ highlightCount: number
+ memoryCount: number
+}
+
+export type DigestDetail = {
+ id: string
+ isoWeek: string
+ emailSubject: string | null
+ status: "pending" | "processing" | "completed" | "failed"
+ sentAt: string | null
+ generatedAt: string
+ digestData: {
+ title: string
+ intro: string
+ highlights: Array<{
+ id: string
+ title: string
+ content: string
+ format: "paragraph" | "bullets" | "quote" | "one_liner"
+ query: string
+ sourceDocumentIds: string[]
+ }>
+ featureRecommendations: Array<{
+ feature: string
+ headline: string
+ body: string
+ ctaLabel: string
+ ctaUrl: string
+ }>
+ memoryCount: number
+ spaceCount: number
+ }
+}
+
+export function useDigests(page = 1, limit = 20) {
+ return useQuery({
+ queryKey: ["digests", page, limit],
+ queryFn: async () => {
+ const res = await $fetch("@get/digests", { query: { page, limit } })
+ if (res.error) throw new Error("Failed to fetch digests")
+ return res.data?.digests ?? []
+ },
+ staleTime: 5 * 60 * 1000,
+ })
+}
+
+export function useDigest(id: string | null) {
+ return useQuery({
+ queryKey: ["digest", id],
+ queryFn: async () => {
+ if (!id) return null
+ const res = await $fetch("@get/digests/:id", { params: { id } })
+ if (res.error) throw new Error("Failed to fetch digest")
+ return (res.data as DigestDetail) ?? null
+ },
+ enabled: !!id,
+ staleTime: 10 * 60 * 1000,
+ })
+}
diff --git a/apps/web/hooks/use-document-mutations.ts b/apps/web/hooks/use-document-mutations.ts
index bff873a7..c0b1c864 100644
--- a/apps/web/hooks/use-document-mutations.ts
+++ b/apps/web/hooks/use-document-mutations.ts
@@ -7,28 +7,27 @@ import {
} from "@tanstack/react-query"
import { toast } from "sonner"
import { $fetch } from "@lib/api"
-import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
-import type { z } from "zod"
import { useAuth } from "@lib/auth-context"
import { analytics } from "@/lib/analytics"
+import { fetchSpaceSettings, spaceSettingsKey } from "@/hooks/use-space-context"
+import { getBackendUrl } from "@/lib/url-helpers"
-type DocumentsResponse = z.infer
+/** Pull the human-readable message out of a $fetch error (handles `{error}`/`{message}`/string). */
+function fetchErrorMessage(err: unknown, fallback: string): string {
+ if (typeof err === "string") return err
+ if (err && typeof err === "object") {
+ const e = err as { error?: unknown; message?: unknown }
+ if (typeof e.error === "string") return e.error
+ if (typeof e.message === "string") return e.message
+ }
+ return fallback
+}
interface DocumentWithId {
id?: string
customId?: string | null
}
-interface DocumentsQueryData {
- documents: DocumentWithId[]
- totalCount: number
-}
-
-type InfiniteQueryData = {
- pages: DocumentsResponse[]
- pageParams: number[]
-}
-
interface UseDocumentMutationsOptions {
onClose?: () => void
}
@@ -212,6 +211,9 @@ function restoreQueriesFromSnapshot(
}
const FILE_UPLOAD_CONCURRENCY = 3
+const BULK_LINK_BATCH_SIZE = 500
+const fullDocumentQueryKey = (documentId: string) =>
+ ["document-full", documentId] as const
export type FileUploadEntry = { id: string; file: File }
@@ -226,7 +228,23 @@ export function useDocumentMutations({
const queryClient = useQueryClient()
const { user } = useAuth()
- const entityContext = `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`
+ const defaultEntityContext = `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`
+
+ // Skip when the space has its own context — sending one would overwrite the stored value.
+ const resolveEntityContext = async (
+ project: string,
+ ): Promise => {
+ try {
+ const settings = await queryClient.fetchQuery({
+ queryKey: spaceSettingsKey(project),
+ queryFn: () => fetchSpaceSettings(project),
+ staleTime: 60 * 1000,
+ })
+ return settings?.entityContext ? undefined : defaultEntityContext
+ } catch {
+ return defaultEntityContext
+ }
+ }
const noteMutation = useMutation({
mutationFn: async ({
@@ -236,11 +254,12 @@ export function useDocumentMutations({
content: string
project: string
}) => {
+ const entityContext = await resolveEntityContext(project)
const response = await $fetch("@post/documents", {
body: {
content,
containerTags: [project],
- entityContext,
+ ...(entityContext !== undefined ? { entityContext } : {}),
metadata: { sm_source: "consumer" },
},
})
@@ -300,11 +319,12 @@ export function useDocumentMutations({
const linkMutation = useMutation({
mutationFn: async ({ url, project }: { url: string; project: string }) => {
+ const entityContext = await resolveEntityContext(project)
const response = await $fetch("@post/documents", {
body: {
content: url,
containerTags: [project],
- entityContext,
+ ...(entityContext !== undefined ? { entityContext } : {}),
metadata: { sm_source: "consumer" },
},
})
@@ -362,6 +382,100 @@ export function useDocumentMutations({
},
})
+ const bulkLinkMutation = useMutation({
+ mutationFn: async ({
+ urls,
+ project,
+ }: {
+ urls: string[]
+ project: string
+ }): Promise<{ success: number; failed: number }> => {
+ let success = 0
+ let failed = 0
+
+ for (let i = 0; i < urls.length; i += BULK_LINK_BATCH_SIZE) {
+ const chunk = urls.slice(i, i + BULK_LINK_BATCH_SIZE)
+ const response = await $fetch("@post/documents/batch", {
+ body: {
+ documents: chunk.map((url) => ({
+ content: url,
+ containerTags: [project],
+ entityContext,
+ metadata: { sm_source: "consumer" },
+ })),
+ },
+ })
+ if (response.error) {
+ throw new Error(response.error?.message || "Failed to add links")
+ }
+ success += response.data?.success ?? 0
+ failed += response.data?.failed ?? 0
+ }
+
+ return { success, failed }
+ },
+ onMutate: async ({ urls, project }) => {
+ const previousQueries = await cancelAndSnapshotQueries(queryClient)
+ const now = new Date().toISOString()
+
+ for (const url of urls) {
+ const optimisticMemory: OptimisticMemory = {
+ id: `temp-${crypto.randomUUID()}`,
+ content: "",
+ url,
+ title: "Processing...",
+ description: "Extracting content...",
+ containerTags: [project],
+ createdAt: now,
+ updatedAt: now,
+ status: "queued",
+ type: "link",
+ metadata: {
+ processingStage: "queued",
+ processingMessage: "Added to processing queue",
+ },
+ memoryEntries: [],
+ isOptimistic: true,
+ }
+ queryClient.setQueriesData(
+ { queryKey: ["documents-with-memories"] },
+ (old) => addOptimisticMemoryToQueryData(old, optimisticMemory),
+ )
+ }
+
+ return { previousQueries }
+ },
+ onError: (error, _variables, context) => {
+ restoreQueriesFromSnapshot(queryClient, context?.previousQueries)
+ toast.error("Failed to add links", {
+ description: error instanceof Error ? error.message : "Unknown error",
+ })
+ },
+ onSuccess: (data, variables) => {
+ for (let i = 0; i < data.success; i++) {
+ analytics.documentAdded({ type: "link", project_id: variables.project })
+ }
+ queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
+ queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
+ if (data.failed === 0) {
+ toast.success(`${data.success} links added!`, {
+ description: "Your links are being processed",
+ })
+ onClose?.()
+ return
+ }
+ if (data.success === 0) {
+ toast.error("Failed to add links", {
+ description: `All ${data.failed} links failed`,
+ })
+ return
+ }
+ toast.warning("Some links failed", {
+ description: `${data.success} added, ${data.failed} failed`,
+ })
+ },
+ })
+
const fileMutation = useMutation({
mutationFn: async ({
fileEntries,
@@ -376,22 +490,22 @@ export function useDocumentMutations({
}): Promise => {
const applyMeta = fileEntries.length === 1
const failures: { id: string; message: string }[] = []
+ const entityContext = await resolveEntityContext(project)
const uploadOne = async (entry: FileUploadEntry) => {
const formData = new FormData()
formData.append("file", entry.file)
formData.append("containerTags", JSON.stringify([project]))
- formData.append("entityContext", entityContext)
+ if (entityContext !== undefined) {
+ formData.append("entityContext", entityContext)
+ }
formData.append("metadata", JSON.stringify({ sm_source: "consumer" }))
- const response = await fetch(
- `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`,
- {
- method: "POST",
- body: formData,
- credentials: "include",
- },
- )
+ const response = await fetch(`${getBackendUrl()}/v3/documents/file`, {
+ method: "POST",
+ body: formData,
+ credentials: "include",
+ })
if (!response.ok) {
let message = "Failed to upload file"
@@ -544,6 +658,10 @@ export function useDocumentMutations({
onSuccess: (_data, variables) => {
analytics.documentEdited({ document_id: variables.documentId })
toast.success("Document saved successfully!")
+ queryClient.setQueryData(
+ fullDocumentQueryKey(variables.documentId),
+ variables.content,
+ )
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
},
onError: (error) => {
@@ -560,7 +678,9 @@ export function useDocumentMutations({
})
if (response.error) {
- throw new Error(response.error?.message || "Failed to delete document")
+ throw new Error(
+ fetchErrorMessage(response.error, "Failed to delete document"),
+ )
}
return response.data
@@ -584,6 +704,10 @@ export function useDocumentMutations({
onSuccess: (_data, variables) => {
analytics.documentDeleted({ document_id: variables.documentId })
toast.success("Document deleted successfully!")
+ queryClient.removeQueries({
+ queryKey: fullDocumentQueryKey(variables.documentId),
+ exact: true,
+ })
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
onClose?.()
},
@@ -596,7 +720,9 @@ export function useDocumentMutations({
})
if (response.error) {
- throw new Error(response.error?.message || "Failed to delete documents")
+ throw new Error(
+ fetchErrorMessage(response.error, "Failed to delete documents"),
+ )
}
return response.data
@@ -623,6 +749,12 @@ export function useDocumentMutations({
toast.success(
`${variables.documentIds.length} document${variables.documentIds.length === 1 ? "" : "s"} deleted`,
)
+ for (const documentId of variables.documentIds) {
+ queryClient.removeQueries({
+ queryKey: fullDocumentQueryKey(documentId),
+ exact: true,
+ })
+ }
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
},
})
@@ -630,6 +762,7 @@ export function useDocumentMutations({
return {
noteMutation,
linkMutation,
+ bulkLinkMutation,
fileMutation,
updateMutation,
deleteMutation,
diff --git a/apps/web/hooks/use-full-document.ts b/apps/web/hooks/use-full-document.ts
new file mode 100644
index 00000000..b954ae75
--- /dev/null
+++ b/apps/web/hooks/use-full-document.ts
@@ -0,0 +1,29 @@
+import { useQuery } from "@tanstack/react-query"
+import { $fetch } from "@lib/api"
+
+// List endpoint truncates `content` to ~2000 chars; fetch the full doc by id for the viewer.
+export function useFullDocumentContent(
+ documentId: string | null | undefined,
+ enabled: boolean,
+): string | null {
+ const isReal = !!documentId && !documentId.startsWith("temp-")
+
+ const { data } = useQuery({
+ queryKey: ["document-full", documentId],
+ enabled: enabled && isReal,
+ staleTime: 5 * 60 * 1000,
+ queryFn: async () => {
+ const res = await $fetch("@get/documents/:id", {
+ params: { id: documentId as string },
+ disableValidation: true,
+ })
+ if (res.error) {
+ throw new Error(res.error?.message || "Failed to fetch document")
+ }
+ const doc = res.data as { content?: string | null } | null
+ return typeof doc?.content === "string" ? doc.content : null
+ },
+ })
+
+ return data ?? null
+}
diff --git a/apps/web/hooks/use-inferred-memories.ts b/apps/web/hooks/use-inferred-memories.ts
new file mode 100644
index 00000000..c93d0fce
--- /dev/null
+++ b/apps/web/hooks/use-inferred-memories.ts
@@ -0,0 +1,109 @@
+"use client"
+
+import { $fetch } from "@lib/api"
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { useCallback } from "react"
+import { toast } from "sonner"
+
+export type InferredMemory = {
+ id: string
+ memory: string
+ parentCount: number
+ createdAt: string
+ updatedAt: string
+ metadata: Record | null
+}
+
+export type ReviewAction = "approve" | "decline" | "undo"
+
+const inferredKey = (containerTag: string | undefined) =>
+ ["inferred-memories", containerTag] as const
+
+// Inferred memories the engine wasn't fully confident about, awaiting review.
+export function useInferredMemories(containerTag: string | undefined) {
+ return useQuery({
+ queryKey: inferredKey(containerTag),
+ queryFn: async (): Promise => {
+ if (!containerTag) return []
+ const res = await $fetch("@get/container-tags/:containerTag/inferred", {
+ params: { containerTag },
+ })
+ if (res.error) throw new Error(res.error?.message ?? "Failed to load")
+ return res.data?.memories ?? []
+ },
+ enabled: !!containerTag,
+ staleTime: 60 * 1000,
+ })
+}
+
+export function useReviewInferredMemory(containerTag: string | undefined) {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async ({
+ memoryId,
+ action,
+ }: {
+ memoryId: string
+ action: ReviewAction
+ }) => {
+ if (!containerTag) throw new Error("Missing container tag")
+ const res = await $fetch(
+ "@post/container-tags/:containerTag/inferred/:memoryId/review",
+ { params: { containerTag, memoryId }, body: { action } },
+ )
+ if (res.error) throw new Error(res.error?.message ?? "Review failed")
+ return res.data
+ },
+ // The modal pops cards off its local stack as you swipe, so we just drop
+ // the reviewed entry from the cached queue once the request settles.
+ // Undo restores the memory server-side, so refetch to bring it back.
+ onSuccess: (_data, { memoryId, action }) => {
+ if (action === "undo") {
+ queryClient.invalidateQueries({
+ queryKey: inferredKey(containerTag),
+ })
+ return
+ }
+ queryClient.setQueryData(
+ inferredKey(containerTag),
+ (prev) => prev?.filter((m) => m.id !== memoryId),
+ )
+ },
+ // The card already flew off the stack, so a silent failure would read as
+ // "saved". Surface it; the entry stays cached (onSuccess never ran) so it
+ // resurfaces for review later.
+ onError: (_err, { action }) => {
+ toast.error(
+ action === "undo"
+ ? "Couldn't undo that review. Try again."
+ : "Couldn't save your review. It'll resurface for review later.",
+ )
+ },
+ })
+}
+
+// Session-local queue edits for decisions that don't hit the server (skip),
+// so the trigger's live count reflects them and the prompt can be dismissed.
+export function useInferredMemoryCache(containerTag: string | undefined) {
+ const queryClient = useQueryClient()
+ const drop = useCallback(
+ (memoryId: string) =>
+ queryClient.setQueryData(
+ inferredKey(containerTag),
+ (prev) => prev?.filter((m) => m.id !== memoryId),
+ ),
+ [queryClient, containerTag],
+ )
+ const restore = useCallback(
+ (memory: InferredMemory) =>
+ queryClient.setQueryData(
+ inferredKey(containerTag),
+ (prev) =>
+ prev?.some((m) => m.id === memory.id)
+ ? prev
+ : [...(prev ?? []), memory],
+ ),
+ [queryClient, containerTag],
+ )
+ return { drop, restore }
+}
diff --git a/apps/web/hooks/use-org-settings.ts b/apps/web/hooks/use-org-settings.ts
new file mode 100644
index 00000000..dc415ff1
--- /dev/null
+++ b/apps/web/hooks/use-org-settings.ts
@@ -0,0 +1,80 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { toast } from "sonner"
+import { $fetch } from "@lib/api"
+import { useAuth } from "@lib/auth-context"
+
+const API_BASE = `${process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"}/v3`
+
+export type OrgSettings = {
+ shouldLLMFilter: boolean
+ filterPrompt: string | null
+ includeItems?: string[] | null
+ excludeItems?: string[] | null
+}
+
+type OrgSettingsResponse = {
+ settings?: Partial
+} & Partial
+
+export function useOrgSettings() {
+ const { org } = useAuth()
+ const orgId = org?.id ?? ""
+
+ return useQuery({
+ queryKey: ["settings", "org", orgId],
+ queryFn: async (): Promise => {
+ const response = await $fetch("@get/settings", {
+ disableValidation: true,
+ })
+ if (response.error) {
+ throw new Error(response.error.message || "Failed to load settings")
+ }
+ const data = response.data as OrgSettingsResponse | null
+ const settings = data?.settings ?? data ?? {}
+ return {
+ shouldLLMFilter: settings.shouldLLMFilter ?? false,
+ filterPrompt: settings.filterPrompt ?? null,
+ includeItems: settings.includeItems ?? null,
+ excludeItems: settings.excludeItems ?? null,
+ }
+ },
+ enabled: !!orgId,
+ staleTime: 60 * 1000,
+ })
+}
+
+export function useUpdateOrgSettings() {
+ const { org } = useAuth()
+ const queryClient = useQueryClient()
+ const orgId = org?.id ?? ""
+
+ return useMutation({
+ mutationFn: async (settings: Partial) => {
+ const res = await fetch(`${API_BASE}/settings`, {
+ method: "PATCH",
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json",
+ "X-App-Source": "nova",
+ },
+ body: JSON.stringify(settings),
+ })
+ if (!res.ok) {
+ const body = (await res.json().catch(() => ({}))) as {
+ message?: string
+ }
+ throw new Error(body?.message || "Failed to save settings")
+ }
+ return res.json()
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["settings", "org", orgId] })
+ toast.success("Settings saved")
+ },
+ onError: (error) => {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to save settings",
+ )
+ },
+ })
+}
diff --git a/apps/web/hooks/use-processing-documents.ts b/apps/web/hooks/use-processing-documents.ts
index 72ea06fd..99e5ca56 100644
--- a/apps/web/hooks/use-processing-documents.ts
+++ b/apps/web/hooks/use-processing-documents.ts
@@ -37,12 +37,20 @@ export function useProcessingDocuments() {
staleTime: 0,
})
- const docs =
- (
- data as
- | { documents?: Array<{ id?: string | null; status?: string | null }> }
- | undefined
- )?.documents ?? []
+ // Memoized on `data` (kept referentially stable between polls by React
+ // Query's structural sharing) so `processingMap` only changes identity
+ // when the poll payload actually changes — the effect below depends on it.
+ const docs = useMemo(
+ () =>
+ (
+ data as
+ | {
+ documents?: Array<{ id?: string | null; status?: string | null }>
+ }
+ | undefined
+ )?.documents ?? [],
+ [data],
+ )
const processingMap = useMemo(() => {
const map = new Map()
@@ -52,7 +60,6 @@ export function useProcessingDocuments() {
}
}
return map
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [docs])
// Detect docs that just finished (present in previous poll, absent now).
@@ -80,8 +87,10 @@ export function useProcessingDocuments() {
clearTimeout(t1)
clearTimeout(t2)
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [processingMap.keys, queryClient.refetchQueries])
+ // `processingMap` (not `processingMap.keys` — that's the shared
+ // Map.prototype method, identical for every map, so the effect would
+ // never re-run and finished docs would never trigger a refresh).
+ }, [processingMap, queryClient])
return processingMap
}
diff --git a/apps/web/hooks/use-research-status.ts b/apps/web/hooks/use-research-status.ts
new file mode 100644
index 00000000..862588f0
--- /dev/null
+++ b/apps/web/hooks/use-research-status.ts
@@ -0,0 +1,64 @@
+"use client"
+
+import { useQuery } from "@tanstack/react-query"
+import { useAuth } from "@lib/auth-context"
+
+const BACKEND =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+
+const POLL_INTERVAL_MS = 2_000
+const MAX_POLLS = 150
+
+export type ResearchStat = { label: string; value: string }
+
+export type ResearchEvent = {
+ aspect: string
+ label: string
+ status: "in_progress" | "complete" | "error" | string
+ detail: string | null
+ stats: ResearchStat[]
+ highlights: string[]
+ sources: string[]
+ createdAt: number
+}
+
+export type ResearchState = {
+ status: "queued" | "running" | "done" | null
+ domain: string | null
+ findings: number
+ events: ResearchEvent[]
+}
+
+const EMPTY: ResearchState = {
+ status: null,
+ domain: null,
+ findings: 0,
+ events: [],
+}
+
+export function useResearchStatus(enabled = true) {
+ const { org } = useAuth()
+ const orgId = org?.id
+
+ const { data } = useQuery({
+ queryKey: ["brain-research-status", orgId],
+ queryFn: async () => {
+ const res = await fetch(`${BACKEND}/brain/research/status`, {
+ credentials: "include",
+ headers: { "X-App-Source": "nova" },
+ })
+ if (!res.ok) return EMPTY
+ return (await res.json()) as ResearchState
+ },
+ enabled: Boolean(enabled && orgId),
+ refetchInterval: (query) => {
+ const status = query.state.data?.status
+ const polls = query.state.dataUpdateCount
+ if (status === "done" || polls >= MAX_POLLS) return false
+ return POLL_INTERVAL_MS
+ },
+ staleTime: 0,
+ })
+
+ return data ?? EMPTY
+}
diff --git a/apps/web/hooks/use-space-context.ts b/apps/web/hooks/use-space-context.ts
new file mode 100644
index 00000000..1258b381
--- /dev/null
+++ b/apps/web/hooks/use-space-context.ts
@@ -0,0 +1,115 @@
+"use client"
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { toast } from "sonner"
+import { $fetch } from "@lib/api"
+
+export type SpaceSettings = {
+ containerTag: string
+ name: string | null
+ entityContext: string | null
+}
+
+export const spaceSettingsKey = (containerTag: string) =>
+ ["container-tag-settings", containerTag] as const
+
+export async function fetchSpaceSettings(
+ containerTag: string,
+): Promise {
+ const response = await $fetch(`@get/container-tags/${containerTag}`, {
+ disableValidation: true,
+ })
+ if (response.error) {
+ throw new Error(response.error?.message || "Failed to load space settings")
+ }
+ const data = response.data as Partial | null
+ if (!data) return null
+ return {
+ containerTag,
+ name: data.name ?? null,
+ entityContext: data.entityContext ?? null,
+ }
+}
+
+export function useSpaceContext(containerTag: string, enabled = true) {
+ return useQuery({
+ queryKey: spaceSettingsKey(containerTag),
+ queryFn: () => fetchSpaceSettings(containerTag),
+ enabled: enabled && !!containerTag,
+ staleTime: 60 * 1000,
+ })
+}
+
+export function useUpdateSpace() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ containerTag,
+ name,
+ entityContext,
+ }: {
+ containerTag: string
+ name?: string
+ entityContext?: string | null
+ }) => {
+ const body: { name?: string; entityContext?: string | null } = {}
+ if (name !== undefined) body.name = name
+ if (entityContext !== undefined) body.entityContext = entityContext
+ const response = await $fetch(`@patch/container-tags/${containerTag}`, {
+ body,
+ })
+ if (response.error) {
+ throw new Error(response.error?.message || "Failed to save space")
+ }
+ return response.data
+ },
+ onSuccess: (_data, { containerTag }) => {
+ queryClient.invalidateQueries({
+ queryKey: spaceSettingsKey(containerTag),
+ })
+ queryClient.invalidateQueries({ queryKey: ["container-tags"] })
+ toast.success("Space updated")
+ },
+ onError: (error) => {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to save space",
+ )
+ },
+ })
+}
+
+export function useUpdateSpaceContext() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ containerTag,
+ entityContext,
+ }: {
+ containerTag: string
+ entityContext: string | null
+ }) => {
+ const response = await $fetch(`@patch/container-tags/${containerTag}`, {
+ body: { entityContext },
+ })
+ if (response.error) {
+ throw new Error(
+ response.error?.message || "Failed to save space context",
+ )
+ }
+ return response.data
+ },
+ onSuccess: (_data, { containerTag }) => {
+ queryClient.invalidateQueries({
+ queryKey: spaceSettingsKey(containerTag),
+ })
+ toast.success("Space context saved")
+ },
+ onError: (error) => {
+ toast.error(
+ error instanceof Error ? error.message : "Failed to save space context",
+ )
+ },
+ })
+}
diff --git a/apps/web/hooks/use-space-profile.ts b/apps/web/hooks/use-space-profile.ts
new file mode 100644
index 00000000..8883625f
--- /dev/null
+++ b/apps/web/hooks/use-space-profile.ts
@@ -0,0 +1,57 @@
+import { useQuery } from "@tanstack/react-query"
+import { useAuth } from "@lib/auth-context"
+
+export type SpaceProfile = {
+ static: string[]
+ dynamic: string[]
+}
+
+const API_BASE =
+ process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
+
+type SpaceProfileResponse = {
+ profile?: {
+ static?: string[] | null
+ dynamic?: string[] | null
+ } | null
+}
+
+export function useSpaceProfile(containerTag: string) {
+ const { org } = useAuth()
+ const orgId = org?.id ?? ""
+
+ return useQuery({
+ queryKey: ["space-profile", orgId, containerTag],
+ queryFn: async (): Promise => {
+ const response = await fetch(`${API_BASE}/v4/profile`, {
+ method: "POST",
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json",
+ "X-App-Source": "nova",
+ },
+ body: JSON.stringify({ containerTag }),
+ })
+
+ if (!response.ok) {
+ const body = (await response.json().catch(() => ({}))) as {
+ error?: string
+ message?: string
+ }
+ throw new Error(
+ body.message ?? body.error ?? "Failed to load space profile",
+ )
+ }
+
+ const data = (await response.json()) as SpaceProfileResponse
+ const profile = data.profile
+
+ return {
+ static: profile?.static ?? [],
+ dynamic: profile?.dynamic ?? [],
+ }
+ },
+ enabled: !!orgId && !!containerTag,
+ staleTime: 60 * 1000,
+ })
+}
diff --git a/apps/web/hooks/use-token-usage.ts b/apps/web/hooks/use-token-usage.ts
index 898d89ac..4dc62811 100644
--- a/apps/web/hooks/use-token-usage.ts
+++ b/apps/web/hooks/use-token-usage.ts
@@ -2,11 +2,12 @@ import { getSubscriptionStatus, isAllowedFrom } from "@lib/queries"
import type { useCustomer } from "autumn-js/react"
import { calculateUsagePercent, getDaysRemaining } from "@/lib/billing-utils"
-export type PlanType = "free" | "pro" | "scale" | "enterprise"
+export type PlanType = "free" | "pro" | "max" | "scale" | "enterprise"
export const PLAN_DISPLAY_NAMES: Record = {
free: "Free",
pro: "Pro",
+ max: "Max",
scale: "Scale",
enterprise: "Enterprise",
}
@@ -15,8 +16,9 @@ export const PLAN_DISPLAY_NAMES: Record = {
export const PLAN_RANK: Record = {
free: 0,
pro: 1,
- scale: 2,
- enterprise: 3,
+ max: 2,
+ scale: 3,
+ enterprise: 4,
}
export function normalizePlanType(raw: unknown): PlanType {
@@ -24,6 +26,7 @@ export function normalizePlanType(raw: unknown): PlanType {
const normalized = raw.toLowerCase().replace(/^api_/, "")
if (normalized === "enterprise") return "enterprise"
if (normalized === "scale") return "scale"
+ if (normalized === "max") return "max"
if (normalized === "pro") return "pro"
return "free"
}
@@ -43,6 +46,8 @@ export function useTokenUsage(autumn: ReturnType) {
currentPlan = "enterprise"
} else if (isAllowedFrom(status, "api_scale")) {
currentPlan = "scale"
+ } else if (isAllowedFrom(status, "api_max")) {
+ currentPlan = "max"
} else if (isAllowedFrom(status, "api_pro")) {
currentPlan = "pro"
}
diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts
index d762654e..7ce5b9df 100644
--- a/apps/web/lib/analytics.ts
+++ b/apps/web/lib/analytics.ts
@@ -1,13 +1,53 @@
import posthog from "posthog-js"
+import type { BrainStep } from "@/components/onboarding-brain/types"
+
+const pendingEvents: Array<{
+ eventName: string
+ properties?: Record
+}> = []
+let flushTimer: ReturnType | undefined
+let flushTimeout: ReturnType | undefined
+
+const flushPendingEvents = () => {
+ if (!posthog.__loaded) return
+ while (pendingEvents.length > 0) {
+ const event = pendingEvents.shift()
+ if (!event) return
+ posthog.capture(event.eventName, event.properties)
+ }
+ if (flushTimer) {
+ clearInterval(flushTimer)
+ flushTimer = undefined
+ }
+ if (flushTimeout) {
+ clearTimeout(flushTimeout)
+ flushTimeout = undefined
+ }
+}
+
+const scheduleFlush = () => {
+ if (flushTimer) return
+ flushTimer = setInterval(flushPendingEvents, 200)
+ flushTimeout = setTimeout(() => {
+ if (!flushTimer) return
+ clearInterval(flushTimer)
+ flushTimer = undefined
+ flushTimeout = undefined
+ pendingEvents.length = 0
+ }, 10000)
+}
-// Helper function to safely capture events
const safeCapture = (
eventName: string,
properties?: Record,
) => {
if (posthog.__loaded) {
+ flushPendingEvents()
posthog.capture(eventName, properties)
+ return
}
+ pendingEvents.push({ eventName, properties })
+ scheduleFlush()
}
export const analytics = {
@@ -30,7 +70,7 @@ export const analytics = {
chatDeleted: () => safeCapture("chat_deleted"),
viewModeChanged: (
- mode: "dashboard" | "graph" | "list" | "integrations" | "chat",
+ mode: "dashboard" | "graph" | "list" | "integrations" | "chat" | "digests",
) => safeCapture("view_mode_changed", { mode }),
documentCardClicked: () => safeCapture("document_card_clicked"),
@@ -40,19 +80,19 @@ export const analytics = {
upgradeCompleted: () => safeCapture("upgrade_completed"),
billingPortalOpened: () => safeCapture("billing_portal_opened"),
- connectionAdded: (provider: string) =>
- safeCapture("connection_added", { provider }),
connectionDeleted: () => safeCapture("connection_deleted"),
- connectionAuthStarted: () => safeCapture("connection_auth_started"),
- connectionAuthCompleted: () => safeCapture("connection_auth_completed"),
- connectionAuthFailed: () => safeCapture("connection_auth_failed"),
+ connectionAuthStarted: (props: { provider: string }) =>
+ safeCapture("connection_auth_started", props),
- nextAppResearchCtaDismissed: () =>
- safeCapture("next_app_research_cta_dismissed"),
- nextAppResearchCtaBookCallClicked: () =>
- safeCapture("next_app_research_cta_book_call_clicked"),
- nextAppResearchCtaLobbysideCallClicked: () =>
- safeCapture("next_app_research_cta_lobbyside_call_clicked"),
+ // integrations surface (main Nova page)
+ integrationCardClicked: (props: { kind: string; id: string; name: string }) =>
+ safeCapture("integration_card_clicked", props),
+ integrationInfoModalClosed: (props: {
+ kind: string
+ id: string
+ name: string
+ close_reason: "dismiss" | "close_button" | "im_good" | "action"
+ }) => safeCapture("integration_info_modal_closed", props),
mcpViewOpened: () => safeCapture("mcp_view_opened"),
mcpInstallCmdCopied: () => safeCapture("mcp_install_cmd_copied"),
@@ -72,35 +112,63 @@ export const analytics = {
addDocumentModalOpened: () => safeCapture("add_document_modal_opened"),
// onboarding analytics
- onboardingStepViewed: (props: { step: string; trigger: "user" | "auto" }) =>
- safeCapture("onboarding_step_viewed", props),
+ onboardingStarted: (props: { mode: string; entry_step: BrainStep }) =>
+ safeCapture("onboarding_started", props),
- onboardingNameSubmitted: (props: { name_length: number }) =>
- safeCapture("onboarding_name_submitted", props),
+ onboardingStepViewed: (props: {
+ step: BrainStep
+ index: number
+ trigger: "user" | "auto"
+ }) => safeCapture("onboarding_step_viewed", props),
- onboardingProfileSubmitted: (props: {
- has_twitter: boolean
- has_linkedin: boolean
- other_links_count: number
- description_length: number
- }) => safeCapture("onboarding_profile_submitted", props),
+ onboardingStepCompleted: (props: { step: BrainStep; index: number }) =>
+ safeCapture("onboarding_step_completed", props),
- onboardingRelatableSelected: (props: { options: string[] }) =>
- safeCapture("onboarding_relatable_selected", props),
+ onboardingModeSelected: (props: { mode: string }) =>
+ safeCapture("onboarding_mode_selected", props),
+
+ onboardingWorkspaceCreated: (props: {
+ mode: string
+ has_about: boolean
+ has_domain: boolean
+ }) => safeCapture("onboarding_workspace_created", props),
+
+ onboardingWorkspaceCreateFailed: (props: { error: string }) =>
+ safeCapture("onboarding_workspace_create_failed", props),
onboardingIntegrationClicked: (props: { integration: string }) =>
safeCapture("onboarding_integration_clicked", props),
+ onboardingSourcesCompleted: (props: { connected_count: number }) =>
+ safeCapture("onboarding_sources_completed", props),
+
+ onboardingAgentSelected: (props: { agent: string }) =>
+ safeCapture("onboarding_agent_selected", props),
+
+ onboardingIngestCompleted: () => safeCapture("onboarding_ingest_completed"),
+
+ onboardingIngestSkipped: () => safeCapture("onboarding_ingest_skipped"),
+
+ onboardingInvitesSent: (props: { sent: number; failed: number }) =>
+ safeCapture("onboarding_invites_sent", props),
+
+ onboardingTeamSkipped: () => safeCapture("onboarding_team_skipped"),
+
onboardingChromeExtensionClicked: (props: {
source: "onboarding" | "settings" | "integrations"
}) => safeCapture("onboarding_chrome_extension_clicked", props),
onboardingMcpDetailOpened: () => safeCapture("onboarding_mcp_detail_opened"),
- onboardingXBookmarksDetailOpened: () =>
- safeCapture("onboarding_x_bookmarks_detail_opened"),
+ onboardingSkipped: (props: { from_step: BrainStep }) =>
+ safeCapture("onboarding_skipped", props),
- onboardingCompleted: () => safeCapture("onboarding_completed"),
+ onboardingCompleted: (props: {
+ mode: string
+ steps_completed: number
+ sources_connected: number
+ invites_sent: number
+ }) => safeCapture("onboarding_completed", props),
// main app analytics
searchOpened: (props: {
@@ -120,6 +188,9 @@ export const analytics = {
// chat analytics
chatMessageSent: (props: {
source: "typed" | "suggested" | "highlight" | "home"
+ attachment_count?: number
+ saved_attachment_count?: number
+ temporary_attachment_count?: number
}) => safeCapture("chat_message_sent", props),
chatSuggestedQuestionClicked: () =>
@@ -151,7 +222,14 @@ export const analytics = {
// settings / spaces / docs analytics
settingsTabChanged: (props: {
- tab: "account" | "billing" | "integrations" | "connections" | "support"
+ tab:
+ | "account"
+ | "billing"
+ | "integrations"
+ | "connections"
+ | "company-brain"
+ | "proactiveness"
+ | "support"
}) => safeCapture("settings_tab_changed", props),
spaceCreated: () => safeCapture("space_created"),
@@ -171,4 +249,19 @@ export const analytics = {
documentEdited: (props: { document_id: string }) =>
safeCapture("document_edited", props),
+
+ // weekly digest
+ digestViewed: (props: { digest_id: string; iso_week: string }) =>
+ safeCapture("digest_viewed", props),
+ digestFeedback: (props: {
+ digest_id: string
+ iso_week: string
+ rating: "up" | "down"
+ }) => safeCapture("digest_feedback", props),
+ digestFeedbackDetail: (props: {
+ digest_id: string
+ iso_week: string
+ rating: "up" | "down" | null
+ message: string
+ }) => safeCapture("digest_feedback_detail", props),
}
diff --git a/apps/web/lib/billing-utils.ts b/apps/web/lib/billing-utils.ts
index b07e5c07..cc652768 100644
--- a/apps/web/lib/billing-utils.ts
+++ b/apps/web/lib/billing-utils.ts
@@ -1,3 +1,117 @@
+const COMPANY_BRAIN_PRODUCT_ID = "company_brain"
+
+// Add-on resolved by product presence, not tier.
+// better-auth returns org.metadata as a JSON string, so accept string or object.
+export function hasCompanyBrain(
+ metadataRaw: Record | string | null | undefined,
+): boolean {
+ if (!metadataRaw) return false
+ let metadata: Record