feat: major iteration on the app (#896)

feat: major iteration on the app

add: dashboard improvements

few more improvements

add few more improvements

add few more changes

home improvmeents

few more improvements

add lot of modifications

fix few things
This commit is contained in:
MaheshtheDev 2026-05-02 23:58:58 +00:00
parent 654386c644
commit 68b6c66321
60 changed files with 5406 additions and 1361 deletions

View file

@ -0,0 +1,18 @@
"use client"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
export default function OnboardingPage() {
const router = useRouter()
useEffect(() => {
router.replace("/old/onboarding/welcome?step=input")
}, [router])
return (
<div className="h-screen overflow-hidden bg-black flex items-center justify-center">
<div className="text-white/50 text-sm">Loading...</div>
</div>
)
}

View file

@ -12,7 +12,7 @@ import { useRouter, useSearchParams } from "next/navigation"
import { useOnboardingContext, type MemoryFormData } from "../layout"
import { analytics } from "@/lib/analytics"
export const SETUP_STEPS = ["relatable", "integrations"] as const
export const SETUP_STEPS = ["integrations"] as const
export type SetupStep = (typeof SETUP_STEPS)[number]
interface SetupContextValue {
@ -41,7 +41,7 @@ export default function SetupLayout({ children }: { children: ReactNode }) {
const stepParam = searchParams.get("step")
const currentStep: SetupStep = SETUP_STEPS.includes(stepParam as SetupStep)
? (stepParam as SetupStep)
: "relatable"
: "integrations"
const hasTrackedInitialStep = useRef(false)
const goToStep = useCallback(

View file

@ -0,0 +1,45 @@
"use client"
import { AnimatePresence } from "motion/react"
import { IntegrationsStep } from "@/components/onboarding/setup/integrations-step"
import { SetupHeader } from "@/components/onboarding/setup/header"
import { ChatSidebar } from "@/components/onboarding/setup/chat-sidebar"
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
import { useIsMobile } from "@hooks/use-mobile"
import { useSetupContext } from "./layout"
export default function SetupPage() {
const { memoryFormData } = useSetupContext()
const isMobile = useIsMobile()
return (
<div className="relative h-screen overflow-hidden bg-black">
<SetupHeader />
<AnimatedGradientBackground animateFromBottom={false} />
<main className="relative min-h-screen">
<div className="relative z-10">
<div className="flex flex-col lg:flex-row h-[calc(100vh-90px)] relative">
<div className="flex-1 flex flex-col items-center justify-start p-4 md:p-8">
<AnimatePresence mode="wait">
<IntegrationsStep key="integrations" />
</AnimatePresence>
</div>
{!isMobile && (
<AnimatePresence mode="popLayout">
<ChatSidebar formData={memoryFormData} />
</AnimatePresence>
)}
</div>
</div>
</main>
{isMobile && <ChatSidebar formData={memoryFormData} />}
</div>
)
}

View file

@ -83,7 +83,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
if (isMountedRef.current) {
setShowWelcomeContent(true)
}
}, 1000)
}, 400)
return () => clearTimeout(timer)
}
setShowWelcomeContent(true)
@ -97,7 +97,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
setTimeout(() => {
if (isMountedRef.current) {
analytics.onboardingStepViewed({ step: "welcome", trigger: "auto" })
router.replace("/onboarding/welcome?step=welcome")
router.replace("/old/onboarding/welcome?step=welcome")
}
}, 2000),
)
@ -109,7 +109,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
step: "username",
trigger: "auto",
})
router.replace("/onboarding/welcome?step=username")
router.replace("/old/onboarding/welcome?step=username")
}
}, 2000),
)
@ -133,14 +133,14 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
const goToStep = useCallback(
(step: WelcomeStep) => {
analytics.onboardingStepViewed({ step, trigger: "user" })
router.push(`/onboarding/welcome?step=${step}`)
router.push(`/old/onboarding/welcome?step=${step}`)
},
[router],
)
const goToSetup = useCallback(
(step = "relatable") => {
router.push(`/onboarding/setup?step=${step}`)
(step = "integrations") => {
router.push(`/old/onboarding/setup?step=${step}`)
},
[router],
)

View file

@ -12,7 +12,6 @@ import { OnboardingContentStep } from "@/components/onboarding/welcome/continue-
import { InitialHeader } from "@/components/initial-header"
import { Logo } from "@ui/assets/Logo"
import NovaOrb from "@/components/nova/nova-orb"
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
import {
useWelcomeContext,
@ -212,7 +211,7 @@ export default function WelcomePage() {
const showUserSupermemory = currentStep === "username"
return (
<div className="h-screen overflow-hidden bg-black">
<div className="relative h-screen overflow-hidden bg-black">
<InitialHeader
showUserSupermemory={
currentStep === "features" || currentStep === "memories"
@ -221,10 +220,6 @@ export default function WelcomePage() {
name={name}
/>
{currentStep === "input" && (
<AnimatedGradientBackground animateFromBottom={true} />
)}
{showWelcomeContent && (
<div className="fixed inset-0 flex flex-col items-center justify-center overflow-y-auto">
<motion.div
@ -254,7 +249,6 @@ export default function WelcomePage() {
initial={{
padding: 0,
paddingTop: 0,
y: 60,
}}
className="relative"
>

File diff suppressed because it is too large Load diff

View file

@ -1,74 +0,0 @@
"use client"
import { motion, AnimatePresence } from "motion/react"
import { RelatableQuestion } from "@/components/onboarding/setup/relatable-question"
import { IntegrationsStep } from "@/components/onboarding/setup/integrations-step"
import { SetupHeader } from "@/components/onboarding/setup/header"
import { ChatSidebar } from "@/components/onboarding/setup/chat-sidebar"
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
import { useIsMobile } from "@hooks/use-mobile"
import { useSetupContext, type SetupStep } from "./layout"
function StepNotFound({ goToStep }: { goToStep: (step: SetupStep) => void }) {
return (
<motion.div
className="text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<h2 className="text-white text-2xl mb-4">Unknown step</h2>
<button
type="button"
onClick={() => goToStep("relatable")}
className="text-blue-400 underline"
>
Go to first step
</button>
</motion.div>
)
}
export default function SetupPage() {
const { memoryFormData, currentStep, goToStep } = useSetupContext()
const isMobile = useIsMobile()
const renderStep = () => {
switch (currentStep) {
case "relatable":
return <RelatableQuestion key="relatable" />
case "integrations":
return <IntegrationsStep key="integrations" />
default:
return <StepNotFound key="not-found" goToStep={goToStep} />
}
}
return (
<div className="h-screen overflow-hidden bg-black">
<SetupHeader />
<AnimatedGradientBackground animateFromBottom={false} />
<main className="relative min-h-screen">
<div className="relative z-10">
<div className="flex flex-col lg:flex-row h-[calc(100vh-90px)] relative">
<div className="flex-1 flex flex-col items-center justify-start p-4 md:p-8">
<AnimatePresence mode="wait">{renderStep()}</AnimatePresence>
</div>
{!isMobile && (
<AnimatePresence mode="popLayout">
<ChatSidebar formData={memoryFormData} />
</AnimatePresence>
)}
</div>
</div>
</main>
{isMobile && <ChatSidebar formData={memoryFormData} />}
</div>
)
}

View file

@ -1,9 +1,18 @@
"use client"
import { useState, useCallback, useEffect } from "react"
import {
useState,
useCallback,
useEffect,
useMemo,
useRef,
useSyncExternalStore,
} from "react"
import { AnimatePresence, motion } from "motion/react"
import { useQueryState } from "nuqs"
import { Header } from "@/components/header"
import { ChatSidebar } from "@/components/chat"
import { ChatSidebar, HomeChatComposer } from "@/components/chat"
import { DashboardView } from "@/components/dashboard-view"
import { MemoriesGrid } from "@/components/memories-grid"
import { GraphLayoutView } from "@/components/graph-layout-view"
import { IntegrationsView } from "@/components/integrations-view"
@ -15,7 +24,6 @@ import { FullscreenNoteModal } from "@/components/fullscreen-note-modal"
import type { HighlightItem } from "@/components/highlights-card"
import { HotkeysProvider } from "react-hotkeys-hook"
import { useHotkeys } from "react-hotkeys-hook"
import { AnimatePresence } from "motion/react"
import { useIsMobile } from "@hooks/use-mobile"
import { useAuth } from "@lib/auth-context"
import { useProject } from "@/stores"
@ -26,11 +34,14 @@ import {
useQuickNoteDraft,
} from "@/stores/quick-note-draft"
import { analytics } from "@/lib/analytics"
import type { ModelId } from "@/lib/models"
import { useDocumentMutations } from "@/hooks/use-document-mutations"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { useViewMode } from "@/lib/view-mode-context"
import type { MemoryOfDay } from "@/components/dashboard-view"
import { ErrorBoundary } from "@/components/error-boundary"
import { cn } from "@lib/utils"
import {
@ -39,15 +50,36 @@ import {
qParam,
docParam,
fullscreenParam,
chatParam,
integrationParam,
pluginsPanelParam,
type IntegrationParamValue,
} from "@/lib/search-params"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
function subscribeViewportWidth(cb: () => void) {
window.addEventListener("resize", cb)
return () => window.removeEventListener("resize", cb)
}
function getViewportWidth() {
return window.innerWidth
}
const GRADIENT_TOP_WIDTH_MAX = 1440
function gradientTopPositionForWidth(width: number) {
const minW = 320
const pctWide = 15
const pctNarrow = 70
const w = Math.min(GRADIENT_TOP_WIDTH_MAX, Math.max(minW, width))
const t = (w - minW) / (GRADIENT_TOP_WIDTH_MAX - minW)
const eased = t * t
return `${Math.round(pctNarrow + eased * (pctWide - pctNarrow))}%`
}
function ViewErrorFallback() {
return (
<div className="flex-1 flex items-center justify-center p-8">
@ -68,34 +100,28 @@ function ViewErrorFallback() {
export default function NewPage() {
const isMobile = useIsMobile()
const { user, session } = useAuth()
const {
selectedProject,
isNovaSpaces,
novaContainerTags,
selectedProjects,
setSelectedProjects,
} = useProject()
const selectedProjectTag = selectedProjects[0]
const isNovaContext =
isNovaSpaces ||
(selectedProjectTag !== undefined &&
novaContainerTags.includes(selectedProjectTag))
const { allProjects } = useContainerTags()
const emptyStateSpaceName =
!isNovaSpaces && selectedProjectTag
? selectedProjectTag === DEFAULT_PROJECT_ID
? "My Space"
: (allProjects.find((p) => p.containerTag === selectedProjectTag)
?.name ?? selectedProjectTag)
: undefined
const handleSwitchToAllSpacesFromEmptyState = useCallback(() => {
analytics.spaceSwitched({ space_id: "nova_spaces" })
setSelectedProjects([])
}, [setSelectedProjects])
const { selectedProject, selectedProjects } = useProject()
const selectedProjectTag = selectedProjects[0]
const { allProjects } = useContainerTags()
const dashboardSpaceLabel = useMemo(
() =>
getChatSpaceDisplayLabel({
selectedProject,
allProjects,
}),
[selectedProject, allProjects],
)
const emptyStateSpaceName = selectedProjectTag
? selectedProjectTag === DEFAULT_PROJECT_ID
? "My Space"
: (allProjects.find((p) => p.containerTag === selectedProjectTag)?.name ??
selectedProjectTag)
: undefined
const { viewMode, setViewMode } = useViewMode()
const queryClient = useQueryClient()
const [highlightsForceAt, setHighlightsForceAt] = useState(0)
// Chrome extension auth: send session token via postMessage so the content script can store it
useEffect(() => {
@ -122,12 +148,14 @@ export default function NewPage() {
"fullscreen",
fullscreenParam,
)
const [isChatOpen, setIsChatOpen] = useQueryState("chat", chatParam)
const [integrationFromUrl, setIntegration] = useQueryState(
"integration",
integrationParam,
)
const [pluginsPanelFromUrl] = useQueryState("plugins", pluginsPanelParam)
const [pluginsPanelFromUrl, setPluginsPanel] = useQueryState(
"plugins",
pluginsPanelParam,
)
useEffect(() => {
if (integrationFromUrl || pluginsPanelFromUrl === true) {
@ -138,6 +166,10 @@ export default function NewPage() {
// Ephemeral local state (not worth URL-encoding)
const [fullscreenInitialContent, setFullscreenInitialContent] = useState("")
const [queuedChatSeed, setQueuedChatSeed] = useState<string | null>(null)
const [queuedChatModel, setQueuedChatModel] = useState<ModelId | null>(null)
const [queuedMessageSource, setQueuedMessageSource] = useState<
"highlight" | "home"
>("highlight")
const [selectedDocument, setSelectedDocument] =
useState<DocumentWithMemories | null>(null)
@ -177,6 +209,8 @@ export default function NewPage() {
const resetDraft = useQuickNoteDraftReset(selectedProject)
const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "")
const quickNoteDraftRef = useRef(quickNoteDraft)
quickNoteDraftRef.current = quickNoteDraft
const { noteMutation, bulkDeleteMutation } = useDocumentMutations({
onClose: () => {
@ -247,20 +281,31 @@ export default function NewPage() {
const HIGHLIGHTS_CACHE_NAME = "space-highlights-v1"
const HIGHLIGHTS_MAX_AGE = 4 * 60 * 60 * 1000 // 4 hours
const handleResetHighlights = useCallback(async () => {
toast.success("Refreshing daily brief…")
try {
await caches.delete(HIGHLIGHTS_CACHE_NAME)
} catch {}
setHighlightsForceAt(Date.now())
}, [])
const { data: highlightsData, isLoading: isLoadingHighlights } =
useQuery<SpaceHighlightsResponse>({
queryKey: ["space-highlights", selectedProject],
queryKey: ["space-highlights", selectedProject, highlightsForceAt],
queryFn: async (): Promise<SpaceHighlightsResponse> => {
const spaceId = selectedProject || "sm_project_default"
const forceRefresh = highlightsForceAt > 0
const cacheKey = `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights?spaceId=${spaceId}`
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()
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()
}
}
}
@ -276,6 +321,7 @@ export default function NewPage() {
questionsCount: 4,
includeHighlights: true,
includeQuestions: true,
forceRefresh,
}),
},
)
@ -286,13 +332,21 @@ export default function NewPage() {
const data = await response.json()
const cacheResponse = new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
"x-cached-at": String(Date.now()),
},
})
await cache.put(cacheKey, cacheResponse)
// 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
},
@ -300,6 +354,37 @@ export default function NewPage() {
refetchOnWindowFocus: false,
})
const { data: memoryOfDay = null } = useQuery<MemoryOfDay | null>({
queryKey: [
"memory-of-day",
user?.id,
new Date().toISOString().slice(0, 10),
],
queryFn: async (): Promise<MemoryOfDay | null> => {
const cacheKey = `memory-of-day:${user?.id}:${new Date().toISOString().slice(0, 10)}`
try {
const stored = localStorage.getItem(cacheKey)
if (stored) return JSON.parse(stored) as MemoryOfDay
} catch {}
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/memory-of-day`,
{ credentials: "include" },
)
if (!response.ok) return null
const data = (await response.json()) as MemoryOfDay | null
if (data) {
try {
localStorage.setItem(cacheKey, JSON.stringify(data))
} catch {}
}
return data
},
staleTime: 24 * 60 * 60 * 1000,
refetchOnWindowFocus: false,
enabled: !!user,
})
useHotkeys("c", () => {
analytics.addDocumentModalOpened()
setAddDoc("note")
@ -324,7 +409,7 @@ export default function NewPage() {
const handleQuickNoteSave = useCallback(
(content: string) => {
if (content.trim()) {
const hadPreviousContent = quickNoteDraft.trim().length > 0
const hadPreviousContent = quickNoteDraftRef.current.trim().length > 0
noteMutation.mutate(
{ content, project: selectedProject },
{
@ -339,7 +424,7 @@ export default function NewPage() {
)
}
},
[selectedProject, noteMutation, quickNoteDraft],
[selectedProject, noteMutation],
)
const handleFullScreenSave = useCallback(
@ -375,11 +460,29 @@ export default function NewPage() {
const handleHighlightsChat = useCallback(
(seed: string) => {
setQueuedChatSeed(seed)
setIsChatOpen(true)
setQueuedChatModel(null)
setQueuedMessageSource("highlight")
void setViewMode("chat")
},
[setIsChatOpen],
[setViewMode],
)
const handleHomeChatStart = useCallback(
(message: string, model: ModelId) => {
setQueuedChatSeed(message)
setQueuedChatModel(model)
setQueuedMessageSource("home")
void setViewMode("chat")
},
[setViewMode],
)
const consumeQueuedChat = useCallback(() => {
setQueuedChatSeed(null)
setQueuedChatModel(null)
setQueuedMessageSource("highlight")
}, [])
const handleHighlightsShowRelated = useCallback(
(query: string) => {
analytics.searchOpened({ source: "highlight_related" })
@ -401,6 +504,11 @@ export default function NewPage() {
[setViewMode, setIntegration],
)
const handleOpenPlugins = useCallback(() => {
void setViewMode("integrations")
void setPluginsPanel(true)
}, [setViewMode, setPluginsPanel])
const handleAddMemory = useCallback(
(tab: "note" | "link") => {
analytics.addDocumentModalOpened()
@ -409,120 +517,193 @@ export default function NewPage() {
[setAddDoc],
)
const chatOpen = isChatOpen !== null ? isChatOpen : !isMobile
const viewportWidth = useSyncExternalStore(
subscribeViewportWidth,
getViewportWidth,
() => GRADIENT_TOP_WIDTH_MAX,
)
const gradientTopPosition = gradientTopPositionForWidth(viewportWidth)
const isChatView = viewMode === "chat"
const isGraphMode = viewMode === "graph" && !isMobile
const isMemoriesDesktop = viewMode === "list" && !isMobile
const isHomeDesktop = viewMode === "dashboard" && !isMobile
const showNovaBackdrop = isGraphMode || isMemoriesDesktop
const isDashboardShell =
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
return (
<HotkeysProvider>
<div
className={cn(
"bg-black min-h-screen",
isGraphMode && "h-screen overflow-hidden",
"relative flex min-h-dvh flex-col bg-black",
isGraphMode && "h-dvh overflow-hidden",
)}
>
<AnimatedGradientBackground
topPosition="15%"
animateFromBottom={false}
/>
{isGraphMode && (
<div
id="graph-dotted-grid"
className="absolute inset-0 pointer-events-none bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.25)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_60%,transparent_100%)]"
/>
{showNovaBackdrop && (
<>
<AnimatedGradientBackground
animateFromBottom={isHomeDesktop}
topPosition={gradientTopPosition}
/>
<div
className={cn(
"pointer-events-none absolute inset-0 z-0",
isHomeDesktop
? "bg-[linear-gradient(to_top,rgb(0_0_0/0.88)_0%,rgb(0_0_0/0.52)_38%,rgb(0_0_0/0.42)_100%)]"
: "bg-black/50",
)}
aria-hidden
/>
<div
id="graph-dotted-grid"
className={cn(
"pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.25)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_60%,transparent_100%)]",
isHomeDesktop && "opacity-70",
)}
/>
</>
)}
<Header
onAddMemory={() => {
analytics.addDocumentModalOpened()
setAddDoc("note")
}}
onOpenChat={() => setIsChatOpen(true)}
onOpenSearch={() => {
analytics.searchOpened({ source: "header" })
setIsSearchOpen(true)
}}
/>
<main
key={`main-container-${chatOpen}-${viewMode}`}
className={cn(
"z-10 relative",
isGraphMode && "h-[calc(100vh-86px)] overflow-hidden",
)}
>
<div className={cn("relative z-10 flex flex-col md:flex-row h-full")}>
<ErrorBoundary fallback={<ViewErrorFallback />}>
{viewMode === "integrations" ? (
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
<IntegrationsView />
</div>
) : viewMode === "graph" && !isMobile ? (
<div className="flex-1">
<GraphLayoutView isChatOpen={chatOpen} />
</div>
) : (
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
<MemoriesGrid
isChatOpen={chatOpen}
onOpenDocument={handleOpenDocument}
isSelectionMode={isSelectionMode}
selectedDocumentIds={selectedDocumentIds}
onEnterSelectionMode={handleEnterSelectionMode}
onToggleSelection={handleToggleSelection}
onClearSelection={handleClearSelection}
onSelectAllVisible={handleSelectAllVisible}
onBulkDelete={handleBulkDelete}
isBulkDeleting={bulkDeleteMutation.isPending}
quickNoteProps={{
onSave: handleQuickNoteSave,
onMaximize: handleMaximize,
isSaving: noteMutation.isPending,
}}
highlightsProps={{
items: highlightsData?.highlights || [],
onChat: handleHighlightsChat,
onShowRelated: handleHighlightsShowRelated,
isLoading: isLoadingHighlights,
}}
emptyStateProps={
isNovaContext
? {
onAddMemory: handleAddMemory,
onOpenIntegrations: handleOpenIntegrations,
isAllSpaces: isNovaSpaces,
spaceName: emptyStateSpaceName,
onSwitchToAllSpaces: isNovaSpaces
? undefined
: handleSwitchToAllSpacesFromEmptyState,
}
: undefined
}
/>
</div>
<AnimatePresence mode="wait">
<motion.main
key={`main-container-${viewMode}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.22, ease: [0.4, 0, 0.2, 1] }}
className={cn(
"relative z-10 flex min-h-0 flex-1 flex-col",
(isGraphMode || isChatView) && "overflow-hidden",
)}
>
<div
className={cn(
"relative z-10 flex min-h-0 flex-1 flex-col md:flex-row",
)}
</ErrorBoundary>
<div className="hidden md:block md:sticky md:top-0 md:h-screen">
<AnimatePresence mode="popLayout">
<ErrorBoundary>
<ChatSidebar
isChatOpen={chatOpen}
setIsChatOpen={(open) => setIsChatOpen(open)}
queuedMessage={queuedChatSeed}
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
emptyStateSuggestions={highlightsData?.questions}
>
<ErrorBoundary fallback={<ViewErrorFallback />}>
{isChatView ? (
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col md:self-stretch">
<ChatSidebar
layout="page"
isChatOpen
setIsChatOpen={(open) => {
if (!open) void setViewMode("dashboard")
}}
queuedMessage={queuedChatSeed}
onConsumeQueuedMessage={consumeQueuedChat}
queuedMessageSource={queuedMessageSource}
initialSelectedModel={queuedChatModel}
emptyStateSuggestions={highlightsData?.questions}
/>
</div>
) : viewMode === "integrations" ? (
<div className="min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0">
<IntegrationsView />
</div>
) : viewMode === "graph" && !isMobile ? (
<div className="min-h-0 min-w-0 flex-1">
<GraphLayoutView />
</div>
) : viewMode === "list" ? (
<div
className={cn(
"min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0",
"pb-10 md:pb-12",
)}
>
<MemoriesGrid
isChatOpen={false}
onOpenDocument={handleOpenDocument}
isSelectionMode={isSelectionMode}
selectedDocumentIds={selectedDocumentIds}
onEnterSelectionMode={handleEnterSelectionMode}
onToggleSelection={handleToggleSelection}
onClearSelection={handleClearSelection}
onSelectAllVisible={handleSelectAllVisible}
onBulkDelete={handleBulkDelete}
isBulkDeleting={bulkDeleteMutation.isPending}
quickNoteProps={{
onSave: handleQuickNoteSave,
onMaximize: handleMaximize,
isSaving: noteMutation.isPending,
}}
highlightsProps={{
items: highlightsData?.highlights || [],
onChat: handleHighlightsChat,
onShowRelated: handleHighlightsShowRelated,
isLoading: isLoadingHighlights,
}}
emptyStateProps={{
onAddMemory: handleAddMemory,
onOpenIntegrations: handleOpenIntegrations,
isAllSpaces: false,
spaceName: emptyStateSpaceName,
onSwitchToAllSpaces: undefined,
}}
/>
</div>
) : (
<DashboardView
spaceLabel={dashboardSpaceLabel}
headerNotice={
viewMode === "graph" && isMobile ? (
<div
id="graph-mobile-notice"
className="rounded-lg border border-[#2261CA33] bg-[#041127] px-3 py-2.5 text-sm text-[#8B8B8B]"
>
<span className="font-medium text-white">
Graph view is available on desktop.
</span>{" "}
Use a larger screen for the full graph, or keep
working from this home view.
</div>
) : undefined
}
highlights={highlightsData?.highlights ?? []}
isLoadingHighlights={isLoadingHighlights}
onAddMemory={handleAddMemory}
onOpenSearch={() => {
analytics.searchOpened({ source: "header" })
setIsSearchOpen(true)
}}
onOpenIntegrations={handleOpenIntegrations}
onOpenPlugins={handleOpenPlugins}
onNavigateToMemories={() => void setViewMode("list")}
onNavigateToGraph={() => void setViewMode("graph")}
onOpenDocument={handleOpenDocument}
onHighlightsChat={handleHighlightsChat}
onHighlightsShowRelated={handleHighlightsShowRelated}
onResetHighlights={handleResetHighlights}
memoryOfDay={memoryOfDay}
/>
</ErrorBoundary>
</AnimatePresence>
)}
</ErrorBoundary>
</div>
</motion.main>
</AnimatePresence>
{isDashboardShell && (
<div
className={cn(
"pointer-events-none fixed inset-x-0 z-30 bg-gradient-to-t from-black via-black/80 to-transparent pt-12",
isMobile ? "bottom-[4.5rem]" : "bottom-0",
)}
>
<div className="pointer-events-auto">
<HomeChatComposer onStartChat={handleHomeChatStart} />
</div>
</div>
</main>
{isMobile && (
<ChatSidebar
isChatOpen={chatOpen}
setIsChatOpen={(open) => setIsChatOpen(open)}
queuedMessage={queuedChatSeed}
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
emptyStateSuggestions={highlightsData?.questions}
/>
)}
<AddDocumentModal
@ -536,7 +717,6 @@ export default function NewPage() {
if (!open) setSearchPrefill("")
}}
projectId={selectedProject}
novaContainerTags={isNovaSpaces ? novaContainerTags : undefined}
onOpenDocument={handleOpenDocument}
onAddMemory={() => {
analytics.addDocumentModalOpened()

View file

@ -6,7 +6,7 @@ import { motion } from "motion/react"
import NovaOrb from "@/components/nova/nova-orb"
import { useState, useEffect, useRef } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import Account from "@/components/settings/account"
import Integrations from "@/components/settings/integrations"
import ConnectionsMCP from "@/components/settings/connections-mcp"
@ -16,7 +16,11 @@ import { useRouter } from "next/navigation"
import { useIsMobile } from "@hooks/use-mobile"
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
import { analytics } from "@/lib/analytics"
import { Sun } from "lucide-react"
import { LogOut, RotateCcw, Trash2, Sun, LoaderIcon } from "lucide-react"
import { authClient } from "@lib/auth"
import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog"
import { useResetOrganization } from "@/hooks/use-reset-organization"
import { useDeleteUserAccount } from "@/hooks/use-account-settings"
const TABS = ["account", "integrations", "connections", "support"] as const
type SettingsTab = (typeof TABS)[number]
@ -28,6 +32,14 @@ type NavItem = {
icon: React.ReactNode
}
type DangerItem = {
id: "logout" | "reset" | "delete"
label: string
description: string
icon: React.ReactNode
color: "neutral" | "amber" | "red"
}
const NAV_ITEMS: NavItem[] = [
{
id: "account",
@ -103,6 +115,51 @@ const NAV_ITEMS: NavItem[] = [
},
]
const DANGER_ITEMS: DangerItem[] = [
{
id: "logout",
label: "Log out",
description: "Sign out of your account on this device",
icon: <LogOut className="size-5" />,
color: "neutral",
},
{
id: "reset",
label: "Reset data",
description: "Erase all memories, connections and spaces",
icon: <RotateCcw className="size-5" />,
color: "amber",
},
{
id: "delete",
label: "Delete account",
description: "Permanently delete your account and all data",
icon: <Trash2 className="size-5" />,
color: "red",
},
]
const DANGER_COLORS: Record<
DangerItem["color"],
{ idle: string; hover: string; icon: string }
> = {
neutral: {
idle: "text-white/50",
hover: "hover:text-white",
icon: "text-white/40",
},
amber: {
idle: "text-[#7A6030]",
hover: "hover:text-[#C7991B]",
icon: "text-[#7A6030]",
},
red: {
idle: "text-[#6B2A2A]",
hover: "hover:text-[#C73B1B]",
icon: "text-[#6B2A2A]",
},
}
function parseHashToTab(hash: string): SettingsTab {
const cleaned = hash.replace("#", "").toLowerCase()
return TABS.includes(cleaned as SettingsTab)
@ -133,13 +190,40 @@ export function UserSupermemory({ name }: { name: string }) {
}
export default function SettingsPage() {
const { user } = useAuth()
const { user, org } = useAuth()
const [activeTab, setActiveTab] = useState<SettingsTab>("account")
const hasInitialized = useRef(false)
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 handleLogout = async () => {
await authClient.signOut()
router.push("/login")
}
const handleDeleteAccount = async () => {
if (deleteEmailConfirm !== user?.email) return
deleteUserAccount.mutate(
{ confirmation: deleteEmailConfirm },
{
onSuccess: () => {
setIsDeleteDialogOpen(false)
setDeleteEmailConfirm("")
router.push("/login")
},
},
)
}
useEffect(() => {
if (hasInitialized.current) return
hasInitialized.current = true
@ -277,6 +361,55 @@ export default function SettingsPage() {
)}
</button>
))}
{/* Divider */}
{!isMobile && <div className="my-1 h-px bg-[#0F1621]" />}
{DANGER_ITEMS.map((item) => {
const colors = DANGER_COLORS[item.color]
const handleClick = () => {
if (item.id === "logout") handleLogout()
else if (item.id === "reset") setIsResetDialogOpen(true)
else if (item.id === "delete") setIsDeleteDialogOpen(true)
}
return (
<button
key={item.id}
type="button"
onClick={handleClick}
className={cn(
"rounded-xl transition-colors flex items-start gap-3 shrink-0 group",
isMobile ? "px-3 py-2 text-sm" : "text-left p-4",
"hover:bg-[#14161A] hover:shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
colors.idle,
colors.hover,
)}
>
<span
className={cn(
"shrink-0",
!isMobile && "mt-0.5",
colors.icon,
`group-hover:${colors.hover.replace("hover:", "")}`,
)}
>
{item.icon}
</span>
{isMobile ? (
<span className="font-medium whitespace-nowrap">
{item.label}
</span>
) : (
<div className="flex flex-col gap-0.5">
<span className="font-medium">{item.label}</span>
<span className="text-sm opacity-60">
{item.description}
</span>
</div>
)}
</button>
)
})}
</nav>
</div>
<div className="flex-1 flex flex-col gap-4 md:overflow-y-auto md:max-w-2xl [scrollbar-gutter:stable] md:pr-[17px]">
@ -303,6 +436,169 @@ export default function SettingsPage() {
</div>
</div>
</main>
{/* Reset data dialog */}
{(() => {
const confirmText = org?.name || user?.name || ""
return (
<Dialog
open={isResetDialogOpen}
onOpenChange={(open) => {
setIsResetDialogOpen(open)
if (!open) setResetConfirmation("")
}}
>
<DialogContent className="sm:max-w-md">
<div
className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}
>
<div className="flex flex-col gap-1.5">
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
Reset all data?
</h2>
<p className="text-sm text-[#8B8B8B]">
This permanently removes:
</p>
<ul className="text-sm text-[#8B8B8B] list-disc pl-5 space-y-0.5 mt-1">
<li>All documents and memories</li>
<li>All connections (Google Drive, Notion, etc.)</li>
<li>All custom spaces (default space stays)</li>
<li>Organization settings and filters</li>
</ul>
<p className="text-sm text-[#8B8B8B] mt-1">
Your account and billing plan stay intact.{" "}
<strong className="text-[#FAFAFA]">
This cannot be undone.
</strong>
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-[#8B8B8B]">
Type{" "}
<strong className="text-[#FAFAFA]">
{confirmText || "your name"}
</strong>{" "}
to confirm:
</p>
<input
type="text"
value={resetConfirmation}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-3 justify-end">
<DialogClose asChild>
<button
type="button"
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
>
Cancel
</button>
</DialogClose>
<button
type="button"
disabled={
!confirmText ||
resetConfirmation !== confirmText ||
resetOrganization.isPending
}
onClick={() =>
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 ? (
<LoaderIcon className="size-[15px] animate-spin" />
) : (
<RotateCcw className="size-[15px]" />
)}
{resetOrganization.isPending
? "Resetting…"
: "Reset organization"}
</button>
</div>
</div>
</DialogContent>
</Dialog>
)
})()}
{/* Delete account dialog */}
<Dialog
open={isDeleteDialogOpen}
onOpenChange={(open) => {
setIsDeleteDialogOpen(open)
if (!open) setDeleteEmailConfirm("")
}}
>
<DialogContent className="sm:max-w-md">
<div className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}>
<div className="flex flex-col gap-1.5">
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
Delete your account?
</h2>
<p className="text-sm text-[#8B8B8B]">
Permanently deletes all your data and cancels any active
subscriptions.{" "}
<strong className="text-[#FAFAFA]">
This cannot be undone.
</strong>
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-[#8B8B8B]">
Type your email{" "}
<strong className="text-[#FAFAFA]">{user?.email}</strong> to
confirm:
</p>
<input
type="email"
value={deleteEmailConfirm}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-3 justify-end">
<DialogClose asChild>
<button
type="button"
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
>
Cancel
</button>
</DialogClose>
<button
type="button"
disabled={
deleteEmailConfirm !== user?.email ||
deleteUserAccount.isPending
}
onClick={handleDeleteAccount}
className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
>
{deleteUserAccount.isPending ? (
<LoaderIcon className="size-[15px] animate-spin" />
) : (
<Trash2 className="size-[15px]" />
)}
{deleteUserAccount.isPending ? "Deleting…" : "Delete account"}
</button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -8,55 +8,51 @@ export function AnimatedGradientBackground({
animateFromBottom?: boolean
}) {
return (
<div className="fixed inset-0 z-0 overflow-hidden">
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
<motion.div
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-0.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
style={{ top: animateFromBottom ? undefined : topPosition }}
initial={{ y: "100%" }}
animate={{
y: 0,
opacity: animateFromBottom ? 0 : [1, 0, 1],
top: animateFromBottom ? "0%" : topPosition,
}}
transition={{
y: { duration: 0.75, ease: "easeOut" },
opacity: animateFromBottom
? { duration: 2, ease: "easeOut" }
initial={{ opacity: 0 }}
animate={
animateFromBottom
? { opacity: 1 }
: { opacity: [1, 0, 1], top: topPosition }
}
transition={
animateFromBottom
? { duration: 1, ease: "easeOut" }
: {
duration: 8,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
},
top: animateFromBottom
? { duration: 0.75, ease: "easeOut" }
: undefined,
}}
opacity: {
duration: 8,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
},
}
}
/>
<motion.div
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-1.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
style={{ top: animateFromBottom ? undefined : topPosition }}
initial={{ y: "100%" }}
animate={{
y: 0,
opacity: animateFromBottom ? 0 : [0, 1, 0],
top: animateFromBottom ? "0%" : topPosition,
}}
transition={{
y: { duration: 0.75, ease: "easeOut" },
opacity: animateFromBottom
? { duration: 2, ease: "easeOut" }
initial={{ opacity: 0 }}
animate={
animateFromBottom
? { opacity: 1 }
: { opacity: [0, 1, 0], top: topPosition }
}
transition={
animateFromBottom
? { duration: 1, ease: "easeOut", delay: 0.2 }
: {
duration: 8,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
},
top: animateFromBottom
? { duration: 0.75, ease: "easeOut" }
: undefined,
}}
opacity: {
duration: 8,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
},
}
}
/>
<motion.div
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat"
className="absolute inset-0 bg-[url('/bg-rectangle.png')] bg-cover bg-bottom bg-no-repeat"
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
style={{
mixBlendMode: "soft-light",

View file

@ -0,0 +1,53 @@
"use client"
import { useMemo } from "react"
import type { UIMessage } from "@ai-sdk/react"
import { MemoryGraph } from "@/components/memory-graph"
import { useProject } from "@/stores"
import { extractHighlightDocumentIdsFromMessages } from "@/lib/chat-highlight-documents"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
export function ChatGraphContextRail({
messages,
className,
}: {
messages: UIMessage[]
className?: string
}) {
const { effectiveContainerTags } = useProject()
const highlightIds = useMemo(
() => extractHighlightDocumentIdsFromMessages(messages),
[messages],
)
return (
<div
id="chat-graph-context-rail"
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col bg-black",
dmSansClassName(),
className,
)}
>
<div className="pointer-events-none absolute top-3 left-4 z-20">
<p className="text-xs font-medium text-white/70">Memory map</p>
<p className="mt-0.5 max-w-[14rem] text-[10px] leading-snug text-white/35">
{highlightIds.length > 0
? `${highlightIds.length} memor${highlightIds.length === 1 ? "y" : "ies"} used by Nova`
: "Memories used by Nova will be highlighted here"}
</p>
</div>
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-24 bg-gradient-to-r from-transparent to-black" />
<div className="min-h-0 flex-1 pt-10">
<MemoryGraph
containerTags={effectiveContainerTags}
variant="consumer"
highlightDocumentIds={highlightIds}
highlightsVisible={highlightIds.length > 0}
maxNodes={160}
/>
</div>
</div>
)
}

View file

@ -0,0 +1,82 @@
"use client"
import { useCallback, useMemo, useState } from "react"
import ChatInput from "./input"
import ChatModelSelector from "./model-selector"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
import { useProject } from "@/stores"
import { useContainerTags } from "@/hooks/use-container-tags"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import type { ModelId } from "@/lib/models"
export function HomeChatComposer({
onStartChat,
className,
}: {
onStartChat: (message: string, model: ModelId) => void
className?: string
}) {
const [input, setInput] = useState("")
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
const { selectedProject } = useProject()
const { allProjects } = useContainerTags()
const chatSpaceLabel = useMemo(
() =>
getChatSpaceDisplayLabel({
selectedProject,
allProjects,
}),
[selectedProject, allProjects],
)
const send = useCallback(() => {
const t = input.trim()
if (!t) return
onStartChat(t, selectedModel)
setInput("")
}, [input, onStartChat, selectedModel])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
send()
}
}
return (
<div className={cn(className)}>
<div className="mx-auto w-full max-w-[720px] px-4 pt-1 pb-3 md:pb-4">
<ChatInput
value={input}
onChange={(e) => setInput(e.target.value)}
onSend={send}
onStop={() => {}}
onKeyDown={handleKeyDown}
isResponding={false}
showStatusStrip={false}
stackedToolbar={
<>
<ChatModelSelector
selectedModel={selectedModel}
onModelChange={setSelectedModel}
minimal
/>
<div
className={cn(
"inline-flex max-w-[min(160px,35vw)] min-w-0 shrink items-center rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5",
dmSansClassName(),
)}
title={chatSpaceLabel}
>
<span className="truncate text-sm text-[#FAFAFA]">
{chatSpaceLabel}
</span>
</div>
</>
}
/>
</div>
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@ import { ChevronUpIcon } from "lucide-react"
import NovaOrb from "@/components/nova/nova-orb"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { useRef, useState } from "react"
import { type ReactNode, useRef, useState } from "react"
import { motion } from "motion/react"
import { SendButton, StopButton } from "./actions"
@ -18,6 +18,10 @@ interface ChatInputProps {
activeStatus?: string
chainOfThoughtComponent?: React.ReactNode
onExpandedChange?: (expanded: boolean) => void
/** Model + space controls on one row with send; textarea full-width above */
stackedToolbar?: ReactNode
/** Nova status row + chain-of-thought toggle (off for e.g. home composer) */
showStatusStrip?: boolean
}
export default function ChatInput({
@ -30,6 +34,8 @@ export default function ChatInput({
activeStatus,
chainOfThoughtComponent,
onExpandedChange,
stackedToolbar,
showStatusStrip = true,
}: ChatInputProps) {
const [isMultiline, setIsMultiline] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
@ -52,82 +58,122 @@ export default function ChatInput({
<motion.div
className={cn("relative z-20!")}
animate={{
padding: isExpanded ? "16px" : "0",
margin: isExpanded ? "0" : "16px",
borderRadius: isExpanded ? "0 0 12px 12px" : "12px",
backgroundColor: isExpanded ? "#000B1B" : "#01173C",
padding: showStatusStrip ? (isExpanded ? "16px" : "0") : "0",
margin: showStatusStrip ? (isExpanded ? "0" : "16px") : "0",
borderRadius: showStatusStrip
? isExpanded
? "0 0 12px 12px"
: "12px"
: "0",
backgroundColor: showStatusStrip
? isExpanded
? "#000B1B"
: "#01173C"
: "transparent",
}}
transition={{
duration: 0.3,
ease: "easeOut",
}}
>
<div
className={cn(
"absolute bottom-full left-0 right-0 overflow-hidden transition-all duration-300 ease-out bg-[#000B1B]",
isExpanded
? "max-h-[60vh] opacity-100 overflow-y-auto pt-1.5 pb-2 rounded-t-xl px-4"
: "max-h-0 opacity-0",
)}
style={{
zIndex: isExpanded ? 50 : 0,
}}
>
{chainOfThoughtComponent}
</div>
<button
type="button"
className={cn(
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left",
!chainOfThoughtComponent && "disabled:cursor-not-allowed",
)}
onClick={() => {
const newExpanded = !isExpanded
setIsExpanded(newExpanded)
onExpandedChange?.(newExpanded)
}}
disabled={!chainOfThoughtComponent}
>
<div className="flex items-center gap-3">
<NovaOrb size={24} className="blur-[1px]! z-10" />
<p className={cn("text-[#525D6E]", dmSansClassName())}>
{activeStatus || "Waiting for input..."}
</p>
</div>
{chainOfThoughtComponent && (
<ChevronUpIcon
{showStatusStrip ? (
<>
<div
className={cn(
"size-4 text-[#525D6E] transition-transform duration-300",
isExpanded && "rotate-180",
"absolute bottom-full left-0 right-0 overflow-hidden transition-all duration-300 ease-out bg-[#000B1B]",
isExpanded
? "max-h-[60vh] opacity-100 overflow-y-auto pt-1.5 pb-2 rounded-t-xl px-4"
: "max-h-0 opacity-0",
)}
style={{
zIndex: isExpanded ? 50 : 0,
}}
>
{chainOfThoughtComponent}
</div>
<button
type="button"
className={cn(
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left",
!chainOfThoughtComponent && "disabled:cursor-not-allowed",
)}
onClick={() => {
const newExpanded = !isExpanded
setIsExpanded(newExpanded)
onExpandedChange?.(newExpanded)
}}
disabled={!chainOfThoughtComponent}
>
<div className="flex items-center gap-3">
<NovaOrb size={24} className="blur-[1px]! z-10" />
<p className={cn("text-[#525D6E]", dmSansClassName())}>
{activeStatus || "Waiting for input..."}
</p>
</div>
{chainOfThoughtComponent && (
<ChevronUpIcon
className={cn(
"size-4 text-[#525D6E] transition-transform duration-300",
isExpanded && "rotate-180",
)}
/>
)}
</button>
</>
) : null}
{stackedToolbar ? (
<div className="flex flex-col gap-2 rounded-xl border border-[#52596633] bg-[#070E1B] p-2 transition-all duration-200 focus-within:outline-1 focus-within:outline-[#525D6EB2]">
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
placeholder="Ask your supermemory..."
className="w-full resize-none overflow-y-auto bg-transparent p-2 transition-all duration-200 placeholder:text-[#525D6E] focus:outline-none"
style={{ minHeight: "36px" }}
rows={1}
disabled={isResponding}
/>
)}
</button>
<div
className={cn(
"flex items-end gap-2 bg-[#070E1B] rounded-xl p-2 border-[#52596633] border focus-within:outline-[#525D6EB2] focus-within:outline-1 transition-all duration-200",
isMultiline && "flex-col",
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
placeholder="Ask your supermemory..."
className="bg-transparent w-full p-2 placeholder:text-[#525D6E] focus:outline-none resize-none overflow-y-auto transition-all duration-200"
style={{ minHeight: "36px" }}
rows={1}
disabled={isResponding}
/>
<div className="transition-all duration-200">
{isResponding ? (
<StopButton onClick={onStop} />
) : (
<SendButton onClick={onSend} disabled={!value.trim()} />
)}
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
{stackedToolbar}
</div>
<div className="shrink-0">
{isResponding ? (
<StopButton onClick={onStop} />
) : (
<SendButton onClick={onSend} disabled={!value.trim()} />
)}
</div>
</div>
</div>
</div>
) : (
<div
className={cn(
"flex items-end gap-2 rounded-xl border border-[#52596633] bg-[#070E1B] p-2 transition-all duration-200 focus-within:outline-1 focus-within:outline-[#525D6EB2]",
isMultiline && "flex-col",
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
placeholder="Ask your supermemory..."
className="w-full resize-none overflow-y-auto bg-transparent p-2 transition-all duration-200 placeholder:text-[#525D6E] focus:outline-none"
style={{ minHeight: "36px" }}
rows={1}
disabled={isResponding}
/>
<div className="transition-all duration-200">
{isResponding ? (
<StopButton onClick={onStop} />
) : (
<SendButton onClick={onSend} disabled={!value.trim()} />
)}
</div>
</div>
)}
</motion.div>
)
}

View file

@ -11,11 +11,14 @@ import { analytics } from "@/lib/analytics"
interface ChatModelSelectorProps {
selectedModel?: ModelId
onModelChange?: (model: ModelId) => void
/** Compact pill matching inline send control (black + #161F2C border, rounded-full) */
minimal?: boolean
}
export default function ChatModelSelector({
selectedModel: selectedModelProp,
onModelChange,
minimal = false,
}: ChatModelSelectorProps = {}) {
const [internalModel, setInternalModel] =
useState<ModelId>("claude-sonnet-4.6")
@ -34,25 +37,44 @@ export default function ChatModelSelector({
setIsOpen(false)
}
const trigger = minimal ? (
<button
type="button"
className={cn(
"flex max-w-[min(100%,220px)] min-w-0 shrink cursor-pointer items-center gap-1.5 rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5 text-sm transition-colors hover:bg-[#161F2C]",
dmSansClassName(),
)}
onClick={() => setIsOpen(!isOpen)}
>
<p className="min-w-0 truncate text-left text-[#FAFAFA]">
{currentModelData.name}{" "}
<span className="text-[#525D6E]">{currentModelData.version}</span>
</p>
<ChevronDownIcon className="size-3.5 shrink-0 text-[#525D6E]" />
</button>
) : (
<Button
variant="headers"
className={cn(
"h-10! max-w-[min(100%,220px)] shrink gap-1 rounded-full border-[#73737333] bg-[#0D121A] text-base",
dmSansClassName(),
)}
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
onClick={() => setIsOpen(!isOpen)}
>
<p className="truncate text-sm">
{currentModelData.name}{" "}
<span className="text-[#737373]">{currentModelData.version}</span>
</p>
<ChevronDownIcon className="size-4 text-[#737373]" />
</Button>
)
return (
<div className="relative flex items-center gap-2">
<Button
variant="headers"
className={cn(
"rounded-full text-base gap-1 h-10! border-[#73737333] bg-[#0D121A]",
dmSansClassName(),
)}
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
onClick={() => setIsOpen(!isOpen)}
>
<p className="text-sm">
{currentModelData.name}{" "}
<span className="text-[#737373]">{currentModelData.version}</span>
</p>
<ChevronDownIcon className="size-4 text-[#737373]" />
</Button>
<div className="relative flex min-w-0 shrink items-center gap-2">
{trigger}
{isOpen && (
<>
@ -64,7 +86,7 @@ export default function ChatModelSelector({
aria-label="Close model selector"
/>
<div className="absolute top-full left-0 mt-2 w-64 bg-[#0D121A] backdrop-blur-xl border border-[#73737333] rounded-lg shadow-xl z-50 overflow-hidden">
<div className="absolute bottom-full left-0 mb-2 w-64 bg-[#0D121A] backdrop-blur-xl border border-[#73737333] rounded-lg shadow-xl z-50 overflow-hidden">
<div className="p-2 space-y-1">
{models.map((model) => {
const modelData = modelNames[model.id]

View file

@ -0,0 +1,869 @@
"use client"
import type { ReactNode } from "react"
import { useMemo, useState, useEffect } from "react"
import { useAuth } from "@lib/auth-context"
import { $fetch } from "@lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useQuery } from "@tanstack/react-query"
import { useRouter } from "next/navigation"
import {
ArrowRight,
ExternalLink,
FileText,
Lightbulb,
Link2,
RotateCcw,
SearchIcon,
Terminal,
} from "lucide-react"
import type { z } from "zod"
import { CHROME_EXTENSION_URL, RAYCAST_EXTENSION_URL } from "@lib/constants"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { useProject } from "@/stores"
import {
HighlightsCard,
type HighlightItem,
} from "@/components/highlights-card"
import { StaticGraphPreview } from "@/components/memory-graph/graph-card"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
import { ChromeIcon, RaycastIcon } from "@/components/integration-icons"
import { GoogleDrive, Notion, MCPIcon } from "@ui/assets/icons"
import { analytics } from "@/lib/analytics"
import type { IntegrationParamValue } from "@/lib/search-params"
import { motion, AnimatePresence } from "motion/react"
import {
usePersonalization,
type Profession,
} from "@/hooks/use-personalization"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
const fadeUp = {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0 },
transition: {
duration: 0.3,
ease: [0.4, 0, 0.2, 1] as [number, number, number, number],
},
}
const CYCLE_INTERVAL_MS = 8_000
const PLUGIN_TAGLINES: Record<Profession, Partial<Record<string, string>>> = {
developer: {
mcp: "Ask Claude about your saved docs and specs from any IDE",
chrome: "Save Stack Overflow answers, docs and repos in one click",
raycast: "Search your tech docs and snippets without context switching",
notion: "Make your engineering specs and RFCs instantly findable",
"google-drive": "Query your design docs, code specs and shared files",
},
research: {
mcp: "Ask Claude across your entire reading list and notes",
chrome: "Clip papers and articles directly while you read",
raycast: "Pull up citations and notes without breaking your focus",
notion: "Keep your literature review alongside your saved papers",
"google-drive": "Index datasets, papers and research docs in one place",
},
finance: {
mcp: "Ask Claude about your saved thesis notes and research",
chrome: "Save earnings calls, market reports and articles instantly",
raycast: "Surface your research and models without breaking flow",
notion: "Make your investment thesis and portfolio notes searchable",
"google-drive": "Query your financial models, decks and reports instantly",
},
design: {
mcp: "Ask Claude about your saved briefs and design research",
chrome: "Save inspiration and references as you browse",
raycast: "Find your saved references and briefs from anywhere",
notion: "Make your design system docs and briefs searchable",
"google-drive": "Index your briefs, feedback docs and creative assets",
},
legal: {
mcp: "Ask Claude across your saved contracts and case notes",
chrome: "Clip case law, statutes and legal articles in one click",
raycast: "Surface contracts and precedents without leaving your workflow",
notion: "Keep memos, briefs and case notes instantly searchable",
"google-drive": "Index contracts, filings and legal research docs",
},
marketing: {
mcp: "Ask Claude across your saved campaigns and research",
chrome: "Save competitor pages and inspiration as you browse",
raycast: "Pull up campaign briefs and notes without context switching",
notion: "Make your content calendar and campaign briefs searchable",
"google-drive": "Query campaign reports, briefs and creative assets",
},
medical: {
mcp: "Ask Claude across your medical literature and clinical notes",
chrome: "Save studies and clinical resources while you read",
raycast: "Surface guidelines and notes without breaking your flow",
notion: "Keep clinical notes and research in one searchable place",
"google-drive": "Index guidelines, studies and patient education docs",
},
default: {
mcp: "Ask Claude using your own saved knowledge",
chrome: "Save any page in one click while you browse",
raycast: "Search your memory without leaving the keyboard",
notion: "Make every note and doc instantly searchable",
"google-drive": "Ask questions across your docs, slides and sheets",
},
}
export type MemoryOfDay = {
memories: string[]
timeLabel: string
sourceDocumentId: string | null
}
const TIPS: Record<Profession, string[]> = {
developer: [
"Use ⌘K to search code snippets and docs by intent, not just keywords",
"Connect Claude MCP to query your saved knowledge from any IDE",
"Save GitHub repos and READMEs — ask questions across all of them",
"Use 'Related' on highlights to find connected technical concepts",
],
research: [
"Save papers and ask questions across your entire reading list",
"Use 'Related' on highlights to surface connected research",
"Connect Notion to index your notes alongside your papers",
"Semantic search means you can ask questions, not just search titles",
],
finance: [
"Save articles and ask follow-up questions across your research",
"Connect Notion to keep your investment thesis searchable",
"Use ⌘K to find specific data points across all your saves",
"Daily Brief surfaces connections you may have missed",
],
design: [
"Save inspiration and search by concept — 'minimalist UI' finds the right ones",
"Use ⌘K to rediscover references by meaning, not filename",
"Connect Notion to make your briefs and moodboards searchable",
"Chrome extension saves any page in one click while you browse",
],
legal: [
"Save documents and search across them semantically in seconds",
"Connect Notion to index your memos and case notes together",
"Use Daily Brief to resurface relevant precedents automatically",
"Google Drive sync keeps your contracts indexed and queryable",
],
marketing: [
"Save campaigns and resources — ask what worked across all of them",
"Chrome extension captures competitor pages in one click",
"Use 'Related' to find similar campaigns in your archive",
"Connect Notion to make your campaign briefs instantly searchable",
],
medical: [
"Save studies and query across your entire reading list",
"Connect Notion to keep clinical notes alongside research",
"Use ⌘K to find specific findings across hundreds of papers",
"Daily Brief surfaces relevant research from your saves automatically",
],
default: [
"Use ⌘K to search by meaning — ask questions, not just keywords",
"Daily Brief surfaces insights from your saves each morning",
"Chrome extension saves any page in one click while you browse",
"Connect integrations to make all your knowledge searchable here",
],
}
const PROFESSION_PLUGIN_ORDER: Record<Profession, string[]> = {
developer: ["mcp", "chrome", "raycast", "notion", "google-drive"],
research: ["notion", "chrome", "google-drive", "mcp", "raycast"],
finance: ["notion", "google-drive", "chrome", "mcp", "raycast"],
design: ["chrome", "notion", "raycast", "mcp", "google-drive"],
legal: ["notion", "google-drive", "chrome", "mcp", "raycast"],
marketing: ["chrome", "notion", "raycast", "google-drive", "mcp"],
medical: ["notion", "chrome", "google-drive", "mcp", "raycast"],
default: ["mcp", "chrome", "notion", "raycast", "google-drive"],
}
const PROFESSION_LABELS: {
value: Exclude<Profession, "default">
label: string
}[] = [
{ value: "developer", label: "Developer" },
{ value: "research", label: "Researcher" },
{ value: "finance", label: "Finance" },
{ value: "design", label: "Designer" },
{ value: "legal", label: "Legal" },
{ value: "marketing", label: "Marketing" },
{ value: "medical", label: "Medical" },
]
// Static plugin metadata — shared between PluginPromoCard and RecommendedPluginsCard
const PLUGIN_STATIC = [
{
id: "mcp",
name: "Claude MCP",
Icon: MCPIcon,
accentColor: "#D4A853",
tagline: "Ask Claude from your own saved knowledge, not just training data",
cta: "Set up",
},
{
id: "chrome",
name: "Chrome Extension",
Icon: ChromeIcon,
accentColor: "#4BA0FA",
tagline: "Save any page in one click — findable by meaning, forever",
cta: "Install",
},
{
id: "raycast",
name: "Raycast",
Icon: RaycastIcon,
accentColor: "#FF6363",
tagline: "Search your entire memory without leaving your keyboard",
cta: "Install",
},
{
id: "notion",
name: "Notion",
Icon: Notion,
accentColor: "#FAFAFA",
tagline: "Sync your workspace and make every note searchable everywhere",
cta: "Connect",
},
{
id: "google-drive",
name: "Google Drive",
Icon: GoogleDrive,
accentColor: "#4BA0FA",
tagline:
"Index your Drive files — ask questions across docs, slides, sheets",
cta: "Connect",
},
] as const
function RecommendedPluginsCard({
profession,
setProfession,
connectedProviders,
hasMcp,
onOpenPlugins,
onOpenIntegrations,
}: {
profession: Profession
setProfession: (p: Profession) => void
connectedProviders: Set<string>
hasMcp: boolean
onOpenPlugins: () => void
onOpenIntegrations: (integration?: IntegrationParamValue) => void
}) {
const [isEditing, setIsEditing] = useState(false)
useEffect(() => {
setIsEditing(false)
}, [])
const showPicker = profession === "default" || isEditing
const allPlugins = useMemo(() => {
const onClicks: Record<string, () => void> = {
mcp: onOpenPlugins,
chrome: () =>
window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer"),
raycast: () =>
window.open(RAYCAST_EXTENSION_URL, "_blank", "noopener,noreferrer"),
notion: () => onOpenIntegrations("notion"),
"google-drive": () => onOpenIntegrations("google-drive"),
}
const connected: Record<string, boolean> = {
mcp: hasMcp,
chrome: false,
raycast: false,
notion: connectedProviders.has("notion"),
"google-drive": connectedProviders.has("google-drive"),
}
return PLUGIN_STATIC.map((p) => ({
...p,
connected: connected[p.id] ?? false,
onClick: onClicks[p.id]!,
}))
}, [hasMcp, connectedProviders, onOpenPlugins, onOpenIntegrations])
const order = PROFESSION_PLUGIN_ORDER[profession]
const suggestions = useMemo(
() =>
order
.map((id) => allPlugins.find((p) => p.id === id))
.filter((p): p is NonNullable<typeof p> => !!p && !p.connected)
.slice(0, 3),
[order, allPlugins],
)
return (
<div
className={cn(
"bg-[#050709] border border-[#0F1621] rounded-xl px-3 py-2 flex flex-col gap-1",
dmSansClassName(),
)}
>
{showPicker ? (
<div className="px-1 py-2 flex flex-col gap-2.5">
<p className="text-[11px] text-[#737373]">
{isEditing ? "Change your field:" : "What's your field?"}
</p>
<div className="flex flex-wrap gap-1.5">
{PROFESSION_LABELS.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => {
setProfession(value)
setIsEditing(false)
}}
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all cursor-pointer",
profession === value
? "border-[#3374FF]/40 bg-[#3374FF]/10 text-[#6BB0FF]"
: "border-[#161F2C] text-[#525D6E] hover:border-[#3374FF]/25 hover:text-[#4BA0FA]",
)}
>
{label}
</button>
))}
</div>
{isEditing && (
<button
type="button"
onClick={() => setIsEditing(false)}
className="text-[10px] text-[#3A4455] hover:text-[#525D6E] transition-colors text-left cursor-pointer"
>
Cancel
</button>
)}
</div>
) : suggestions.length === 0 ? (
<div className="flex items-center justify-center py-4">
<p className="text-[11px] text-[#525D6E] text-center">
You're all set
</p>
</div>
) : (
<>
<ul>
{suggestions.map((plugin) => (
<li key={plugin.id}>
<button
type="button"
onClick={plugin.onClick}
className="group w-full flex items-center gap-2.5 rounded-lg px-2 py-2 hover:bg-[#0D121A] transition-colors cursor-pointer"
>
<plugin.Icon className="size-4 shrink-0 text-[#525D6E]" />
<div className="flex-1 min-w-0 text-left">
<p className="text-[12px] text-[#737373] group-hover:text-white transition-colors leading-tight">
{plugin.name}
</p>
<p className="text-[11px] text-[#525D6E] leading-tight mt-0.5">
{PLUGIN_TAGLINES[profession][plugin.id] ?? plugin.tagline}
</p>
</div>
<span className="shrink-0 text-[10px] font-medium text-[#3374FF] group-hover:text-[#6BB0FF] transition-colors">
{plugin.cta}
</span>
</button>
</li>
))}
</ul>
<button
type="button"
onClick={() => setIsEditing(true)}
className="text-left px-2 pb-1 text-[10px] text-[#3A4455] hover:text-[#525D6E] transition-colors cursor-pointer"
>
Not a{" "}
{PROFESSION_LABELS.find(
(p) => p.value === profession,
)?.label.toLowerCase()}
? Change
</button>
</>
)}
</div>
)
}
function MemoryOfDayCard({ data }: { data: MemoryOfDay }) {
const router = useRouter()
const memory = data.memories[0]
if (!memory) return null
return (
<button
type="button"
onClick={() => router.push("/?view=list")}
className={cn(
"group w-full h-full text-left bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col justify-between hover:border-[rgba(255,255,255,0.10)] transition-colors cursor-pointer",
dmSansClassName(),
)}
>
<div className="flex flex-col gap-2.5">
<span className="self-start text-[9px] font-semibold tracking-[0.12em] uppercase text-[#4BA0FA] bg-[#4BA0FA]/10 rounded-full px-2 py-0.5">
{data.timeLabel}
</span>
<p className="text-[12px] text-[#8B9DB5] leading-relaxed line-clamp-4">
{memory}
</p>
</div>
<span className="text-[10px] text-[#2A3A50] group-hover:text-[#4A6A80] transition-colors">
View memories
</span>
</button>
)
}
function PluginPromoCard({
hasMcp,
connectedProviders,
onOpenPlugins,
onOpenIntegrations,
}: {
hasMcp: boolean
connectedProviders: Set<string>
onOpenPlugins: () => void
onOpenIntegrations: (integration?: IntegrationParamValue) => void
}) {
const plugins = useMemo(() => {
const onClicks: Record<string, () => void> = {
mcp: onOpenPlugins,
chrome: () =>
window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer"),
raycast: () =>
window.open(RAYCAST_EXTENSION_URL, "_blank", "noopener,noreferrer"),
notion: () => onOpenIntegrations("notion"),
"google-drive": () => onOpenIntegrations("google-drive"),
}
const connected: Record<string, boolean> = {
mcp: hasMcp,
chrome: false,
raycast: false,
notion: connectedProviders.has("notion"),
"google-drive": connectedProviders.has("google-drive"),
}
return PLUGIN_STATIC.map((p) => ({
...p,
connected: connected[p.id] ?? false,
onClick: onClicks[p.id]!,
})).filter((p) => !p.connected)
}, [hasMcp, connectedProviders, onOpenPlugins, onOpenIntegrations])
const [index, setIndex] = useState(0)
// Reset when the plugins list changes length (e.g., user connects one)
useEffect(() => {
setIndex(0)
}, [])
useEffect(() => {
if (plugins.length <= 1) return
const id = setInterval(
() => setIndex((i) => (i + 1) % plugins.length),
CYCLE_INTERVAL_MS,
)
return () => clearInterval(id)
}, [plugins.length])
const safeIndex = Math.min(index, plugins.length - 1)
const plugin = plugins[safeIndex]
return (
<div
className={cn(
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col justify-between gap-3 h-full",
dmSansClassName(),
)}
>
{plugin ? (
<>
<AnimatePresence mode="wait">
<motion.div
key={plugin.id}
initial={{ opacity: 0, x: 16 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -16 }}
transition={{ duration: 0.3 }}
className="flex flex-col gap-3 flex-1"
>
<div className="flex items-center justify-between">
<plugin.Icon className="size-7 shrink-0" />
{plugins.length > 1 && (
<div className="flex gap-1">
{plugins.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setIndex(i)}
className={cn(
"rounded-full transition-all cursor-pointer",
i === safeIndex
? "w-3 h-1 bg-[#4BA0FA]"
: "size-1 bg-[#2A3040] hover:bg-[#3A4455]",
)}
/>
))}
</div>
)}
</div>
<div className="flex flex-col gap-1">
<p className="text-[11px] font-semibold text-[#FAFAFA] leading-tight">
{plugin.name}
</p>
<p className="text-[10px] text-[#525D6E] leading-normal">
{plugin.tagline}
</p>
</div>
</motion.div>
</AnimatePresence>
<button
type="button"
onClick={plugin.onClick}
className="w-full bg-[#0D121A] rounded-lg px-3 py-1.5 text-[11px] font-medium text-[#4BA0FA] hover:text-white hover:bg-[#141C28] transition-colors cursor-pointer text-left flex items-center justify-between group"
style={{ boxShadow: "inset 1px 1px 2px rgba(0,0,0,0.5)" }}
>
<span>{plugin.cta}</span>
<ArrowRight className="size-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
</>
) : (
<div className="flex flex-col items-center justify-center h-full gap-1.5 text-center">
<Terminal className="size-4 text-[#3A4455]" />
<p className="text-[10px] text-[#3A4455]">
All integrations connected
</p>
</div>
)}
</div>
)
}
export function DashboardView({
spaceLabel,
headerNotice,
highlights,
isLoadingHighlights,
onAddMemory,
onOpenSearch,
onOpenIntegrations,
onOpenPlugins,
onNavigateToMemories,
onNavigateToGraph,
onOpenDocument,
onHighlightsChat,
onHighlightsShowRelated,
onResetHighlights,
memoryOfDay,
}: {
spaceLabel: string
headerNotice?: ReactNode
highlights: HighlightItem[]
isLoadingHighlights: boolean
onAddMemory: (tab: "note" | "link") => void
onOpenSearch: () => void
onOpenIntegrations: (integration?: IntegrationParamValue) => void
onOpenPlugins: () => void
onNavigateToMemories: () => void
onNavigateToGraph: () => void
onOpenDocument: (document: DocumentWithMemories) => void
onHighlightsChat: (seed: string) => void
onHighlightsShowRelated: (query: string) => void
onResetHighlights: () => void
memoryOfDay: MemoryOfDay | null
}) {
const { user } = useAuth()
const { effectiveContainerTags } = useProject()
const _router = useRouter()
const { data: recentsData } = useQuery({
queryKey: ["dashboard-recents", effectiveContainerTags],
queryFn: async (): Promise<DocumentsResponse> => {
const response = await $fetch("@post/documents/documents", {
body: {
page: 1,
limit: 5,
sort: "createdAt",
order: "desc",
containerTags: effectiveContainerTags,
},
disableValidation: true,
})
if (response.error) throw new Error(response.error?.message)
return response.data as DocumentsResponse
},
staleTime: 60 * 1000,
enabled: !!user,
})
const { data: connections = [] } = useQuery({
queryKey: ["connections-list", effectiveContainerTags],
queryFn: async () => {
const response = await $fetch("@post/connections/list", {
body: { containerTags: effectiveContainerTags },
})
if (response.error) return []
return response.data ?? []
},
staleTime: 5 * 60 * 1000,
enabled: !!user,
})
const { data: mcpData } = useQuery({
queryKey: ["mcp-status"],
queryFn: async () => {
const response = await $fetch("@get/mcp/has-login")
return response.data ?? { previousLogin: false }
},
staleTime: 5 * 60 * 1000,
enabled: !!user,
})
const {
copy: personalizedCopy,
profession,
setProfession,
} = usePersonalization()
const recents = recentsData?.documents ?? []
const totalMemories = recentsData?.pagination?.totalItems ?? 0
const hasMcp = mcpData?.previousLogin ?? false
const connectedProviders = new Set(connections.map((c) => c.provider))
const dayOfYear = Math.round(
(Date.now() - new Date(new Date().getFullYear(), 0, 1).getTime()) /
86_400_000,
)
const tips = TIPS[profession]
const tip = tips[dayOfYear % tips.length]
return (
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto p-4 pt-2! pb-32 md:p-6 md:pb-36 md:pr-0",
dmSansClassName(),
)}
>
<div className="mx-auto w-full max-w-4xl space-y-4 md:space-y-5">
{headerNotice ? <div className="space-y-2">{headerNotice}</div> : null}
{/* Header */}
<motion.header
{...fadeUp}
transition={{ ...fadeUp.transition, delay: 0 }}
className="flex items-end justify-between gap-4 border-b border-[#0F1621] pb-4"
>
<div className="space-y-0.5">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-[#3A4455]">
Home
</p>
<h1 className="text-xl font-medium tracking-tight text-white md:text-2xl">
{spaceLabel}
</h1>
</div>
{totalMemories > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onNavigateToGraph}
className="group relative shrink-0 w-[140px] h-[56px] rounded-xl overflow-hidden border border-[rgba(255,255,255,0.05)] hover:border-[rgba(255,255,255,0.14)] transition-all bg-[#0B1017] hover:scale-[1.02]"
aria-label="Open graph view"
>
<StaticGraphPreview
documentCount={totalMemories}
memoryCount={totalMemories * 6}
width={140}
height={56}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
View graph
</TooltipContent>
</Tooltip>
)}
</motion.header>
{/* Daily Brief — hero */}
<motion.section
{...fadeUp}
transition={{ ...fadeUp.transition, delay: 0.05 }}
className="space-y-2"
>
<div className="flex items-center gap-1.5">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-[#3A4455]">
Daily brief
</p>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onResetHighlights}
className="text-[#2A3040] hover:text-[#5A6478] transition-colors cursor-pointer"
aria-label="Refresh daily brief"
>
<RotateCcw className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Refresh daily brief
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-3 items-stretch">
<div className="flex-[4] min-w-0">
<HighlightsCard
items={highlights}
onChat={onHighlightsChat}
onShowRelated={onHighlightsShowRelated}
isLoading={isLoadingHighlights}
/>
</div>
<div className="flex-[2] hidden sm:block min-w-0">
{memoryOfDay ? (
<MemoryOfDayCard data={memoryOfDay} />
) : (
<PluginPromoCard
hasMcp={hasMcp}
connectedProviders={connectedProviders}
onOpenPlugins={onOpenPlugins}
onOpenIntegrations={onOpenIntegrations}
/>
)}
</div>
</div>
</motion.section>
{/* Actions + connection status — single unified row */}
<motion.section
{...fadeUp}
transition={{ ...fadeUp.transition, delay: 0.1 }}
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
{/* Quick actions */}
<div className="flex items-center gap-0.5 -mx-2.5">
<button
type="button"
onClick={() => onAddMemory("link")}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-[#5A6478] hover:bg-[#0D121A] hover:text-white transition-colors cursor-pointer"
>
<Link2 className="size-3.5 shrink-0" />
{personalizedCopy.saveLink}
</button>
<span className="text-[#1A2030] select-none">·</span>
<button
type="button"
onClick={() => onAddMemory("note")}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-[#5A6478] hover:bg-[#0D121A] hover:text-white transition-colors cursor-pointer"
>
<FileText className="size-3.5 shrink-0" />
{personalizedCopy.writeNote}
</button>
<span className="text-[#1A2030] select-none">·</span>
<button
type="button"
onClick={() => {
analytics.searchOpened({ source: "header" })
onOpenSearch()
}}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-[#5A6478] hover:bg-[#0D121A] hover:text-white transition-colors cursor-pointer"
>
<SearchIcon className="size-3.5 shrink-0" />
Search
</button>
</div>
{/* Tip of the day */}
<p className="hidden sm:flex items-center gap-1.5 text-[11px] text-[#525D6E] min-w-0 overflow-hidden">
<Lightbulb className="size-3 shrink-0 text-[#3374FF]" />
<span className="truncate">{tip}</span>
</p>
</motion.section>
{/* Recently saved + Suggested for you */}
<motion.section
{...fadeUp}
transition={{ ...fadeUp.transition, delay: 0.15 }}
className="space-y-2"
>
{recents.length > 0 ? (
<>
{/* Shared header row — both labels aligned */}
<div className="flex gap-4">
<div className="flex-[3] min-w-0">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-[#3A4455]">
Recently saved
</p>
</div>
<div className="flex-[2] min-w-0 hidden sm:block">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-[#3A4455]">
Suggested for you
</p>
</div>
</div>
{/* Content row */}
<div className="flex gap-4 items-start">
<ul className="flex-[3] min-w-0 space-y-0.5">
{recents.map((doc) => {
const isLink = !!doc.url
return (
<li key={doc.id ?? doc.customId}>
<button
type="button"
onClick={() => onOpenDocument(doc)}
className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-[#0D121A]"
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-[#0D121A] group-hover:bg-[#131B28] transition-colors">
{isLink ? (
<ExternalLink className="size-3 text-[#3A4455]" />
) : (
<FileText className="size-3 text-[#3A4455]" />
)}
</div>
<span className="min-w-0 flex-1 truncate text-sm text-[#737373] group-hover:text-white transition-colors">
{doc.title?.trim() || "Untitled"}
</span>
<ArrowRight className="size-3.5 shrink-0 text-[#1E2736] group-hover:text-[#3A4455] transition-colors" />
</button>
</li>
)
})}
</ul>
<div className="flex-[2] min-w-0 hidden sm:block">
<RecommendedPluginsCard
profession={profession}
setProfession={setProfession}
connectedProviders={connectedProviders}
hasMcp={hasMcp}
onOpenPlugins={onOpenPlugins}
onOpenIntegrations={onOpenIntegrations}
/>
</div>
</div>
</>
) : (
/* No recents yet — show suggestions full-width */
<>
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-[#3A4455]">
Suggested for you
</p>
<div className="max-w-sm">
<RecommendedPluginsCard
profession={profession}
setProfession={setProfession}
connectedProviders={connectedProviders}
hasMcp={hasMcp}
onOpenPlugins={onOpenPlugins}
onOpenIntegrations={onOpenIntegrations}
/>
</div>
</>
)}
</motion.section>
</div>
</div>
)
}

View file

@ -96,14 +96,14 @@ export const FilePreview = memo(function FilePreview({
className="w-4 h-4"
/>
<p
className={cn(dmSansClassName(), "text-[10px] font-semibold")}
className={cn(dmSansClassName(), "text-[11px] font-semibold")}
style={{ color: color }}
>
{extension}
</p>
</div>
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
<p className="text-[11px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}

View file

@ -24,20 +24,20 @@ export function GoogleDocsPreview({
url={document.url}
className="w-4 h-4"
/>
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
{label}
</p>
</div>
{document.summary ? (
<p className="text-[10px] text-[#737373] line-clamp-4">
<p className="text-[11px] text-[#737373] line-clamp-4">
{document.summary}
</p>
) : document.content ? (
<p className="text-[10px] text-[#737373] line-clamp-4">
<p className="text-[11px] text-[#737373] line-clamp-4">
{document.content}
</p>
) : (
<p className="text-[10px] text-[#737373] line-clamp-4">
<p className="text-[11px] text-[#737373] line-clamp-4">
No summary available
</p>
)}

View file

@ -16,7 +16,7 @@ export function McpPreview({ document }: { document: DocumentWithMemories }) {
<p
className={cn(
dmSansClassName(),
"text-[12px] font-semibold flex items-center gap-1",
"text-[13px] font-semibold flex items-center gap-1",
)}
>
<ClaudeDesktopIcon className="size-3" />
@ -26,12 +26,12 @@ export function McpPreview({ document }: { document: DocumentWithMemories }) {
</div>
<div className="space-y-[6px]">
{document.title && (
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
{document.title}
</p>
)}
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
<p className="text-[11px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}

View file

@ -14,7 +14,7 @@ export function NotePreview({ document }: { document: DocumentWithMemories }) {
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
<div className="flex items-center gap-1">
<DocumentIcon type="note" className="w-4 h-4" />
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
Note
</p>
</div>
@ -23,14 +23,14 @@ export function NotePreview({ document }: { document: DocumentWithMemories }) {
<p
className={cn(
dmSansClassName(),
"text-[12px] font-semibold line-clamp-2 leading-[125%]",
"text-[13px] font-semibold line-clamp-2 leading-[125%]",
)}
>
{document.title}
</p>
)}
{document.summary && (
<p className="text-[10px] text-[#737373] line-clamp-4">
<p className="text-[11px] text-[#737373] line-clamp-4">
{document.summary}
</p>
)}

View file

@ -65,7 +65,7 @@ export function NotionPreview({
<span
className={cn(
dmSansClassName(),
"text-[10px] tracking-wide text-[#929292] uppercase",
"text-[11px] tracking-wide text-[#929292] uppercase",
)}
>
Notion
@ -85,7 +85,7 @@ export function NotionPreview({
<p
className={cn(
dmSansClassName(),
"text-[12px] font-semibold text-[#E5E5E5] line-clamp-2 leading-[140%]",
"text-[13px] font-semibold text-[#E5E5E5] line-clamp-2 leading-[140%]",
)}
>
{document.title}
@ -128,6 +128,8 @@ export function NotionPreview({
<svg
viewBox="0 0 10 10"
className="w-full h-full text-white"
aria-label="Checked"
role="img"
>
<path
d="M2.5 5L4.5 7L7.5 3.5"
@ -142,7 +144,7 @@ export function NotionPreview({
</div>
<p
className={cn(
"text-[10px] line-clamp-1 leading-[140%]",
"text-[11px] line-clamp-1 leading-[140%]",
block.checked
? "text-[#555] line-through"
: "text-[#737373]",
@ -158,7 +160,7 @@ export function NotionPreview({
return (
<div key={i} className="flex items-start gap-1.5">
<div className="mt-[5px] w-[4px] h-[4px] rounded-full bg-[#555] shrink-0" />
<p className="text-[10px] text-[#737373] line-clamp-1 leading-[140%]">
<p className="text-[11px] text-[#737373] line-clamp-1 leading-[140%]">
{block.text}
</p>
</div>
@ -168,7 +170,7 @@ export function NotionPreview({
return (
<p
key={i}
className="text-[10px] text-[#737373] line-clamp-1 leading-[140%]"
className="text-[11px] text-[#737373] line-clamp-1 leading-[140%]"
>
{block.text}
</p>
@ -176,7 +178,7 @@ export function NotionPreview({
})}
</div>
) : document.summary ? (
<p className="text-[10px] text-[#737373] line-clamp-4">
<p className="text-[11px] text-[#737373] line-clamp-4">
{document.summary}
</p>
) : null}
@ -189,7 +191,7 @@ export function NotionPreview({
<p
className={cn(
dmSansClassName(),
"text-[10px] font-semibold flex items-center gap-1",
"text-[11px] font-semibold flex items-center gap-1",
)}
style={{
background:
@ -203,7 +205,7 @@ export function NotionPreview({
{document.memoryEntries.length}
</p>
)}
<p className={cn(dmSansClassName(), "text-[10px] text-[#737373]")}>
<p className={cn(dmSansClassName(), "text-[11px] text-[#737373]")}>
{new Date(document.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",

View file

@ -63,7 +63,7 @@ function CustomTweetHeader({
<div className="flex gap-0.5 items-center">
<p
className={cn(
"font-semibold leading-tight overflow-hidden text-[#fafafa] text-[12px] truncate tracking-[-0.12px]",
"font-semibold leading-tight overflow-hidden text-[#fafafa] text-[13px] truncate tracking-[-0.12px]",
dmSansClassName(),
)}
>
@ -73,7 +73,7 @@ function CustomTweetHeader({
</div>
<p
className={cn(
"font-medium leading-tight overflow-hidden text-[#737373] text-[12px] truncate tracking-[-0.12px]",
"font-medium leading-tight overflow-hidden text-[#737373] text-[13px] truncate tracking-[-0.12px]",
dmSansClassName(),
)}
>

View file

@ -21,12 +21,12 @@ export const YoutubePreview = memo(function YoutubePreview({
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
{document.title && (
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
{document.title}
</p>
)}
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
<p className="text-[11px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}

View file

@ -128,9 +128,15 @@ function TextDocumentIcon({ className }: { className?: string }) {
function XIcon({ className }: { className?: string }) {
return (
<span className={cn("font-bold", className)} style={{ color: "#FFFFFF" }}>
𝕏
</span>
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={cn("text-white", className)}
>
<title>X (Twitter)</title>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.746l7.73-8.835L1.254 2.25H8.08l4.253 5.622 5.911-5.622zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
)
}

View file

@ -87,6 +87,7 @@ export function DocumentContent({
<TweetContent
url={document.url}
tweetMetadata={document.metadata?.sm_internal_twitter_metadata}
content={document.content}
/>
)

View file

@ -7,9 +7,14 @@ import { ExternalLinkIcon } from "lucide-react"
interface TweetContentProps {
url?: string | null
tweetMetadata?: unknown
content?: string | null
}
export function TweetContent({ url, tweetMetadata }: TweetContentProps) {
export function TweetContent({
url,
tweetMetadata,
content,
}: TweetContentProps) {
if (tweetMetadata) {
return (
<div className="flex-1 flex items-center justify-center w-full p-4 overflow-auto">
@ -18,6 +23,27 @@ export function TweetContent({ url, tweetMetadata }: TweetContentProps) {
)
}
if (content) {
return (
<div className="flex-1 flex flex-col w-full p-6 overflow-auto">
<pre className="whitespace-pre-wrap text-sm text-[#E5E5E5] font-sans leading-relaxed">
{content}
</pre>
{url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-blue-400 hover:underline mt-4"
>
View on X
<ExternalLinkIcon className="w-3.5 h-3.5" />
</a>
)}
</div>
)
}
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-gray-400">
<p>Tweet preview unavailable</p>

View file

@ -37,7 +37,8 @@ export function Summary({
<p className="text-[16px] font-semibold text-[#FAFAFA] line-clamp-1 leading-[125%]">
Summary
</p>
<div className="text-[#737373] text-[10px] leading-[150%]">
<div className="flex items-center gap-1 text-[#737373] opacity-50 text-[10px] leading-[150%]">
<SyncLogoIcon className="w-[10px] h-[10px]" />
powered by supermemory
</div>
</div>

View file

@ -35,7 +35,6 @@ interface DocumentsCommandPaletteProps {
open: boolean
onOpenChange: (open: boolean) => void
projectId: string
novaContainerTags?: string[]
onOpenDocument: (document: DocumentWithMemories) => void
onAddMemory?: () => void
onOpenIntegrations?: () => void
@ -46,7 +45,6 @@ export function DocumentsCommandPalette({
open,
onOpenChange,
projectId,
novaContainerTags,
onOpenDocument,
onAddMemory,
onOpenIntegrations,
@ -159,8 +157,7 @@ export function DocumentsCommandPalette({
body: {
q: search.trim(),
limit: 10,
containerTags:
novaContainerTags ?? (projectId ? [projectId] : undefined),
containerTags: projectId ? [projectId] : undefined,
includeSummary: true,
},
signal: controller.signal,
@ -178,7 +175,7 @@ export function DocumentsCommandPalette({
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [search, projectId, novaContainerTags])
}, [search, projectId])
// Build the item list
const hasQuery = search.trim().length > 0
@ -202,7 +199,7 @@ export function DocumentsCommandPalette({
// Reset selection on items change
useEffect(() => {
setSelectedIndex(0)
}, [search, searchResults.length])
}, [])
// Scroll selected into view
useEffect(() => {

View file

@ -20,7 +20,7 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
if (organizations === null) return
if (organizations.length > 0) return
if (pathname.startsWith("/onboarding")) return
router.replace("/onboarding/welcome?step=input")
router.replace("/onboarding")
}, [session, organizations, isRestoring, pathname, router])
return children

View file

@ -12,11 +12,7 @@ import { dmSansClassName } from "@/lib/fonts"
import { ShareModal } from "./share-modal"
import { shareParam } from "@/lib/search-params"
interface GraphLayoutViewProps {
isChatOpen: boolean
}
export const GraphLayoutView = memo<GraphLayoutViewProps>(({ isChatOpen }) => {
export const GraphLayoutView = memo(function GraphLayoutView() {
const { effectiveContainerTags } = useProject()
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
const [isShareModalOpen, setIsShareModalOpen] = useQueryState(
@ -34,14 +30,14 @@ export const GraphLayoutView = memo<GraphLayoutViewProps>(({ isChatOpen }) => {
}, [setIsShareModalOpen])
return (
<div className="relative w-full h-[calc(100vh-86px)]">
<div className="relative h-full min-h-0 w-full">
{/* Full-width graph */}
<div className="absolute inset-0">
<MemoryGraph
containerTags={effectiveContainerTags}
variant="consumer"
highlightDocumentIds={allHighlightDocumentIds}
highlightsVisible={isChatOpen}
highlightsVisible
maxNodes={undefined}
canvasRef={canvasRef}
/>

View file

@ -3,7 +3,6 @@
import { Logo } from "@ui/assets/Logo"
import { useAuth } from "@lib/auth-context"
import {
LayoutGridIcon,
Plus,
SearchIcon,
Settings,
@ -13,11 +12,12 @@ import {
ExternalLink,
MenuIcon,
MessageCircleIcon,
LifeBuoy,
LayoutGrid,
} from "lucide-react"
import { Button } from "@ui/components/button"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs"
import { GraphIcon, IntegrationsIcon } from "@/components/integration-icons"
import {
DropdownMenu,
@ -26,6 +26,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} 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"
@ -34,17 +35,16 @@ import { useIsMobile } from "@hooks/use-mobile"
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
import { UserProfileMenu } from "@/components/user-profile-menu"
import { FeedbackModal } from "./feedback-modal"
import { useViewMode, type ViewMode } from "@/lib/view-mode-context"
import { useViewMode } from "@/lib/view-mode-context"
import { useQueryState } from "nuqs"
import { feedbackParam } from "@/lib/search-params"
interface HeaderProps {
onAddMemory?: () => void
onOpenChat?: () => void
onOpenSearch?: () => void
}
export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
const { user, isRestoring } = useAuth()
const { selectedProjects, setSelectedProjects } = useProject()
const router = useRouter()
@ -65,21 +65,21 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
""
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
return (
<div className="flex p-3 md:p-4 justify-between items-center gap-2">
<div className="flex items-center justify-center gap-2 md:gap-4 z-10! min-w-0">
<div className="relative z-10 flex shrink-0 items-center justify-between gap-1.5 p-2.5 md:gap-2 md:p-3">
<div className="z-10! flex min-w-0 shrink items-center justify-center gap-1.5 md:gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center rounded-lg px-2 py-1.5 -ml-2 cursor-pointer hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-colors shrink-0"
className="-ml-2 flex shrink-0 cursor-pointer items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
>
<Logo className="h-7" />
<Logo className="h-6 md:h-7" />
{!isMobile && userName && (
<div className="flex flex-col items-start justify-center ml-2">
<p className="text-[#8B8B8B] text-[11px] leading-tight">
<div className="ml-1.5 flex flex-col items-start justify-center sm:ml-2">
<p className="text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
{userName}
</p>
<p className="text-white font-bold text-xl leading-none -mt-1">
<p className="-mt-0.5 text-base leading-none font-medium text-white/90 sm:text-lg">
supermemory
</p>
</div>
@ -129,7 +129,6 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="self-stretch w-px bg-[#FFFFFF33] hidden md:block" />
{!isMobile && (
<SpaceSelector
selectedProjects={selectedProjects}
@ -140,47 +139,94 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
)}
</div>
{!isMobile && (
<Tabs
value={viewMode === "list" ? "grid" : viewMode}
onValueChange={(v) =>
setViewMode(v === "grid" ? "list" : (v as ViewMode))
}
>
<TabsList className="rounded-full border border-[#161F2C] h-11! z-10!">
<TabsTrigger
value="grid"
className={cn(
"rounded-full data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-4 py-4 cursor-pointer",
dmSansClassName(),
)}
>
<LayoutGridIcon className="size-4" />
Grid
</TabsTrigger>
<TabsTrigger
value="graph"
className={cn(
"rounded-full dark:data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-4 py-4 cursor-pointer",
dmSansClassName(),
)}
>
<GraphIcon className="size-4" />
Graph
</TabsTrigger>
<TabsTrigger
value="integrations"
className={cn(
"rounded-full dark:data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-4 py-4 cursor-pointer",
dmSansClassName(),
)}
>
<IntegrationsIcon className="size-4" />
Integrations
</TabsTrigger>
</TabsList>
</Tabs>
<div className="z-10! flex min-w-0 max-w-full flex-1 items-center justify-center gap-1.5 overflow-hidden px-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Home"
aria-current={viewMode === "dashboard" ? "page" : undefined}
onClick={() => void setViewMode("dashboard")}
className={cn(
"flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
viewMode === "dashboard"
? "border-[#2261CA33] bg-[#00173C] text-white"
: "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5",
dmSansClassName(),
)}
>
<Home className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Home
</TooltipContent>
</Tooltip>
<div
role="tablist"
aria-label="Content"
aria-orientation="horizontal"
className="text-muted-foreground z-10! inline-flex h-10 w-fit min-w-0 max-w-full items-center justify-center gap-0.5 overflow-x-auto rounded-full border border-[#161F2C] bg-muted p-1 [scrollbar-width:thin]"
>
{(
[
{
mode: "integrations" as const,
label: "Integrations",
icon: IntegrationsIcon,
},
{ mode: "graph" as const, label: "Graph", icon: GraphIcon },
{
mode: "list" as const,
label: "Memories",
icon: LayoutGrid,
},
] as const
).map(({ mode, label, icon: Icon }) => (
<button
key={mode}
type="button"
role="tab"
aria-selected={viewMode === mode}
onClick={() => void setViewMode(mode)}
className={cn(
"inline-flex h-[calc(100%-1px)] min-h-0 cursor-pointer 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",
viewMode === mode
? "border-[#2261CA33] bg-[#00173C] text-white"
: "text-foreground hover:bg-white/5",
dmSansClassName(),
)}
>
<Icon className="size-3.5 shrink-0 sm:size-4" />
{label}
</button>
))}
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Chat"
aria-current={viewMode === "chat" ? "page" : undefined}
onClick={() => void setViewMode("chat")}
className={cn(
"flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
viewMode === "chat"
? "border-[#2261CA33] bg-[#00173C] text-white"
: "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5",
dmSansClassName(),
)}
>
<MessageCircleIcon className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Chat
</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex items-center gap-2 z-10!">
<div className="z-10! flex shrink-0 items-center gap-1.5">
{isMobile ? (
<>
<SpaceSelector
@ -217,6 +263,13 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
<Plus className="h-4 w-4 text-[#737373]" />
Add memory
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void setViewMode("dashboard")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<Home className="h-4 w-4 text-[#737373]" />
Home
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setViewMode("integrations")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
@ -225,7 +278,28 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
Integrations
</DropdownMenuItem>
<DropdownMenuItem
onClick={onOpenChat}
onClick={() => void setViewMode("graph")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<GraphIcon className="h-4 w-4 text-[#737373]" />
Graph
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onOpenSearch?.()}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<SearchIcon className="h-4 w-4 text-[#737373]" />
Search
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void setViewMode("list")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<LayoutGrid className="h-4 w-4 text-[#737373]" />
Memories
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void setViewMode("chat")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<MessageCircleIcon className="h-4 w-4 text-[#737373]" />
@ -236,7 +310,7 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
onClick={handleFeedback}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<MessageCircleIcon className="h-4 w-4 text-[#737373]" />
<LifeBuoy className="h-4 w-4 text-[#737373]" />
Feedback
</DropdownMenuItem>
<DropdownMenuItem
@ -251,62 +325,48 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
</>
) : (
<>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
onClick={onAddMemory}
>
<div className="flex items-center gap-2">
<Plus className="size-4" />
Add memory
</div>
<span
className={cn(
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center",
dmSansClassName(),
)}
>
C
</span>
</Button>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
onClick={onOpenSearch}
>
<SearchIcon className="size-4" />
<span className="bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm text-[10px] flex items-center justify-center gap-0.5 px-1">
<svg
className="size-[7.5px]"
viewBox="0 0 9 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="headers"
className={cn(
"rounded-full! h-9! min-h-9 shrink-0",
"max-lg:w-9 max-lg:min-w-9 max-lg:justify-center max-lg:gap-0 max-lg:px-0",
"lg:min-w-0 lg:gap-1.5 lg:px-3 lg:font-medium",
dmSansClassName(),
)}
onClick={onAddMemory}
aria-label="Add memory"
>
<title>Command Key</title>
<path
d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
stroke="#737373"
strokeWidth="0.833333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className={cn(dmSansClassName())}>K</span>
</span>
</Button>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
onClick={handleFeedback}
>
<div className="flex items-center gap-2">
<MessageCircleIcon className="size-4" />
Feedback
</div>
</Button>
<Plus className="size-3.5 shrink-0 lg:size-4" />
<span className="max-lg:sr-only">Add memory</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Add memory (C)
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="headers"
className={cn(
"size-9! min-h-9 min-w-9 shrink-0 rounded-full! border-[#161F2C]/90 px-0! text-muted-foreground hover:text-foreground",
dmSansClassName(),
)}
onClick={onOpenSearch}
aria-label="Search"
>
<SearchIcon className="size-4 shrink-0" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Search (K)
</TooltipContent>
</Tooltip>
</>
)}
<UserProfileMenu />
<UserProfileMenu onOpenFeedback={handleFeedback} />
</div>
<FeedbackModal
isOpen={feedbackOpen}

View file

@ -7,7 +7,6 @@ import {
ChevronLeft,
ChevronRight,
Info,
Loader2,
MessageSquare,
Link2,
} from "lucide-react"
@ -103,14 +102,24 @@ export function HighlightsCard({
return (
<div
className={cn(
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3 min-h-[180px] items-center justify-center",
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3",
dmSansClassName(),
)}
>
<Loader2 className="size-5 animate-spin text-[#4BA0FA]" />
<span className="text-[10px] text-[#737373]">
Loading highlights...
</span>
<div className="flex items-center gap-1">
<div className="size-[14px] rounded-full bg-[#1A2030] animate-pulse" />
<div className="h-2 w-20 rounded bg-[#1A2030] animate-pulse" />
</div>
<div className="flex flex-col gap-1.5">
<div className="h-2.5 w-2/5 rounded bg-[#1A2030] animate-pulse" />
<div className="h-2 w-full rounded bg-[#1A2030] animate-pulse" />
<div className="h-2 w-[85%] rounded bg-[#1A2030] animate-pulse" />
<div className="h-2 w-[65%] rounded bg-[#1A2030] animate-pulse" />
</div>
<div className="flex items-center gap-2">
<div className="h-[26px] w-14 rounded-lg bg-[#1A2030] animate-pulse" />
<div className="h-[26px] w-16 rounded-lg bg-[#1A2030] animate-pulse" />
</div>
</div>
)
}

View file

@ -25,6 +25,7 @@ import {
type IntegrationParamValue,
} from "@/lib/search-params"
import Image from "next/image"
import { IntegrationGridCard } from "@/components/integrations/integration-grid-card"
type CardId =
| "mcp"
@ -238,39 +239,14 @@ export function IntegrationsView() {
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{cards.map((card) => (
<button
<IntegrationGridCard
key={card.id}
type="button"
title={card.title}
description={card.description}
icon={card.icon}
pro={card.pro}
onClick={() => setSelectedCard(card.id)}
className={cn(
"bg-[#080B0F] relative rounded-xl p-4 pt-14",
"border border-[#0D121A]",
"hover:border-[#3374FF]/50",
"transition-all duration-300 cursor-pointer text-left w-full",
"hover:bg-[url('/onboarding/bg-gradient-1.png')] hover:bg-[length:200%_auto] hover:bg-[center_top_1rem] hover:bg-no-repeat",
"group",
)}
>
{card.pro && (
<span className="absolute top-3 left-3 bg-[#4BA0FA] text-[#00171A] text-[10px] font-bold tracking-[0.3px] px-1.5 py-0.5 rounded-[3px]">
PRO
</span>
)}
<div className="absolute top-2 right-2 opacity-60 group-hover:opacity-100 transition-opacity">
{card.icon}
</div>
<div className="flex-1">
<h3 className="text-white text-sm font-medium">{card.title}</h3>
<p
className={cn(
"text-[#8B8B8B] text-xs leading-relaxed mt-0.5",
dmSansClassName(),
)}
>
{card.description}
</p>
</div>
</button>
/>
))}
</div>
</div>

View file

@ -0,0 +1,54 @@
"use client"
import type { ReactNode } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
export function IntegrationGridCard({
title,
description,
icon,
pro,
onClick,
}: {
title: string
description: string
icon: ReactNode
pro?: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"bg-[#080B0F] relative rounded-xl p-4 pt-14",
"border border-[#0D121A]",
"hover:border-[#3374FF]/50",
"transition-all duration-300 cursor-pointer text-left w-full",
"hover:bg-[url('/onboarding/bg-gradient-1.png')] hover:bg-[length:200%_auto] hover:bg-[center_top_1rem] hover:bg-no-repeat",
"group",
)}
>
{pro ? (
<span className="absolute top-3 left-3 bg-[#4BA0FA] text-[#00171A] text-[10px] font-bold tracking-[0.3px] px-1.5 py-0.5 rounded-[3px]">
PRO
</span>
) : null}
<div className="absolute top-2 right-2 opacity-60 group-hover:opacity-100 transition-opacity">
{icon}
</div>
<div className="flex-1">
<h3 className="text-white text-sm font-medium">{title}</h3>
<p
className={cn(
"text-[#8B8B8B] text-xs leading-relaxed mt-0.5",
dmSansClassName(),
)}
>
{description}
</p>
</div>
</button>
)
}

View file

@ -26,8 +26,7 @@ import { McpPreview } from "./document-cards/mcp-preview"
import { NotionPreview } from "./document-cards/notion-preview"
import { getFaviconUrl } from "@/lib/url-helpers"
import { QuickNoteCard } from "./quick-note-card"
import { HighlightsCard, type HighlightItem } from "./highlights-card"
import { GraphCard } from "./memory-graph"
import type { HighlightItem } from "./highlights-card"
import { Button } from "@ui/components/button"
import {
categoriesParam,
@ -44,7 +43,16 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@ui/components/alert-dialog"
import { CheckIcon, Loader, Trash2Icon, XIcon } from "lucide-react"
import {
AlignLeft,
CheckIcon,
LayoutGrid,
Loader,
Trash2Icon,
XIcon,
} from "lucide-react"
import { useProcessingDocuments } from "@/hooks/use-processing-documents"
import { TimelineView } from "./timeline-view"
// Document category type
type DocumentCategory =
@ -171,7 +179,9 @@ function MemoriesGridLoading() {
}
// Discriminated union for masonry items
type MasonryItem = { type: "document"; id: string; data: DocumentWithMemories }
type MasonryItem =
| { type: "document"; id: string; data: DocumentWithMemories }
| { type: "quick-note"; id: "quick-note" }
interface QuickNoteProps {
onSave: (content: string) => void
@ -226,8 +236,18 @@ export function MemoriesGrid({
emptyStateProps,
}: MemoriesGridProps) {
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
const [localViewMode, setLocalViewMode] = useState<"grid" | "timeline">(
() => {
if (typeof window === "undefined") return "grid"
return (
(localStorage.getItem("memories-view-mode") as "grid" | "timeline") ??
"grid"
)
},
)
const { user, isSessionPending } = useAuth()
const { effectiveContainerTags } = useProject()
const processingStatusMap = useProcessingDocuments()
const isMobile = useIsMobile()
const [selectedCategories, setSelectedCategories] = useQueryState(
"categories",
@ -309,6 +329,11 @@ export function MemoriesGrid({
enabled: !!user,
})
const handleSetViewMode = useCallback((mode: "grid" | "timeline") => {
setLocalViewMode(mode)
localStorage.setItem("memories-view-mode", mode)
}, [])
const handleCategoryToggle = useCallback(
(category: DocumentCategory) => {
setSelectedCategories((prev) => {
@ -334,23 +359,27 @@ export function MemoriesGrid({
}, [data])
const hasQuickNote = !!quickNoteProps
const hasHighlights = !!highlightsProps
const _hasHighlights = !!highlightsProps
const masonryItems: MasonryItem[] = useMemo(() => {
const items: MasonryItem[] = []
if (!isMobile && hasQuickNote) {
items.push({ type: "quick-note", id: "quick-note" })
}
for (const doc of documents) {
items.push({ type: "document", id: doc.id, data: doc })
}
return items
}, [documents])
}, [documents, isMobile, hasQuickNote])
// Stable key for Masonry based on document IDs, not item values
const masonryKey = useMemo(() => {
const docIds = documents.map((d) => d.id).join(",")
return `masonry-${documents.length}-${docIds}-${isChatOpen}`
}, [documents, isChatOpen])
return `masonry-${documents.length}-${docIds}-${isChatOpen}-${hasQuickNote}`
}, [documents, isChatOpen, hasQuickNote])
const isLoadingMore = isFetchingNextPage
@ -402,6 +431,26 @@ export function MemoriesGrid({
onBulkDelete?.()
}, [onBulkDelete])
// All mutable values the render function needs — kept in a ref so the
// function identity never changes (masonic uses render as a React component
// type, so a new reference unmounts every item and kills textarea focus).
const renderRef = useRef({
quickNoteProps,
handleCardClick,
isSelectionMode,
selectedDocumentIds,
onToggleSelection,
processingStatusMap,
})
renderRef.current = {
quickNoteProps,
handleCardClick,
isSelectionMode,
selectedDocumentIds,
onToggleSelection,
processingStatusMap,
}
const renderMasonryItem = useCallback(
({
index,
@ -412,6 +461,16 @@ export function MemoriesGrid({
data: MasonryItem
width: number
}) => {
const r = renderRef.current
if (data.type === "quick-note") {
return r.quickNoteProps ? (
<div style={{ width }} className="p-2">
<QuickNoteCard {...r.quickNoteProps} />
</div>
) : null
}
if (data.type === "document") {
const doc = data.data
return (
@ -420,14 +479,17 @@ export function MemoriesGrid({
index={index}
data={doc}
width={width}
onClick={handleCardClick}
isSelectionMode={isSelectionMode}
isSelected={doc.id ? selectedDocumentIds.has(doc.id) : false}
onClick={r.handleCardClick}
isSelectionMode={r.isSelectionMode}
isSelected={doc.id ? r.selectedDocumentIds.has(doc.id) : false}
onToggleSelection={
doc.id && onToggleSelection
? () => onToggleSelection(doc.id as string)
doc.id && r.onToggleSelection
? () => r.onToggleSelection?.(doc.id as string)
: undefined
}
processingStatus={
doc.id ? r.processingStatusMap.get(doc.id) : undefined
}
/>
</ErrorBoundary>
)
@ -435,7 +497,8 @@ export function MemoriesGrid({
return null
},
[handleCardClick, isSelectionMode, selectedDocumentIds, onToggleSelection],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
)
if (isSessionPending) {
@ -496,6 +559,35 @@ export function MemoriesGrid({
))}
</div>
<div className="flex items-center gap-2 shrink-0">
{/* View mode toggle */}
<div className="flex items-center gap-1">
<button
type="button"
aria-label="Grid view"
className={cn(
"w-8 h-8 flex items-center justify-center rounded-full border transition-colors cursor-pointer",
localViewMode === "grid"
? "bg-[#00173C] border-[#2261CA33]"
: "bg-[#0D121A] border-[#161F2C] hover:bg-[#00173C]",
)}
onClick={() => handleSetViewMode("grid")}
>
<LayoutGrid className="w-4 h-4 text-[#737373]" />
</button>
<button
type="button"
aria-label="Timeline view"
className={cn(
"w-8 h-8 flex items-center justify-center rounded-full border transition-colors cursor-pointer",
localViewMode === "timeline"
? "bg-[#00173C] border-[#2261CA33]"
: "bg-[#0D121A] border-[#161F2C] hover:bg-[#00173C]",
)}
onClick={() => handleSetViewMode("timeline")}
>
<AlignLeft className="w-4 h-4 text-[#737373]" />
</button>
</div>
{isSelectionMode && (
<>
<button
@ -620,41 +712,30 @@ export function MemoriesGrid({
</div>
) : (
<div className="h-full overflow-auto scrollbar-thin">
{!isMobile && (hasQuickNote || hasHighlights) && (
<div className="flex gap-2 mb-2 px-2">
{hasQuickNote && quickNoteProps && (
<div className="w-[216px] shrink-0">
<QuickNoteCard {...quickNoteProps} />
</div>
)}
{hasHighlights && highlightsProps && (
<div className="flex-1 min-w-0">
<HighlightsCard {...highlightsProps} />
</div>
)}
<div className="w-[216px] shrink-0">
<GraphCard
containerTags={effectiveContainerTags}
width={200}
height={220}
/>
</div>
</div>
{localViewMode === "timeline" ? (
<TimelineView
documents={documents}
onOpenDocument={onOpenDocument}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={loadMoreDocuments}
/>
) : (
<Masonry
key={masonryKey}
items={masonryItems}
render={renderMasonryItem}
columnGutter={0}
rowGutter={0}
columnWidth={260}
maxColumnCount={isMobile ? 1 : undefined}
itemHeightEstimate={200}
overscanBy={3}
onRender={maybeLoadMore}
/>
)}
<Masonry
key={masonryKey}
items={masonryItems}
render={renderMasonryItem}
columnGutter={0}
rowGutter={0}
columnWidth={216}
maxColumnCount={isMobile ? 1 : undefined}
itemHeightEstimate={200}
overscanBy={3}
onRender={maybeLoadMore}
/>
{isLoadingMore && (
{isLoadingMore && localViewMode === "grid" && (
<div className="py-10 flex items-center justify-center">
<Loader className="size-10 animate-spin text-sky-400" />
</div>
@ -676,7 +757,7 @@ function DocumentUrlDisplay({ url }: { url: string }) {
<p
className={cn(
dmSansClassName(),
"text-[10px] text-[#737373] line-clamp-1",
"text-[11px] text-[#737373] line-clamp-1",
)}
>
{isLoading ? "YouTube" : channelName || "YouTube"}
@ -688,7 +769,7 @@ function DocumentUrlDisplay({ url }: { url: string }) {
<p
className={cn(
dmSansClassName(),
"text-[10px] text-[#737373] line-clamp-1",
"text-[11px] text-[#737373] line-clamp-1",
)}
>
{getAbsoluteUrl(url)}
@ -701,6 +782,74 @@ function isTemporaryId(id: string | null | undefined): boolean {
return id.startsWith("temp-") || id.startsWith("temp-file-")
}
const PROCESSING_WORDS = [
"Reading",
"Absorbing",
"Scanning",
"Thinking",
"Connecting",
"Pondering",
"Synthesizing",
"Reflecting",
"Understanding",
"Organizing",
"Memorizing",
"Filing",
"Saving",
"Learning",
"Cataloguing",
"Weaving",
]
function ProcessingBadge() {
const [wordIndex, setWordIndex] = useState(() =>
Math.floor(Math.random() * PROCESSING_WORDS.length),
)
useEffect(() => {
const id = setInterval(() => {
setWordIndex((i) => (i + 1) % PROCESSING_WORDS.length)
}, 1800)
return () => clearInterval(id)
}, [])
return (
<div className="flex items-center gap-1">
<span className="relative flex h-1.5 w-1.5 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-sky-400" />
</span>
<span
className={cn(
dmSansClassName(),
"text-[10px] text-sky-400 font-medium",
)}
>
{PROCESSING_WORDS[wordIndex]}
</span>
</div>
)
}
function DoneBadge() {
return (
<div className="flex items-center gap-1">
<CheckIcon
className="w-2.5 h-2.5 text-emerald-400 shrink-0"
strokeWidth={3}
/>
<span
className={cn(
dmSansClassName(),
"text-[10px] text-emerald-400 font-medium",
)}
>
Done
</span>
</div>
)
}
const DocumentCard = memo(
({
index: _index,
@ -710,6 +859,7 @@ const DocumentCard = memo(
isSelectionMode = false,
isSelected = false,
onToggleSelection,
processingStatus,
}: {
index: number
data: DocumentWithMemories
@ -718,12 +868,26 @@ const DocumentCard = memo(
isSelectionMode?: boolean
isSelected?: boolean
onToggleSelection?: () => void
processingStatus?: string
}) => {
const canSelect =
!isTemporaryId(document.id) && !isTemporaryId(document.customId)
const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 })
const cardRef = useRef<HTMLButtonElement>(null)
const [ogData, setOgData] = useState<OgData | null>(null)
const [showDone, setShowDone] = useState(false)
const prevStatusRef = useRef<string | undefined>(processingStatus)
useEffect(() => {
const prev = prevStatusRef.current
prevStatusRef.current = processingStatus
// Show the "done" checkmark briefly when the card leaves the processing map
if (prev && !processingStatus) {
setShowDone(true)
const id = setTimeout(() => setShowDone(false), 2000)
return () => clearTimeout(id)
}
}, [processingStatus])
const ogImage = (document as DocumentWithMemories & { ogImage?: string })
.ogImage
@ -847,15 +1011,16 @@ const DocumentCard = memo(
) && (
<div className="pb-[10px] space-y-1">
{document.url &&
!document.url.includes("x.com") &&
!document.url.includes("twitter.com") &&
!document.url.includes("files.supermemory.ai") && (
!document.url.includes("files.supermemory.ai") &&
(document.title ||
(!document.url.includes("x.com") &&
!document.url.includes("twitter.com"))) && (
<div className="px-3">
<div className="flex justify-between items-center gap-2">
<p
className={cn(
dmSansClassName(),
"text-[12px] text-[#E5E5E5] line-clamp-1 font-semibold",
"text-[13px] text-[#E5E5E5] line-clamp-1 font-semibold",
)}
>
{document.title || ogData?.title || "Untitled Document"}
@ -878,16 +1043,22 @@ const DocumentCard = memo(
<div
className={cn(
"flex items-center px-3",
document.memoryEntries.length > 0
processingStatus ||
showDone ||
document.memoryEntries.length > 0
? "justify-between"
: "justify-end",
)}
>
{document.memoryEntries.length > 0 && (
{processingStatus ? (
<ProcessingBadge />
) : showDone ? (
<DoneBadge />
) : document.memoryEntries.length > 0 ? (
<p
className={cn(
dmSansClassName(),
"text-[10px] text-[#369BFD] font-semibold flex items-center gap-1",
"text-[11px] text-[#369BFD] font-semibold flex items-center gap-1",
)}
style={{
background:
@ -900,11 +1071,11 @@ const DocumentCard = memo(
<SyncLogoIcon className="w-[12.33px] h-[10px]" />
{document.memoryEntries.length}
</p>
)}
) : null}
<p
className={cn(
dmSansClassName(),
"text-[10px] text-[#737373] line-clamp-1",
"text-[11px] text-[#737373] line-clamp-1",
)}
>
{new Date(document.createdAt).toLocaleDateString("en-US", {
@ -939,10 +1110,7 @@ function ContentPreview({
return <GoogleDocsPreview document={document} />
}
if (
document.url?.includes("x.com/") &&
document.metadata?.sm_internal_twitter_metadata
) {
if (document.metadata?.sm_internal_twitter_metadata) {
return (
<TweetPreview
data={
@ -952,6 +1120,13 @@ function ContentPreview({
)
}
if (
document.url?.includes("x.com/") ||
document.url?.includes("twitter.com/")
) {
return <NotePreview document={document} />
}
if (document.source === "mcp") {
return <McpPreview document={document} />
}

View file

@ -23,7 +23,7 @@ function seededRandom(seed: number) {
}
}
function StaticGraphPreview({
export function StaticGraphPreview({
documentCount,
memoryCount,
width,
@ -70,10 +70,10 @@ function StaticGraphPreview({
let b = Math.floor(rand() * nodes.length)
if (b === a) b = (a + 1) % nodes.length
result.push({
x1: nodes[a]!.x,
y1: nodes[a]!.y,
x2: nodes[b]!.x,
y2: nodes[b]!.y,
x1: nodes[a]?.x,
y1: nodes[a]?.y,
x2: nodes[b]?.x,
y2: nodes[b]?.y,
})
}
return result

View file

@ -171,14 +171,7 @@ export function IntegrationsStep() {
})}
</div>
<div className="flex justify-between w-full max-w-4xl">
<Button
variant="link"
className="text-white hover:text-gray-300 hover:no-underline cursor-pointer"
onClick={() => router.push("/onboarding/setup?step=relatable")}
>
Back
</Button>
<div className="flex justify-end w-full max-w-4xl">
<Button
variant="link"
className="text-white hover:text-gray-300 hover:no-underline cursor-pointer"

View file

@ -1,164 +0,0 @@
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { Button } from "@ui/components/button"
import { useRouter } from "next/navigation"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { analytics } from "@/lib/analytics"
const relatableOptions = [
{
emoji: "😔",
text: "I always forget what I save in my twitter bookmarks",
},
{
emoji: "😭",
text: "Going through e-books manually is so tedious",
},
{
emoji: "🥲",
text: "I always have to feed every AI app with my data",
},
{
emoji: "😵‍💫",
text: "Referring meeting notes makes my AI chat hallucinate",
},
{
emoji: "🫤",
text: "I save nothing on my browser, it's just useless",
},
]
export function RelatableQuestion() {
const router = useRouter()
const [selectedOptions, setSelectedOptions] = useState<number[]>([])
const handleContinueOrSkip = () => {
const selectedTexts = selectedOptions.map(
(idx) => relatableOptions[idx]?.text || "",
)
analytics.onboardingRelatableSelected({ options: selectedTexts })
router.push("/onboarding/setup?step=integrations")
}
return (
<motion.div
className="flex flex-col items-center justify-center h-full"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.6 }}
>
<motion.h1
className="text-white text-[32px] font-medium mb-6 text-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
Which of these sound most relatable?
</motion.h1>
<div
className={cn(
"flex flex-wrap justify-center gap-4 max-w-3xl",
dmSansClassName(),
)}
>
{relatableOptions.map((option, index) => (
<div
key={option.text}
className={cn(
"rounded-lg max-w-[140px] min-h-[159px] transition-all duration-300",
selectedOptions.includes(index)
? "p-px bg-linear-to-b from-[#3374FF] to-[#1A63FF00]"
: "p-0 border border-[#0D121A] hover:border-[#4C608B66]",
)}
>
<button
className={`
group relative w-full h-full rounded-lg p-2 cursor-pointer transition-all duration-300 overflow-hidden
bg-[#080B0F] hover:bg-no-repeat
`}
onClick={() => {
setSelectedOptions((prev) =>
prev.includes(index)
? prev.filter((i) => i !== index)
: [...prev, index],
)
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
setSelectedOptions((prev) =>
prev.includes(index)
? prev.filter((i) => i !== index)
: [...prev, index],
)
}
}}
type="button"
>
<AnimatePresence>
{selectedOptions.includes(index) && (
<motion.div
className="absolute inset-0 bg-[url('/onboarding/bg-gradient-1.png')] bg-size-[550%_auto] bg-top bg-no-repeat"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
)}
</AnimatePresence>
<div className="relative flex flex-col items-start justify-between h-full">
<span
className={`text-2xl ${
selectedOptions.includes(index)
? "opacity-100"
: "opacity-70 group-hover:opacity-100"
}`}
>
{option.emoji}
</span>
<p
className={`text-white text-sm leading-[135%] align-bottom text-left transition-opacity duration-300 ${
selectedOptions.includes(index)
? "opacity-100"
: "opacity-50 group-hover:opacity-100"
}`}
>
{option.text}
</p>
</div>
</button>
</div>
))}
</div>
<div className="flex gap-4 my-8">
<div key={selectedOptions.length === 0 ? "skip" : "continue"}>
<Button
className={cn(
"font-medium text-white hover:no-underline cursor-pointer",
selectedOptions.length !== 0 ? "rounded-xl" : "",
)}
variant={selectedOptions.length !== 0 ? "onboarding" : "link"}
size="lg"
onClick={handleContinueOrSkip}
style={
selectedOptions.length !== 0
? {
background:
"linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
}
: undefined
}
>
{selectedOptions.length === 0
? "Skip for now →"
: "Remember this →"}
</Button>
</div>
</div>
</motion.div>
)
}

View file

@ -42,11 +42,11 @@ export function OnboardingContentStep({
const router = useRouter()
const handleContinue = () => {
router.push("/onboarding/welcome?step=features")
router.push("/old/onboarding/welcome?step=features")
}
const handleAddMemories = () => {
router.push("/onboarding/welcome?step=memories")
router.push("/old/onboarding/welcome?step=memories")
}
const isContinue = currentView === "continue"

View file

@ -23,28 +23,10 @@ export function InputStep({
isSubmitting && "pointer-events-none",
)}
style={{ gap: "24px" }}
initial={{
opacity: 0,
y: 10,
}}
animate={{
opacity: 1,
y: 0,
}}
exit={{
opacity: 0,
y: -10,
transition: {
duration: 0.5,
ease: "easeOut",
bounce: 0,
},
}}
transition={{
duration: 0.8,
ease: "easeOut",
delay: 1,
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.3, ease: "easeOut" } }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.2 }}
layout
>
<h2 className="text-white text-[32px] font-medium leading-[110%]">

View file

@ -256,7 +256,7 @@ export function ProfileStep({ onSubmit }: ProfileStepProps) {
description_length: description.trim().length,
})
onSubmit(formData)
router.push("/onboarding/setup?step=relatable")
router.push("/old/onboarding/setup?step=integrations")
}}
>
{isSubmitting ? "Fetching..." : "Remember this →"}

View file

@ -4,15 +4,7 @@ import { useState, useMemo } from "react"
import { cn } from "@lib/utils"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import {
ChevronsLeftRight,
Plus,
Trash2,
XIcon,
Loader2,
Globe,
Layers,
} from "lucide-react"
import { ChevronDown, Plus, Trash2, XIcon, Loader2, Layers } from "lucide-react"
import type { ContainerTagListType } from "@lib/types"
import { AddSpaceModal } from "./add-space-modal"
import { SelectSpacesModal } from "./select-spaces-modal"
@ -57,8 +49,13 @@ export interface SpaceSelectorProps {
}
const triggerVariants = {
default: "px-3 py-2 rounded-md hover:bg-white/5",
insideOut: "px-3 py-2 rounded-full bg-[#0D121A] shadow-inside-out",
default:
"h-10 min-h-10 shrink-0 rounded-full border border-[#161F2C] bg-muted px-3 gap-2 " +
"hover:bg-white/5 " +
"data-[state=open]:border-[#2261CA33] data-[state=open]:bg-[#00173C]/35 " +
"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]",
}
export function SpaceSelector({
@ -90,15 +87,9 @@ export function SpaceSelector({
const { deleteProjectMutation } = useProjectMutations()
const { allProjects, novaProjects, isLoading } = useContainerTags()
const isNovaSpaces = selectedProjects.length === 0
const { allProjects, isLoading } = useContainerTags()
const displayInfo = useMemo(() => {
if (isNovaSpaces) {
return { name: "Nova Spaces", emoji: null, isMultiple: false }
}
if (selectedProjects.length === 1) {
const containerTag = selectedProjects[0]
if (containerTag === DEFAULT_PROJECT_ID) {
@ -114,18 +105,17 @@ export function SpaceSelector({
}
}
return {
name: `${selectedProjects.length} spaces`,
emoji: null,
isMultiple: true,
if (selectedProjects.length > 1) {
return {
name: `${selectedProjects.length} spaces`,
emoji: null,
isMultiple: true,
}
}
}, [allProjects, selectedProjects, isNovaSpaces])
const handleSelectNovaSpaces = () => {
analytics.spaceSwitched({ space_id: "nova_spaces" })
onValueChange([]) // Empty array = "Nova Spaces" (all nova)
setIsOpen(false)
}
// Nothing selected — default to "My Space"
return { name: "My Space", emoji: "📁", isMultiple: false }
}, [allProjects, selectedProjects])
const handleSelectSingleSpace = (containerTag: string) => {
analytics.spaceSwitched({ space_id: containerTag })
@ -204,13 +194,13 @@ export function SpaceSelector({
}
const availableTargetProjects = useMemo(() => {
const filtered = novaProjects.filter(
const filtered = allProjects.filter(
(p: ContainerTagListType) =>
p.id !== deleteDialog.project?.id &&
p.containerTag !== deleteDialog.project?.containerTag,
)
const defaultProject = novaProjects.find(
const defaultProject = allProjects.find(
(p: ContainerTagListType) => p.containerTag === DEFAULT_PROJECT_ID,
)
@ -227,7 +217,7 @@ export function SpaceSelector({
}
return filtered
}, [novaProjects, deleteDialog.project])
}, [allProjects, deleteDialog.project])
return (
<>
@ -235,29 +225,60 @@ export function SpaceSelector({
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={
isLoading
? "Loading spaces"
: `Space: ${displayInfo.name}. Open menu to switch.`
}
className={cn(
"flex items-center gap-2 cursor-pointer transition-colors focus:outline-none focus-visible:outline-none",
"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,
)}
>
{isNovaSpaces ? (
<Globe className="size-4 text-white" />
) : displayInfo.isMultiple ? (
<Layers className="size-4 text-white" />
{displayInfo.isMultiple ? (
<Layers
className={cn(
"shrink-0",
variant === "insideOut" ? "text-white" : "text-[#737373]",
compact ? "size-3.5" : "size-4",
)}
aria-hidden
/>
) : (
<span className="text-sm font-bold tracking-[-0.98px]">
<span
className="shrink-0 text-sm font-bold tracking-[-0.98px]"
aria-hidden
>
{displayInfo.emoji}
</span>
)}
{!compact && (
<span className="text-sm font-medium text-white">
{isLoading ? "..." : displayInfo.name}
<span
className={cn(
"min-w-0 truncate text-sm font-medium text-white",
"max-w-[10rem] md:max-w-[15rem]",
)}
>
{isLoading ? "…" : displayInfo.name}
</span>
)}
{compact && (
<span className="sr-only">
{isLoading ? "Loading" : displayInfo.name}
</span>
)}
{showChevron && (
<ChevronsLeftRight className="size-4 rotate-90 text-white/70" />
<ChevronDown
className={cn(
"shrink-0 opacity-90",
variant === "insideOut" ? "text-white/80" : "text-[#737373]",
compact ? "size-3.5" : "size-4",
)}
aria-hidden
/>
)}
</button>
</DropdownMenuTrigger>
@ -274,25 +295,6 @@ export function SpaceSelector({
>
<div className="flex flex-col gap-2">
<div className="flex flex-col">
{!singleSelect && (
<>
<DropdownMenuItem
onClick={handleSelectNovaSpaces}
className={cn(
"flex items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium",
isNovaSpaces
? "bg-[#293952]/40"
: "opacity-60 hover:opacity-100 hover:bg-[#293952]/40",
)}
>
<Globe className="size-4" />
<span className="flex-1">Nova Spaces</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[#2E3033] my-1" />
</>
)}
<div className="px-3 py-1">
<span className="text-[10px] uppercase tracking-wider text-[#737373] font-medium">
My Spaces
@ -313,7 +315,7 @@ export function SpaceSelector({
<span className="flex-1">My Space</span>
</DropdownMenuItem>
{novaProjects
{allProjects
.filter(
(p: ContainerTagListType) =>
p.containerTag !== DEFAULT_PROJECT_ID,

View file

@ -0,0 +1,429 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { SyncLogoIcon } from "@ui/assets/icons"
import { DocumentIcon } from "@/components/document-icon"
import { ChevronDownIcon } from "lucide-react"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
// ─── Time period helpers ─────────────────────────────────────────────────────
function getTimePeriodLabel(date: Date, now: Date): string {
const docDay = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const todayDay = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const diffDays = Math.round(
(todayDay.getTime() - docDay.getTime()) / 86400000,
)
if (diffDays === 0) return "Today"
if (diffDays === 1) return "Yesterday"
if (diffDays < 7) return date.toLocaleDateString("en-US", { weekday: "long" })
if (date.getFullYear() === now.getFullYear())
return date.toLocaleDateString("en-US", { month: "long", day: "numeric" })
return date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})
}
// ─── Document type helpers ────────────────────────────────────────────────────
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"))
return {
label: "YouTube Videos",
singularLabel: "YouTube Video",
key: "youtube",
}
switch (doc.type) {
case "tweet":
return { label: "Tweets", singularLabel: "Tweet", key: "tweet" }
case "google_doc":
return {
label: "Google Docs",
singularLabel: "Google Doc",
key: "google_doc",
}
case "google_slide":
return {
label: "Google Slides",
singularLabel: "Google Slide",
key: "google_slide",
}
case "google_sheet":
return {
label: "Google Sheets",
singularLabel: "Google Sheet",
key: "google_sheet",
}
case "notion_doc":
return {
label: "Notion Docs",
singularLabel: "Notion Doc",
key: "notion_doc",
}
case "text":
return { label: "Notes", singularLabel: "Note", key: "text" }
case "pdf":
return { label: "PDFs", singularLabel: "PDF", key: "pdf" }
case "image":
return { label: "Images", singularLabel: "Image", key: "image" }
case "video":
return { label: "Videos", singularLabel: "Video", key: "video" }
case "onedrive":
return {
label: "OneDrive Files",
singularLabel: "OneDrive File",
key: "onedrive",
}
case "webpage":
return { label: "Web Pages", singularLabel: "Web Page", key: "webpage" }
default:
return doc.url?.startsWith("https://")
? { label: "Web Pages", singularLabel: "Web Page", key: "webpage" }
: { label: "Notes", singularLabel: "Note", key: "text" }
}
}
function getPreviewText(doc: DocumentWithMemories): string {
return doc.summary || doc.content || doc.title || ""
}
// ─── Grouped data structures ─────────────────────────────────────────────────
type TypeGroup = { categoryInfo: CategoryInfo; docs: DocumentWithMemories[] }
type PeriodGroup = { label: string; typeGroups: TypeGroup[] }
function groupDocuments(
documents: DocumentWithMemories[],
now: Date,
): PeriodGroup[] {
const sorted = [...documents].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)
const periodMap = new Map<string, DocumentWithMemories[]>()
const periodOrder: string[] = []
for (const doc of sorted) {
const label = getTimePeriodLabel(new Date(doc.createdAt), now)
if (!periodMap.has(label)) {
periodMap.set(label, [])
periodOrder.push(label)
}
periodMap.get(label)?.push(doc)
}
return periodOrder.map((label) => {
const docs = periodMap.get(label)!
const categoryMap = new Map<
string,
{ info: CategoryInfo; docs: DocumentWithMemories[] }
>()
const categoryOrder: string[] = []
for (const doc of docs) {
const info = getDocumentTypeInfo(doc)
if (!categoryMap.has(info.key)) {
categoryMap.set(info.key, { info, docs: [] })
categoryOrder.push(info.key)
}
categoryMap.get(info.key)?.docs.push(doc)
}
return {
label,
typeGroups: categoryOrder.map((key) => {
const entry = categoryMap.get(key)!
return { categoryInfo: entry.info, docs: entry.docs }
}),
}
})
}
// ─── Individual timeline card ─────────────────────────────────────────────────
function TimelineCard({
doc,
onOpenDocument,
indent = false,
}: {
doc: DocumentWithMemories
onOpenDocument: (doc: DocumentWithMemories) => void
indent?: boolean
}) {
const preview = getPreviewText(doc)
const typeLabel = doc.type
? doc.type.charAt(0).toUpperCase() + doc.type.slice(1).replace(/_/g, " ")
: "Document"
const totalMemories = doc.memoryEntries.length
return (
<button
type="button"
className={cn(
"w-full text-left px-4 py-3 cursor-pointer transition-colors",
indent
? "bg-transparent hover:bg-white/[0.04]"
: "rounded-2xl border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D]",
dmSansClassName(),
)}
onClick={() => onOpenDocument(doc)}
>
{/* Type label */}
<div className="flex items-center gap-1.5 mb-2">
<DocumentIcon
type={doc.type}
source={doc.source ?? undefined}
url={doc.url ?? undefined}
className="w-3.5 h-3.5 shrink-0 opacity-60"
/>
<span className="text-[10px] text-white/40 uppercase tracking-widest">
{typeLabel}
</span>
</div>
{/* Title */}
{doc.title && (
<p className="text-[13px] text-white/85 font-medium leading-snug line-clamp-2 mb-1.5">
{doc.title}
</p>
)}
{/* Preview */}
{preview && (
<p className="text-[12px] text-white/45 line-clamp-3 leading-relaxed">
{preview}
</p>
)}
{/* Footer */}
{totalMemories > 0 && (
<div className="flex items-center gap-1 mt-2.5">
<SyncLogoIcon
className="w-[11px] h-[9px]"
style={{
filter:
"brightness(0) saturate(100%) invert(58%) sepia(69%) saturate(535%) hue-rotate(181deg) brightness(101%) contrast(98%)",
}}
/>
<span
className="text-[11px] font-medium"
style={{
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
{totalMemories}
</span>
</div>
)}
</button>
)
}
// ─── Collapsed group card ─────────────────────────────────────────────────────
function GroupCard({
group,
isExpanded,
onToggle,
onOpenDocument,
expandKey,
}: {
group: TypeGroup
isExpanded: boolean
onToggle: () => void
onOpenDocument: (doc: DocumentWithMemories) => void
expandKey: string
}) {
const firstDoc = group.docs[0]!
const preview = getPreviewText(firstDoc)
const count = group.docs.length
const { label, singularLabel } = group.categoryInfo
const countLabel = count === 1 ? `1 ${singularLabel}` : `${count} ${label}`
const totalMemories = group.docs.reduce(
(sum, d) => sum + d.memoryEntries.length,
0,
)
return (
<div>
<button
type="button"
className={cn(
"w-full text-left rounded-2xl px-4 py-3 cursor-pointer transition-colors",
"border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D]",
"flex items-center justify-between gap-3",
isExpanded && "rounded-b-none border-b-transparent",
dmSansClassName(),
)}
onClick={onToggle}
aria-expanded={isExpanded}
>
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<DocumentIcon
type={firstDoc.type}
source={firstDoc.source ?? undefined}
url={firstDoc.url ?? undefined}
className="w-3.5 h-3.5 shrink-0 opacity-60"
/>
<span className="text-[13px] text-white/75 font-medium whitespace-nowrap shrink-0">
{countLabel}
</span>
{preview && (
<span className="text-[12px] text-white/35 truncate">
{preview}
</span>
)}
{totalMemories > 0 && (
<span
className="text-[11px] font-medium shrink-0 ml-auto"
style={{
background:
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
{totalMemories}
</span>
)}
</div>
<ChevronDownIcon
className={cn(
"w-3.5 h-3.5 text-white/20 shrink-0 transition-transform duration-200",
isExpanded && "rotate-180",
)}
/>
</button>
{isExpanded && (
<div
id={`group-${expandKey}`}
className="border border-t-0 border-[#252B35] rounded-b-2xl overflow-hidden divide-y divide-[#252B35]"
>
{group.docs.map((doc) => (
<TimelineCard
key={doc.id}
doc={doc}
onOpenDocument={onOpenDocument}
indent
/>
))}
</div>
)}
</div>
)
}
// ─── Main TimelineView ────────────────────────────────────────────────────────
interface TimelineViewProps {
documents: DocumentWithMemories[]
onOpenDocument: (document: DocumentWithMemories) => void
hasNextPage?: boolean
isFetchingNextPage?: boolean
onLoadMore?: () => void
}
export function TimelineView({
documents,
onOpenDocument,
hasNextPage,
isFetchingNextPage,
onLoadMore,
}: TimelineViewProps) {
const [now] = useState(() => new Date())
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
const sentinelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!sentinelRef.current || !onLoadMore) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) {
onLoadMore()
}
},
{ threshold: 0.1 },
)
observer.observe(sentinelRef.current)
return () => observer.disconnect()
}, [hasNextPage, isFetchingNextPage, onLoadMore])
const toggleGroup = useCallback((key: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}, [])
const periodGroups = groupDocuments(documents, now)
return (
<div
className={cn(
"w-full max-w-[780px] mx-auto py-4 pb-12 space-y-6",
dmSansClassName(),
)}
>
{periodGroups.map((period) => (
<div key={period.label} className="grid grid-cols-[88px_1fr] gap-x-4">
<div className="pt-3 text-right shrink-0">
<span className="text-[10px] text-white/30 font-medium uppercase tracking-[0.15em] leading-none">
{period.label}
</span>
</div>
<div className="space-y-1.5 min-w-0">
{period.typeGroups.map((group) => {
const expandKey = `${period.label}::${group.categoryInfo.key}`
if (group.docs.length === 1) {
return (
<TimelineCard
key={expandKey}
doc={group.docs[0]!}
onOpenDocument={onOpenDocument}
/>
)
}
return (
<GroupCard
key={expandKey}
group={group}
expandKey={expandKey}
isExpanded={expandedGroups.has(expandKey)}
onToggle={() => toggleGroup(expandKey)}
onOpenDocument={onOpenDocument}
/>
)
})}
</div>
</div>
))}
<div ref={sentinelRef} className="h-1" />
</div>
)
}

View file

@ -1,5 +1,6 @@
"use client"
import { useCustomer } from "autumn-js/react"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import { useAuth } from "@lib/auth-context"
import {
@ -11,25 +12,33 @@ import {
} from "@ui/components/dropdown-menu"
import { authClient } from "@lib/auth"
import { useRouter } from "next/navigation"
import { LogOut, Settings, RotateCcw, HelpCircle } from "lucide-react"
import { LogOut, Settings, RotateCcw, HelpCircle, LifeBuoy } 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"
export function UserProfileMenu({
className,
avatarClassName,
onOpenFeedback,
}: {
className?: string
avatarClassName?: string
onOpenFeedback?: () => void
}) {
const { user } = useAuth()
const router = useRouter()
const { resetOrgOnboarded } = useOrgOnboarding()
const autumn = useCustomer()
const { currentPlan, isLoading: planLoading } = useTokenUsage(autumn)
const planBadgeLabel =
currentPlan === "pro" ? "PRO" : currentPlan === "scale" ? "SCALE" : null
const handleTryOnboarding = () => {
resetOrgOnboarded()
router.push("/onboarding?step=input&flow=welcome")
router.push("/onboarding")
}
const handleSignOut = () => {
@ -45,27 +54,72 @@ export function UserProfileMenu({
if (!user) return null
const initials = (() => {
if (user.name) {
const parts = user.name.trim().split(/\s+/)
return parts.length >= 2
? `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
: parts[0].slice(0, 2).toUpperCase()
}
if (user.email) return user.email.slice(0, 2).toUpperCase()
return "SM"
})()
const avatarColor = (() => {
const palette = [
"#0e2244", // navy blue
"#1a1a3e", // deep indigo
"#1e1030", // dark violet
"#0d2e2e", // dark teal
"#2a1020", // dark rose
"#1a2a10", // deep forest
"#2e1a0a", // dark amber
"#0a1e2e", // ocean
]
const seed = user.email ?? user.name ?? ""
let hash = 0
for (let i = 0; i < seed.length; i++)
hash = seed.charCodeAt(i) + ((hash << 5) - hash)
return palette[((hash % palette.length) + palette.length) % palette.length]
})()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={
planBadgeLabel
? `Account menu, ${planBadgeLabel} plan`
: "Account menu"
}
className={cn(
"rounded-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-transform hover:scale-105",
"relative inline-flex shrink-0 rounded-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
className,
)}
>
<Avatar
className={cn(
"border border-[#2E3033] h-8 w-8 md:h-10 md:w-10",
avatarClassName,
)}
className={cn("size-9 border border-[#161F2C]", avatarClassName)}
>
<AvatarImage src={user.image ?? ""} />
<AvatarFallback className="bg-[#0D121A] text-white">
{user.name?.charAt(0)}
<AvatarFallback
className="text-xs font-medium text-white"
style={{ background: avatarColor }}
>
{initials}
</AvatarFallback>
</Avatar>
{!planLoading && planBadgeLabel ? (
<span
id="user-plan-badge"
className={cn(
"pointer-events-none absolute -bottom-0.5 left-1/2 z-10 -translate-x-1/2 rounded border px-1 py-px text-center text-[8px] font-bold uppercase leading-tight tracking-wide",
"border-[#2261CA33] bg-[#00173C] text-[#6BB0FF]",
)}
>
{planBadgeLabel}
</span>
) : null}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
@ -95,8 +149,17 @@ export function UserProfileMenu({
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<RotateCcw className="h-4 w-4 text-[#737373]" />
Restart Onboarding
Try onboarding
</DropdownMenuItem>
{onOpenFeedback ? (
<DropdownMenuItem
onClick={onOpenFeedback}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<LifeBuoy className="h-4 w-4 text-[#737373]" />
Feedback
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuItem
asChild

View file

@ -1,7 +1,6 @@
"use client"
import { useQuery } from "@tanstack/react-query"
import { useMemo } from "react"
import { $fetch } from "@lib/api"
import type { ContainerTagListType } from "@lib/types"
@ -18,20 +17,8 @@ export function useContainerTags() {
staleTime: 30 * 1000,
})
const novaProjects = useMemo(
() => allProjects.filter((p) => p.isNova),
[allProjects],
)
const novaContainerTags = useMemo(
() => novaProjects.map((p) => p.containerTag),
[novaProjects],
)
return {
allProjects,
novaProjects,
novaContainerTags,
isLoading,
}
}

View file

@ -293,6 +293,7 @@ export function useDocumentMutations({
description: "Your note is being processed",
})
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
onClose?.()
},
})
@ -356,6 +357,7 @@ export function useDocumentMutations({
description: "Your link is being processed",
})
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
onClose?.()
},
})
@ -499,6 +501,7 @@ export function useDocumentMutations({
analytics.documentAdded({ type: "file", project_id: variables.project })
}
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
if (data.failures.length === 0) {
toast.success(
data.successCount === 1

View file

@ -0,0 +1,301 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { $fetch } from "@lib/api"
import type { SearchResult } from "@repo/lib/api"
const CACHE_KEY = "sm_profession_v1"
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
export type Profession =
| "developer"
| "finance"
| "research"
| "design"
| "legal"
| "marketing"
| "medical"
| "default"
export interface PersonalizedCopy {
saveLink: string
writeNote: string
chatPlaceholder: string
}
const COPY: Record<Profession, PersonalizedCopy> = {
developer: {
saveLink: "Save a repo",
writeNote: "Write dev notes",
chatPlaceholder: "Ask about your code, docs, or notes…",
},
finance: {
saveLink: "Save an article",
writeNote: "Log a thesis",
chatPlaceholder: "Ask about your research or portfolio…",
},
research: {
saveLink: "Save a paper",
writeNote: "Write notes",
chatPlaceholder: "Ask about your reading or research…",
},
design: {
saveLink: "Save inspiration",
writeNote: "Write a brief",
chatPlaceholder: "What are you working on today?",
},
legal: {
saveLink: "Save a document",
writeNote: "Write a memo",
chatPlaceholder: "Ask about your cases or contracts…",
},
marketing: {
saveLink: "Save a resource",
writeNote: "Write campaign notes",
chatPlaceholder: "Ask about your campaigns or research…",
},
medical: {
saveLink: "Save a study",
writeNote: "Write clinical notes",
chatPlaceholder: "Ask about your research or cases…",
},
default: {
saveLink: "Save link",
writeNote: "Write note",
chatPlaceholder: "Ask your supermemory…",
},
}
const KEYWORDS: Record<Exclude<Profession, "default">, string[]> = {
developer: [
"software",
"engineer",
"developer",
"programming",
"code",
"github",
"typescript",
"javascript",
"python",
"backend",
"frontend",
"api",
"repository",
"startup",
"swe",
"tech",
"devops",
"cloud",
],
finance: [
"finance",
"investment",
"portfolio",
"trading",
"stock",
"fund",
"equity",
"crypto",
"banking",
"analyst",
"fintech",
"hedge",
"venture",
"capital",
"asset",
"valuation",
"economics",
],
research: [
"research",
"academia",
"phd",
"paper",
"journal",
"study",
"scholar",
"university",
"professor",
"scientist",
"thesis",
"experiment",
"hypothesis",
"data analysis",
"publication",
],
design: [
"design",
"ux",
"ui",
"figma",
"creative",
"visual",
"brand",
"illustrator",
"adobe",
"typography",
"wireframe",
"prototype",
"product design",
"graphic",
"art director",
],
legal: [
"lawyer",
"attorney",
"legal",
"law",
"contract",
"compliance",
"litigation",
"counsel",
"paralegal",
"court",
"regulatory",
"intellectual property",
"patent",
"trademark",
],
marketing: [
"marketing",
"growth",
"seo",
"content",
"campaign",
"brand",
"advertising",
"social media",
"pr",
"communications",
"copywriting",
"conversion",
"analytics",
"inbound",
],
medical: [
"doctor",
"physician",
"medical",
"healthcare",
"clinical",
"hospital",
"nursing",
"surgery",
"patient",
"medicine",
"diagnosis",
"treatment",
"pharmacology",
"dentist",
],
}
function classifyProfession(results: SearchResult[]): Profession {
const text = results
.flatMap((r) => [
r.title ?? "",
r.summary ?? "",
...(r.chunks?.slice(0, 2).map((c) => c.content) ?? []),
])
.join(" ")
.toLowerCase()
const scores: Partial<Record<Profession, number>> = {}
for (const [prof, words] of Object.entries(KEYWORDS)) {
scores[prof as Profession] = words.filter((w) => text.includes(w)).length
}
const best = (Object.entries(scores) as [Profession, number][]).sort(
(a, b) => b[1] - a[1],
)[0]
return best && best[1] > 0 ? best[0] : "default"
}
let inflightPromise: Promise<void> | null = null
export function usePersonalization(): {
copy: PersonalizedCopy
profession: Profession
setProfession: (p: Profession) => void
} {
const [copy, setCopy] = useState<PersonalizedCopy>(COPY.default)
const [profession, setProfessionState] = useState<Profession>("default")
const setProfession = useCallback((p: Profession) => {
try {
localStorage.setItem(
CACHE_KEY,
JSON.stringify({ profession: p, ts: Date.now() }),
)
} catch {}
setCopy(COPY[p])
setProfessionState(p)
}, [])
useEffect(() => {
try {
const raw = localStorage.getItem(CACHE_KEY)
if (raw) {
const { profession: cached, ts } = JSON.parse(raw) as {
profession: Profession
ts: number
}
if (Date.now() - ts < CACHE_TTL_MS && COPY[cached]) {
setCopy(COPY[cached])
setProfessionState(cached)
return
}
}
} catch {}
if (inflightPromise) {
inflightPromise.then(() => {
try {
const raw = localStorage.getItem(CACHE_KEY)
if (raw) {
const { profession: cached } = JSON.parse(raw) as {
profession: Profession
}
if (COPY[cached]) {
setCopy(COPY[cached])
setProfessionState(cached)
}
}
} catch {}
})
return
}
inflightPromise = $fetch("@post/search", {
body: {
q: "career profession field industry background work role",
limit: 8,
},
})
.then((res) => {
const results = res.data?.results
if (!results?.length) return
const detected = classifyProfession(results)
try {
localStorage.setItem(
CACHE_KEY,
JSON.stringify({ profession: detected, ts: Date.now() }),
)
} catch {}
setCopy(COPY[detected])
setProfessionState(detected)
})
.catch(() => {})
.finally(() => {
inflightPromise = null
})
}, [])
return { copy, profession, setProfession }
}
export function clearPersonalizationCache() {
try {
localStorage.removeItem(CACHE_KEY)
} catch {}
}

View file

@ -0,0 +1,87 @@
"use client"
import { useEffect, useMemo, useRef } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { $fetch } from "@lib/api"
import { useProject } from "@/stores"
import { useAuth } from "@lib/auth-context"
const MAX_POLLS = 60
const POLL_INTERVAL_MS = 5_000
export function useProcessingDocuments() {
const { user } = useAuth()
const { effectiveContainerTags } = useProject()
const queryClient = useQueryClient()
const prevIdsRef = useRef<Set<string>>(new Set())
const { data } = useQuery({
queryKey: ["processing-documents", effectiveContainerTags],
queryFn: async () => {
const response = await $fetch("@get/documents/processing", {
query: { containerTags: effectiveContainerTags },
disableValidation: true,
})
if (response.error) return { documents: [], totalCount: 0 }
return response.data ?? { documents: [], totalCount: 0 }
},
enabled: !!user,
refetchInterval: (query) => {
const count =
(query.state.data as { totalCount?: number } | undefined)?.totalCount ??
0
const polls = query.state.dataUpdateCount
if (count === 0 || polls >= MAX_POLLS) return false
return POLL_INTERVAL_MS
},
staleTime: 0,
})
const docs =
(
data as
| { documents?: Array<{ id?: string | null; status?: string | null }> }
| undefined
)?.documents ?? []
const processingMap = useMemo(() => {
const map = new Map<string, string>()
for (const doc of docs) {
if (doc.id && doc.status) {
map.set(doc.id, doc.status)
}
}
return map
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docs])
// Detect docs that just finished (present in previous poll, absent now).
// Done here — not in the card — because card remounts reset per-card refs
// and lose the transition signal.
useEffect(() => {
const prev = prevIdsRef.current
const current = new Set(processingMap.keys())
prevIdsRef.current = current
const justFinished = [...prev].filter((id) => !current.has(id))
if (justFinished.length === 0) return
const refresh = () => {
queryClient.refetchQueries({ queryKey: ["documents-with-memories"] })
queryClient.refetchQueries({ queryKey: ["dashboard-recents"] })
}
// First pass: give the backend ~1s to finish writing memory entries
const t1 = setTimeout(refresh, 1000)
// Second pass: insurance in case the first fetch still beat the writes
const t2 = setTimeout(refresh, 4000)
return () => {
clearTimeout(t1)
clearTimeout(t2)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [processingMap.keys, queryClient.refetchQueries])
return processingMap
}

View file

@ -0,0 +1,43 @@
"use client"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { $fetch } from "@lib/api"
export function useResetOrganization() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (body: { confirmation: string }) => {
const res = await $fetch("@post/settings/reset", {
body,
retry: { attempts: 0 },
})
if (res.error) {
const e = res.error as Record<string, unknown>
const msg =
typeof e.error === "string"
? e.error
: typeof e.message === "string"
? e.message
: "Reset failed"
throw new Error(msg)
}
if (!res.data?.success) throw new Error("Reset failed")
return res.data
},
onSuccess: async () => {
queryClient.invalidateQueries()
// Clear the daily brief Cache API entry so stale highlights don't survive the reset
try {
await caches.delete("space-highlights-v1")
} catch {
// Cache API not available in all environments
}
toast.success("Organization data has been reset.")
},
onError: (error: Error) => {
toast.error(error.message || "Failed to reset organization.")
},
})
}

View file

@ -29,8 +29,9 @@ export const analytics = {
chatHistoryViewed: () => safeCapture("chat_history_viewed"),
chatDeleted: () => safeCapture("chat_deleted"),
viewModeChanged: (mode: "graph" | "list" | "integrations") =>
safeCapture("view_mode_changed", { mode }),
viewModeChanged: (
mode: "dashboard" | "graph" | "list" | "integrations" | "chat",
) => safeCapture("view_mode_changed", { mode }),
documentCardClicked: () => safeCapture("document_card_clicked"),
@ -117,8 +118,9 @@ export const analytics = {
}) => safeCapture("highlight_clicked", props),
// chat analytics
chatMessageSent: (props: { source: "typed" | "suggested" | "highlight" }) =>
safeCapture("chat_message_sent", props),
chatMessageSent: (props: {
source: "typed" | "suggested" | "highlight" | "home"
}) => safeCapture("chat_message_sent", props),
chatSuggestedQuestionClicked: () =>
safeCapture("chat_suggested_question_clicked"),

View file

@ -0,0 +1,214 @@
import type { UIMessage } from "@ai-sdk/react"
import { memoryResultsFromSearchToolOutput } from "@/lib/chat-search-memory-results"
const UUID_IN_STRING =
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
const UUID_STRICT =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
// Matches [doc:<id>] annotations emitted by sgrep when includeDocIds is enabled.
// Supermemory uses NanoIDs (alphanumeric + _ -), not UUIDs.
const DOC_ANNOTATION = /\[doc:([A-Za-z0-9_-]{10,40})\]/g
function collectIdsFromDynamicTool(part: Record<string, unknown>): string[] {
const toolName = part.toolName
if (!part.output) return []
if (toolName === "searchMemories") {
return memoryResultsFromSearchToolOutput(part.output)
.map((r) => r.documentId)
.filter((id): id is string => Boolean(id))
}
if (toolName === "bash") {
return documentIdsFromBashText(
extractBashOutputString(part.output as Record<string, unknown>),
)
}
const fromWalk: string[] = []
collectDocumentIdsFromUnknown(part.output, fromWalk)
return fromWalk
}
function extractBashOutputString(output: Record<string, unknown>): string {
const stdout = output.stdout
return typeof stdout === "string" ? stdout : ""
}
/** Heuristic: pull document IDs from bash stdout. Handles [doc:<id>] annotations, UUID patterns, and JSON documentId fields. */
export function documentIdsFromBashText(text: string): string[] {
const found = new Set<string>()
// [doc:<nanoid>] annotations from sgrep --include-doc-ids (highest confidence)
for (const m of text.matchAll(DOC_ANNOTATION)) {
found.add(m[1])
}
// Standard UUID format
for (const m of text.matchAll(UUID_IN_STRING)) {
found.add(m[0].toLowerCase())
}
// JSON "documentId": "..." fields
const quoted = /"documentId"\s*:\s*"([^"]+)"/g
let q = quoted.exec(text)
while (q !== null) {
found.add(q[1])
q = quoted.exec(text)
}
return [...found]
}
function collectDocumentIdsFromUnknown(value: unknown, out: string[]): void {
const seen = new Set<string>()
const walk = (v: unknown, depth: number) => {
if (depth > 18) return
if (v === null || v === undefined) return
if (typeof v === "string") {
if (v.length > 0 && v.length < 400000) {
for (const id of documentIdsFromBashText(v)) {
if (!seen.has(id)) {
seen.add(id)
out.push(id)
}
}
}
return
}
if (Array.isArray(v)) {
for (const x of v) walk(x, depth + 1)
return
}
if (typeof v !== "object") return
const o = v as Record<string, unknown>
const docId = o.documentId
if (
typeof docId === "string" &&
UUID_STRICT.test(docId) &&
!seen.has(docId)
) {
seen.add(docId)
out.push(docId)
}
if (Array.isArray(o.documents)) {
for (const d of o.documents) {
if (!d || typeof d !== "object") continue
const doc = d as Record<string, unknown>
const id = doc.id
if (typeof id === "string" && UUID_STRICT.test(id) && !seen.has(id)) {
seen.add(id)
out.push(id)
}
}
}
for (const key of [
"results",
"memories",
"chunks",
"hits",
"items",
"data",
]) {
if (key in o) walk(o[key], depth + 1)
}
}
walk(value, 0)
}
function toolOutputReady(p: Record<string, unknown>): boolean {
const s = p.state
return (
s === "output-available" ||
s === "done" ||
(s === undefined && p.output !== undefined)
)
}
/** Document IDs referenced by retrieval tools / sources in this thread. */
export function extractHighlightDocumentIdsFromMessages(
messages: UIMessage[],
): string[] {
const ids = new Set<string>()
for (const message of messages) {
if (message.role !== "assistant") continue
const parts = message.parts
if (!parts) continue
for (const part of parts) {
const p = part as Record<string, unknown>
if (p.type === "source-document") {
const sid = (p as { sourceId?: unknown }).sourceId
if (typeof sid === "string" && UUID_STRICT.test(sid)) {
ids.add(sid)
}
continue
}
if (p.type === "tool-searchMemories" && toolOutputReady(p)) {
for (const id of memoryResultsFromSearchToolOutput(p.output)
.map((r) => r.documentId)
.filter(Boolean)) {
ids.add(id as string)
}
continue
}
if (p.type === "dynamic-tool" && toolOutputReady(p)) {
for (const id of collectIdsFromDynamicTool(p)) {
ids.add(id)
}
continue
}
if (
typeof p.type === "string" &&
p.type.startsWith("tool-") &&
toolOutputReady(p)
) {
const name = p.type.slice("tool-".length)
if (name === "searchMemories") {
for (const id of memoryResultsFromSearchToolOutput(p.output)
.map((r) => r.documentId)
.filter(Boolean)) {
ids.add(id as string)
}
} else if (name === "bash") {
const out = p.output as Record<string, unknown> | undefined
const stdout = out && typeof out.stdout === "string" ? out.stdout : ""
for (const id of documentIdsFromBashText(stdout)) {
ids.add(id)
}
} else if (p.output) {
const buf: string[] = []
collectDocumentIdsFromUnknown(p.output, buf)
for (const id of buf) ids.add(id)
}
}
}
}
if (ids.size === 0) {
const lastAssistant = [...messages]
.reverse()
.find((m) => m.role === "assistant")
const parts = lastAssistant?.parts
if (parts) {
const texts = parts
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("\n")
let n = 0
for (const m of texts.matchAll(UUID_IN_STRING)) {
if (n >= 16) break
ids.add(m[0].toLowerCase())
n++
}
}
}
return [...ids]
}

View file

@ -23,12 +23,25 @@ export const shareParam = parseAsBoolean.withDefault(false)
export const feedbackParam = parseAsBoolean.withDefault(false)
// View & filter states
const viewLiterals = ["graph", "list", "integrations"] as const
const integrationLiterals = ["import", "chrome", "connections"] as const
const viewLiterals = [
"dashboard",
"graph",
"list",
"integrations",
"chat",
] as const
const integrationLiterals = [
"import",
"chrome",
"connections",
"notion",
"google-drive",
] as const
export type IntegrationParamValue = (typeof integrationLiterals)[number]
export const integrationParam = parseAsStringLiteral(integrationLiterals)
export type ViewParamValue = (typeof viewLiterals)[number]
export const viewParam = parseAsStringLiteral(viewLiterals).withDefault("list")
export const viewParam =
parseAsStringLiteral(viewLiterals).withDefault("dashboard")
export const pluginsPanelParam = parseAsBoolean
export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 MiB

View file

@ -2,30 +2,18 @@
import { useQueryState } from "nuqs"
import { projectParam } from "@/lib/search-params"
import { useCallback, useMemo } from "react"
import { useCallback } from "react"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import { useContainerTags } from "@/hooks/use-container-tags"
export function useProject() {
const [selectedProjects, _setSelectedProjects] = useQueryState(
"project",
projectParam,
)
const { novaContainerTags } = useContainerTags()
const isNovaSpaces = selectedProjects.length === 0
const selectedProject = selectedProjects[0] ?? DEFAULT_PROJECT_ID
const selectedProject = isNovaSpaces
? DEFAULT_PROJECT_ID
: (selectedProjects[0] ?? DEFAULT_PROJECT_ID)
// Get effective container tags for API calls
// When "Nova Spaces" is selected, use all nova container tags
// Otherwise, use the selected projects
const effectiveContainerTags = useMemo(
() => (isNovaSpaces ? novaContainerTags : selectedProjects),
[isNovaSpaces, novaContainerTags, selectedProjects],
)
const effectiveContainerTags = selectedProjects
const setSelectedProjects = useCallback(
(projects: string[]) => {
@ -46,9 +34,7 @@ export function useProject() {
selectedProject,
setSelectedProjects,
setSelectedProject,
isNovaSpaces,
effectiveContainerTags,
novaContainerTags,
}
}

View file

@ -19,6 +19,7 @@ import {
MemoryResponseSchema,
MigrateMCPRequestSchema,
MigrateMCPResponseSchema,
ProcessingDocumentsResponseSchema,
ProjectSchema,
SearchRequestSchema,
SearchResponseSchema,
@ -132,6 +133,19 @@ export const apiSchema = createSchema({
input: SettingsRequestSchema,
output: SettingsResponseSchema,
},
"@post/settings/reset": {
input: z.object({ confirmation: z.string() }),
output: z.object({
success: z.boolean(),
deletedConnections: z.number(),
deletedDocumentBatches: z.number(),
deletedDocumentsApprox: z.number(),
deletedMemoryRows: z.number(),
deletedExtraSpaces: z.number(),
clearedDefaultSpaceContext: z.boolean(),
settingsReset: z.boolean(),
}),
},
// Memory operations
"@post/documents": {
input: MemoryAddSchema,
@ -165,6 +179,15 @@ export const apiSchema = createSchema({
output: MigrateMCPResponseSchema,
},
"@get/documents/processing": {
output: ProcessingDocumentsResponseSchema,
query: z
.object({
containerTags: z.array(z.string()).optional(),
})
.optional(),
},
"@get/documents/:id": {
output: z.any(),
},

View file

@ -7,8 +7,8 @@ describe("generateMockGraphData", () => {
const data2 = generateMockGraphData({ documentCount: 10, seed: 42 })
expect(data1.documents.length).toBe(data2.documents.length)
expect(data1.documents[0]!.id).toBe(data2.documents[0]!.id)
expect(data1.documents[0]!.title).toBe(data2.documents[0]!.title)
expect(data1.documents[0]?.id).toBe(data2.documents[0]?.id)
expect(data1.documents[0]?.title).toBe(data2.documents[0]?.title)
})
it("produces different output with different seeds", () => {
@ -43,8 +43,9 @@ describe("generateMockGraphData", () => {
const data = generateMockGraphData({ documentCount: 5, seed: 1 })
const doc = data.documents.find((d) => d.memories.length > 0)
expect(doc).toBeDefined()
if (!doc) return
for (const mem of doc!.memories) {
for (const mem of doc.memories) {
expect(mem.id).toBeDefined()
expect(mem.memory).toBeDefined()
expect(typeof mem.isStatic).toBe("boolean")

View file

@ -18,6 +18,15 @@ export interface RenderState {
// Module-level reusable batch map cleared each frame instead of reallocating
const edgeBatches = new Map<string, PreparedEdge[]>()
function nodeMatchesDocumentHighlights(
node: GraphNode,
highlightIds: Set<string>,
): boolean {
if (highlightIds.size === 0) return false
if (node.type === "document") return highlightIds.has(node.id)
return highlightIds.has((node.data as MemoryNodeData).documentId)
}
/** Group items by their `color` property into batches for efficient canvas drawing */
function groupByColor<T extends { color: string }>(
items: T[],
@ -296,7 +305,13 @@ function drawNodes(
const isSelected = node.id === state.selectedNodeId
const isHovered = node.id === state.hoveredNodeId
const isHighlighted = state.highlightIds.has(node.id)
const isHighlighted = nodeMatchesDocumentHighlights(
node,
state.highlightIds,
)
const highlightFocus = state.highlightIds.size > 0
const fadeNonHighlights =
highlightFocus && !isSelected && !isHovered && !isHighlighted
if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) {
if (node.type === "document") {
@ -318,6 +333,9 @@ function drawNodes(
if (state.selectedNodeId && state.dimProgress > 0 && !isSelected) {
alpha = 1 - state.dimProgress * 0.7
}
if (fadeNonHighlights) {
alpha *= 0.35
}
ctx.globalAlpha = alpha
if (node.type === "document") {
@ -363,12 +381,13 @@ function drawNodes(
state.selectedNodeId && state.dimProgress > 0
? 1 - state.dimProgress * 0.7
: 1
const hlBatchMult = state.highlightIds.size > 0 ? 0.4 : 1
if (docDots.length > 0) {
ctx.fillStyle = colors.docFill
ctx.strokeStyle = colors.docStroke
ctx.lineWidth = 1
ctx.globalAlpha = dimAlpha
ctx.globalAlpha = dimAlpha * hlBatchMult
for (const d of docDots) {
const h = d.s * 0.5
ctx.fillRect(d.x - h, d.y - h, d.s, d.s)
@ -383,7 +402,7 @@ function drawNodes(
if (normalDots.length > 0) {
// Subtle glow behind memory dots for luminous effect
ctx.globalAlpha = dimAlpha * 0.25
ctx.globalAlpha = dimAlpha * hlBatchMult * 0.25
for (const [color, batch] of groupByColor(normalDots)) {
ctx.fillStyle = color
ctx.beginPath()
@ -395,7 +414,7 @@ function drawNodes(
}
// Filled dot
ctx.globalAlpha = dimAlpha
ctx.globalAlpha = dimAlpha * hlBatchMult
ctx.fillStyle = colors.memFill
ctx.beginPath()
for (const d of normalDots) {
@ -419,7 +438,7 @@ function drawNodes(
// Draw dimmed (superseded) memory dots at reduced opacity
if (dimmedDots.length > 0) {
ctx.globalAlpha = dimAlpha * 0.5
ctx.globalAlpha = dimAlpha * hlBatchMult * 0.5
ctx.fillStyle = colors.memFill
ctx.beginPath()
for (const d of dimmedDots) {

View file

@ -104,8 +104,24 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
}, [nodes])
useEffect(() => {
s.current.highlightIds = new Set(highlightDocumentIds ?? [])
const ids = new Set(highlightDocumentIds ?? [])
s.current.highlightIds = ids
renderNeeded.current = true
if (ids.size === 0) return
const vp = viewportRef.current
if (!vp) return
const highlighted = s.current.nodes.filter((n) => {
if (n.type === "document") return ids.has(n.id)
const d = n.data as { documentId?: string }
return typeof d.documentId === "string" && ids.has(d.documentId)
})
if (highlighted.length === 0) return
vp.fitToNodes(
highlighted.map((n) => ({ x: n.x, y: n.y, size: n.size ?? 24 })),
s.current.width,
s.current.height,
)
}, [highlightDocumentIds])
useEffect(() => {