aboutsummaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/app/(navigation)/page.tsx41
-rw-r--r--apps/web/app/api/onboarding/research/route.ts57
-rw-r--r--apps/web/app/new/layout.tsx8
-rw-r--r--apps/web/app/new/onboarding/setup/layout.tsx19
-rw-r--r--apps/web/app/new/onboarding/welcome/layout.tsx18
-rw-r--r--apps/web/app/new/onboarding/welcome/page.tsx2
-rw-r--r--apps/web/app/new/page.tsx66
-rw-r--r--apps/web/app/new/settings/page.tsx4
-rw-r--r--apps/web/app/onboarding/page.tsx15
-rw-r--r--apps/web/components/new/add-space-modal.tsx2
-rw-r--r--apps/web/components/new/chat/index.tsx205
-rw-r--r--apps/web/components/new/chat/model-selector.tsx2
-rw-r--r--apps/web/components/new/document-cards/tweet-preview.tsx12
-rw-r--r--apps/web/components/new/feedback-modal.tsx179
-rw-r--r--apps/web/components/new/header.tsx40
-rw-r--r--apps/web/components/new/highlights-card.tsx9
-rw-r--r--apps/web/components/new/mobile-banner.tsx24
-rw-r--r--apps/web/components/new/onboarding/setup/chat-sidebar.tsx144
-rw-r--r--apps/web/components/new/onboarding/setup/integrations-step.tsx19
-rw-r--r--apps/web/components/new/onboarding/setup/relatable-question.tsx5
-rw-r--r--apps/web/components/new/onboarding/welcome/profile-step.tsx7
-rw-r--r--apps/web/components/new/settings/integrations.tsx2
-rw-r--r--apps/web/components/new/space-selector.tsx4
-rw-r--r--apps/web/components/onboarding/new-onboarding-modal.tsx66
-rw-r--r--apps/web/components/onboarding/onboarding-wrapper.tsx12
-rw-r--r--apps/web/hooks/use-document-mutations.ts6
-rw-r--r--apps/web/lib/analytics.ts101
-rw-r--r--apps/web/stores/quick-note-draft.ts4
28 files changed, 839 insertions, 234 deletions
diff --git a/apps/web/app/(navigation)/page.tsx b/apps/web/app/(navigation)/page.tsx
index 73da4f3c..39b6f47c 100644
--- a/apps/web/app/(navigation)/page.tsx
+++ b/apps/web/app/(navigation)/page.tsx
@@ -1,10 +1,11 @@
"use client"
import { useOnboardingStorage } from "@hooks/use-onboarding-storage"
+import { useOrgOnboarding } from "@hooks/use-org-onboarding"
import { useAuth } from "@lib/auth-context"
import { ChevronsDown, LoaderIcon } from "lucide-react"
import { useRouter } from "next/navigation"
-import { useEffect } from "react"
+import { useEffect, useMemo } from "react"
import { InstallPrompt } from "@/components/install-prompt"
import { ChromeExtensionButton } from "@/components/chrome-extension-button"
import { ChatInput } from "@/components/chat-input"
@@ -14,11 +15,37 @@ import { useFeatureFlagEnabled } from "posthog-js/react"
export default function Page() {
const { user, session } = useAuth()
- const { shouldShowOnboarding, isLoading: onboardingLoading } =
- useOnboardingStorage()
const router = useRouter()
const flagEnabled = useFeatureFlagEnabled("nova-alpha-access")
+ // TODO: remove this flow after the feature flag is removed
+ // Old app: localStorage-backed onboarding
+ const {
+ shouldShowOnboarding: shouldShowOldOnboarding,
+ isLoading: oldOnboardingLoading,
+ } = useOnboardingStorage()
+
+ // New app: DB-backed onboarding (org.metadata.isOnboarded)
+ const {
+ shouldShowOnboarding: shouldShowNewOnboarding,
+ isLoading: newOnboardingLoading,
+ } = useOrgOnboarding()
+
+ // Select the appropriate onboarding state based on feature flag
+ const isOnboardingLoading = useMemo(() => {
+ if (flagEnabled) {
+ return newOnboardingLoading
+ }
+ return oldOnboardingLoading
+ }, [flagEnabled, newOnboardingLoading, oldOnboardingLoading])
+
+ const shouldShowOnboarding = useMemo(() => {
+ if (flagEnabled) {
+ return shouldShowNewOnboarding()
+ }
+ return shouldShowOldOnboarding()
+ }, [flagEnabled, shouldShowNewOnboarding, shouldShowOldOnboarding])
+
useEffect(() => {
const url = new URL(window.location.href)
const authenticateChromeExtension = url.searchParams.get(
@@ -46,16 +73,16 @@ export default function Page() {
}, [user, session])
useEffect(() => {
- if (user && !onboardingLoading && shouldShowOnboarding()) {
+ if (user && !isOnboardingLoading && shouldShowOnboarding) {
if (flagEnabled) {
router.push("/new/onboarding?step=input&flow=welcome")
} else {
router.push("/onboarding")
}
}
- }, [user, shouldShowOnboarding, onboardingLoading, router, flagEnabled])
+ }, [user, shouldShowOnboarding, isOnboardingLoading, router, flagEnabled])
- if (!user || onboardingLoading) {
+ if (!user || isOnboardingLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#0f1419]">
<div className="flex flex-col items-center gap-4">
@@ -66,7 +93,7 @@ export default function Page() {
)
}
- if (shouldShowOnboarding()) {
+ if (shouldShowOnboarding) {
return null
}
diff --git a/apps/web/app/api/onboarding/research/route.ts b/apps/web/app/api/onboarding/research/route.ts
index 5e9b933e..67bf4654 100644
--- a/apps/web/app/api/onboarding/research/route.ts
+++ b/apps/web/app/api/onboarding/research/route.ts
@@ -7,11 +7,22 @@ interface ResearchRequest {
email?: string
}
-// prompt to get user context from X/Twitter profile
-function finalPrompt(xUrl: string, userContext: string) {
+function extractHandle(url: string): string {
+ const cleaned = url
+ .toLowerCase()
+ .replace("https://x.com/", "")
+ .replace("https://twitter.com/", "")
+ .replace("http://x.com/", "")
+ .replace("http://twitter.com/", "")
+ .replace("@", "")
+
+ return (cleaned.split("/")[0] ?? cleaned).split("?")[0] ?? cleaned
+}
+
+function finalPrompt(handle: string, userContext: string) {
return `You are researching a user based on their X/Twitter profile to help personalize their experience.
-X/Twitter Profile URL: ${xUrl}${userContext}
+X Handle: @${handle}${userContext}
Please analyze this X/Twitter profile and provide a comprehensive but concise summary of the user. Include:
- Professional background and current role (if available)
@@ -29,18 +40,12 @@ export async function POST(req: Request) {
if (!xUrl?.trim()) {
return Response.json(
- { error: "X/Twitter URL is required" },
+ { error: "X/Twitter URL or handle is required" },
{ status: 400 },
)
}
- const lowerUrl = xUrl.toLowerCase()
- if (!lowerUrl.includes("x.com") && !lowerUrl.includes("twitter.com")) {
- return Response.json(
- { error: "URL must be an X/Twitter profile link" },
- { status: 400 },
- )
- }
+ const handle = extractHandle(xUrl)
const contextParts: string[] = []
if (name) contextParts.push(`Name: ${name}`)
@@ -51,29 +56,13 @@ export async function POST(req: Request) {
: ""
const { text } = await generateText({
- model: xai("grok-4-1-fast-reasoning"),
- prompt: finalPrompt(xUrl, userContext),
- providerOptions: {
- xai: {
- searchParameters: {
- mode: "on",
- sources: [
- {
- type: "web",
- safeSearch: true,
- },
- {
- type: "x",
- includedXHandles: [
- lowerUrl
- .replace("https://x.com/", "")
- .replace("https://twitter.com/", ""),
- ],
- postFavoriteCount: 10,
- },
- ],
- },
- },
+ model: xai.responses("grok-4-fast"),
+ prompt: finalPrompt(handle, userContext),
+ tools: {
+ web_search: xai.tools.webSearch(),
+ x_search: xai.tools.xSearch({
+ allowedXHandles: [handle],
+ }),
},
})
diff --git a/apps/web/app/new/layout.tsx b/apps/web/app/new/layout.tsx
index e761fea2..f0cc0c1e 100644
--- a/apps/web/app/new/layout.tsx
+++ b/apps/web/app/new/layout.tsx
@@ -3,6 +3,7 @@
import { useEffect } from "react"
import { useFeatureFlagEnabled } from "posthog-js/react"
import { useRouter } from "next/navigation"
+import { MobileBanner } from "@/components/new/mobile-banner"
export default function NewLayout({ children }: { children: React.ReactNode }) {
const router = useRouter()
@@ -18,5 +19,10 @@ export default function NewLayout({ children }: { children: React.ReactNode }) {
return null
}
- return <>{children}</>
+ return (
+ <>
+ <MobileBanner />
+ {children}
+ </>
+ )
}
diff --git a/apps/web/app/new/onboarding/setup/layout.tsx b/apps/web/app/new/onboarding/setup/layout.tsx
index 3a958469..6694f5fd 100644
--- a/apps/web/app/new/onboarding/setup/layout.tsx
+++ b/apps/web/app/new/onboarding/setup/layout.tsx
@@ -1,8 +1,16 @@
"use client"
-import { createContext, useContext, useCallback, type ReactNode } from "react"
+import {
+ createContext,
+ useContext,
+ useCallback,
+ useEffect,
+ useRef,
+ type ReactNode,
+} from "react"
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 type SetupStep = (typeof SETUP_STEPS)[number]
@@ -34,9 +42,11 @@ export default function SetupLayout({ children }: { children: ReactNode }) {
const currentStep: SetupStep = SETUP_STEPS.includes(stepParam as SetupStep)
? (stepParam as SetupStep)
: "relatable"
+ const hasTrackedInitialStep = useRef(false)
const goToStep = useCallback(
(step: SetupStep) => {
+ analytics.onboardingStepViewed({ step, trigger: "user" })
router.push(`/new/onboarding/setup?step=${step}`)
},
[router],
@@ -54,6 +64,13 @@ export default function SetupLayout({ children }: { children: ReactNode }) {
router.push("/new")
}, [router, resetOnboarding])
+ useEffect(() => {
+ if (!hasTrackedInitialStep.current) {
+ analytics.onboardingStepViewed({ step: currentStep, trigger: "user" })
+ hasTrackedInitialStep.current = true
+ }
+ }, [currentStep])
+
const contextValue: SetupContextValue = {
memoryFormData,
currentStep,
diff --git a/apps/web/app/new/onboarding/welcome/layout.tsx b/apps/web/app/new/onboarding/welcome/layout.tsx
index 427907c2..07d3c4b3 100644
--- a/apps/web/app/new/onboarding/welcome/layout.tsx
+++ b/apps/web/app/new/onboarding/welcome/layout.tsx
@@ -11,6 +11,7 @@ import {
} from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useOnboardingContext, type MemoryFormData } from "../layout"
+import { analytics } from "@/lib/analytics"
export const WELCOME_STEPS = [
"input",
@@ -61,6 +62,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
const [isSubmitting, setIsSubmitting] = useState(false)
const [showWelcomeContent, setShowWelcomeContent] = useState(false)
const isMountedRef = useRef(true)
+ const hasTrackedInitialStep = useRef(false)
useEffect(() => {
isMountedRef.current = true
@@ -89,6 +91,7 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
timers.push(
setTimeout(() => {
if (isMountedRef.current) {
+ analytics.onboardingStepViewed({ step: "welcome", trigger: "auto" })
router.replace("/new/onboarding/welcome?step=welcome")
}
}, 2000),
@@ -97,6 +100,10 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
timers.push(
setTimeout(() => {
if (isMountedRef.current) {
+ analytics.onboardingStepViewed({
+ step: "username",
+ trigger: "auto",
+ })
router.replace("/new/onboarding/welcome?step=username")
}
}, 2000),
@@ -108,8 +115,19 @@ export default function WelcomeLayout({ children }: { children: ReactNode }) {
}
}, [currentStep, router])
+ useEffect(() => {
+ if (!hasTrackedInitialStep.current) {
+ analytics.onboardingStepViewed({
+ step: currentStep,
+ trigger: "user",
+ })
+ hasTrackedInitialStep.current = true
+ }
+ }, [currentStep])
+
const goToStep = useCallback(
(step: WelcomeStep) => {
+ analytics.onboardingStepViewed({ step, trigger: "user" })
router.push(`/new/onboarding/welcome?step=${step}`)
},
[router],
diff --git a/apps/web/app/new/onboarding/welcome/page.tsx b/apps/web/app/new/onboarding/welcome/page.tsx
index 706cd40a..a09f93b5 100644
--- a/apps/web/app/new/onboarding/welcome/page.tsx
+++ b/apps/web/app/new/onboarding/welcome/page.tsx
@@ -19,6 +19,7 @@ import {
} from "./layout"
import { gapVariants, orbVariants } from "@/lib/variants"
import { authClient } from "@lib/auth"
+import { analytics } from "@/lib/analytics"
function UserSupermemory({ name }: { name: string }) {
return (
@@ -88,6 +89,7 @@ export default function WelcomePage() {
console.error("Failed to update displayUsername:", error)
}
+ analytics.onboardingNameSubmitted({ name_length: name.trim().length })
goToStep("greeting")
setIsSubmitting(false)
}
diff --git a/apps/web/app/new/page.tsx b/apps/web/app/new/page.tsx
index 31fab182..0eb8613c 100644
--- a/apps/web/app/new/page.tsx
+++ b/apps/web/app/new/page.tsx
@@ -1,6 +1,6 @@
"use client"
-import { useState, useCallback } from "react"
+import { useState, useCallback, useEffect } from "react"
import { Header } from "@/components/new/header"
import { ChatSidebar } from "@/components/new/chat"
import { MemoriesGrid } from "@/components/new/memories-grid"
@@ -16,7 +16,10 @@ import { useHotkeys } from "react-hotkeys-hook"
import { AnimatePresence } from "motion/react"
import { useIsMobile } from "@hooks/use-mobile"
import { useProject } from "@/stores"
-import { useQuickNoteDraftReset } from "@/stores/quick-note-draft"
+import {
+ useQuickNoteDraftReset,
+ useQuickNoteDraft,
+} from "@/stores/quick-note-draft"
import { analytics } from "@/lib/analytics"
import { useDocumentMutations } from "@/hooks/use-document-mutations"
import { useQuery } from "@tanstack/react-query"
@@ -42,6 +45,7 @@ export default function NewPage() {
const [searchPrefill, setSearchPrefill] = useState("")
const resetDraft = useQuickNoteDraftReset(selectedProject)
+ const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "")
const { noteMutation } = useDocumentMutations({
onClose: () => {
@@ -92,11 +96,19 @@ export default function NewPage() {
})
useHotkeys("mod+k", (e) => {
e.preventDefault()
+ analytics.searchOpened({ source: "hotkey" })
setIsSearchOpen(true)
})
const [isChatOpen, setIsChatOpen] = useState(!isMobile)
+ useEffect(() => {
+ setIsChatOpen(!isMobile)
+ }, [isMobile])
+
const handleOpenDocument = useCallback((document: DocumentWithMemories) => {
+ if (document.id) {
+ analytics.documentModalOpened({ document_id: document.id })
+ }
setSelectedDocument(document)
setIsDocumentModalOpen(true)
}, [])
@@ -104,28 +116,50 @@ export default function NewPage() {
const handleQuickNoteSave = useCallback(
(content: string) => {
if (content.trim()) {
- noteMutation.mutate({ content, project: selectedProject })
+ const hadPreviousContent = quickNoteDraft.trim().length > 0
+ noteMutation.mutate(
+ { content, project: selectedProject },
+ {
+ onSuccess: () => {
+ if (hadPreviousContent) {
+ analytics.quickNoteEdited()
+ } else {
+ analytics.quickNoteCreated()
+ }
+ },
+ },
+ )
}
},
- [selectedProject, noteMutation],
+ [selectedProject, noteMutation, quickNoteDraft],
)
const handleFullScreenSave = useCallback(
(content: string) => {
if (content.trim()) {
- noteMutation.mutate({ content, project: selectedProject })
+ const hadInitialContent = fullscreenInitialContent.trim().length > 0
+ noteMutation.mutate(
+ { content, project: selectedProject },
+ {
+ onSuccess: () => {
+ if (hadInitialContent) {
+ analytics.quickNoteEdited()
+ } else {
+ analytics.quickNoteCreated()
+ }
+ },
+ },
+ )
}
},
- [selectedProject, noteMutation],
+ [selectedProject, noteMutation, fullscreenInitialContent],
)
- const handleMaximize = useCallback(
- (content: string) => {
- setFullscreenInitialContent(content)
- setIsFullScreenNoteOpen(true)
- },
- [],
- )
+ const handleMaximize = useCallback((content: string) => {
+ analytics.fullscreenNoteModalOpened()
+ setFullscreenInitialContent(content)
+ setIsFullScreenNoteOpen(true)
+ }, [])
const handleHighlightsChat = useCallback((seed: string) => {
setQueuedChatSeed(seed)
@@ -133,6 +167,7 @@ export default function NewPage() {
}, [])
const handleHighlightsShowRelated = useCallback((query: string) => {
+ analytics.searchOpened({ source: "highlight_related" })
setSearchPrefill(query)
setIsSearchOpen(true)
}, [])
@@ -154,7 +189,10 @@ export default function NewPage() {
setIsMCPModalOpen(true)
}}
onOpenChat={() => setIsChatOpen(true)}
- onOpenSearch={() => setIsSearchOpen(true)}
+ onOpenSearch={() => {
+ analytics.searchOpened({ source: "header" })
+ setIsSearchOpen(true)
+ }}
/>
<main
key={`main-container-${isChatOpen}`}
diff --git a/apps/web/app/new/settings/page.tsx b/apps/web/app/new/settings/page.tsx
index f1972bb9..b2e35b7f 100644
--- a/apps/web/app/new/settings/page.tsx
+++ b/apps/web/app/new/settings/page.tsx
@@ -13,6 +13,7 @@ import ConnectionsMCP from "@/components/new/settings/connections-mcp"
import Support from "@/components/new/settings/support"
import { useRouter } from "next/navigation"
import { useIsMobile } from "@hooks/use-mobile"
+import { analytics } from "@/lib/analytics"
const TABS = ["account", "integrations", "connections", "support"] as const
type SettingsTab = (typeof TABS)[number]
@@ -158,6 +159,7 @@ export default function SettingsPage() {
const hash = window.location.hash
const tab = parseHashToTab(hash)
setActiveTab(tab)
+ analytics.settingsTabChanged({ tab })
// If no hash or invalid hash, push #account
if (!hash || !TABS.includes(hash.replace("#", "") as SettingsTab)) {
@@ -169,6 +171,7 @@ export default function SettingsPage() {
const handleHashChange = () => {
const tab = parseHashToTab(window.location.hash)
setActiveTab(tab)
+ analytics.settingsTabChanged({ tab })
}
window.addEventListener("hashchange", handleHashChange)
@@ -230,6 +233,7 @@ export default function SettingsPage() {
onClick={() => {
window.location.hash = item.id
setActiveTab(item.id)
+ analytics.settingsTabChanged({ tab: item.id })
}}
className={cn(
"rounded-xl transition-colors flex items-start gap-3 shrink-0",
diff --git a/apps/web/app/onboarding/page.tsx b/apps/web/app/onboarding/page.tsx
index dcf64ad0..cee9879b 100644
--- a/apps/web/app/onboarding/page.tsx
+++ b/apps/web/app/onboarding/page.tsx
@@ -4,6 +4,7 @@ import { OnboardingProvider } from "./onboarding-context"
import { OnboardingProgressBar } from "./progress-bar"
import { redirect } from "next/navigation"
import { OnboardingBackground } from "./onboarding-background"
+import { OnboardingWrapper } from "@/components/onboarding/onboarding-wrapper"
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Welcome to Supermemory",
@@ -16,11 +17,13 @@ export default function OnboardingPage() {
if (!session) redirect("/login")
return (
- <OnboardingProvider>
- <OnboardingProgressBar />
- <OnboardingBackground>
- <OnboardingForm />
- </OnboardingBackground>
- </OnboardingProvider>
+ <OnboardingWrapper>
+ <OnboardingProvider>
+ <OnboardingProgressBar />
+ <OnboardingBackground>
+ <OnboardingForm />
+ </OnboardingBackground>
+ </OnboardingProvider>
+ </OnboardingWrapper>
)
}
diff --git a/apps/web/components/new/add-space-modal.tsx b/apps/web/components/new/add-space-modal.tsx
index 63ee5e96..fdc5347f 100644
--- a/apps/web/components/new/add-space-modal.tsx
+++ b/apps/web/components/new/add-space-modal.tsx
@@ -9,6 +9,7 @@ import { XIcon, Loader2 } from "lucide-react"
import { Button } from "@ui/components/button"
import { useProjectMutations } from "@/hooks/use-project-mutations"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
+import { analytics } from "@/lib/analytics"
const EMOJI_LIST = [
"📁",
@@ -87,6 +88,7 @@ export function AddSpaceModal({
{ name: trimmedName, emoji: emoji || undefined },
{
onSuccess: () => {
+ analytics.spaceCreated()
handleClose()
},
},
diff --git a/apps/web/components/new/chat/index.tsx b/apps/web/components/new/chat/index.tsx
index 22a77812..51bbe455 100644
--- a/apps/web/components/new/chat/index.tsx
+++ b/apps/web/components/new/chat/index.tsx
@@ -1,6 +1,7 @@
"use client"
import { useState, useEffect, useCallback, useRef } from "react"
+import type { UIMessage } from "@ai-sdk/react"
import { motion, AnimatePresence } from "motion/react"
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
@@ -32,8 +33,7 @@ import { dmSansClassName } from "@/lib/fonts"
import ChatInput from "./input"
import ChatModelSelector from "./model-selector"
import { GradientLogo, LogoBgGradient } from "@ui/assets/Logo"
-import { useProject, usePersistentChat } from "@/stores"
-import { areUIMessageArraysEqual } from "@/stores/chat"
+import { useProject } from "@/stores"
import type { ModelId } from "@/lib/models"
import { SuperLoader } from "../../superloader"
import { UserMessage } from "./message/user-message"
@@ -135,23 +135,11 @@ export function ChatSidebar({
const pendingFollowUpGenerations = useRef<Set<string>>(new Set())
const messagesContainerRef = useRef<HTMLDivElement>(null)
const { selectedProject } = useProject()
- const {
- currentChatId,
- setCurrentChatId,
- setConversation,
- getCurrentConversation,
- } = usePersistentChat()
- const lastSavedMessagesRef = useRef<typeof messages | null>(null)
- const lastSavedActiveIdRef = useRef<string | null>(null)
- const lastLoadedChatIdRef = useRef<string | null>(null)
- const lastLoadedMessagesRef = useRef<typeof messages | null>(null)
-
- // Initialize chat ID if none exists
- useEffect(() => {
- if (!currentChatId) {
- setCurrentChatId(generateId())
- }
- }, [currentChatId, setCurrentChatId])
+ const [currentChatId, setCurrentChatId] = useState<string>(() => generateId())
+ const [pendingThreadLoad, setPendingThreadLoad] = useState<{
+ id: string
+ messages: UIMessage[]
+ } | null>(null)
// Adjust chat height based on scroll position (desktop only)
useEffect(() => {
@@ -195,58 +183,12 @@ export function ChatSidebar({
},
})
- // Restore messages from store when currentChatId changes
- useEffect(() => {
- if (currentChatId !== lastLoadedChatIdRef.current) {
- lastLoadedMessagesRef.current = null
- lastSavedMessagesRef.current = null
- }
-
- if (currentChatId === lastLoadedChatIdRef.current) {
- return
- }
-
- const msgs = getCurrentConversation()
-
- if (msgs && msgs.length > 0) {
- const currentMessages = lastLoadedMessagesRef.current
- if (!currentMessages || !areUIMessageArraysEqual(currentMessages, msgs)) {
- lastLoadedMessagesRef.current = msgs
- setMessages(msgs)
- }
- } else if (!currentChatId) {
- if (
- lastLoadedMessagesRef.current &&
- lastLoadedMessagesRef.current.length > 0
- ) {
- lastLoadedMessagesRef.current = []
- setMessages([])
- }
- }
-
- lastLoadedChatIdRef.current = currentChatId
- }, [currentChatId, getCurrentConversation, setMessages])
-
- // Persist messages to store whenever they change
useEffect(() => {
- const activeId = currentChatId
- if (!activeId || messages.length === 0) {
- return
- }
-
- if (activeId !== lastSavedActiveIdRef.current) {
- lastSavedMessagesRef.current = null
- lastSavedActiveIdRef.current = activeId
- }
-
- const lastSaved = lastSavedMessagesRef.current
- if (lastSaved && areUIMessageArraysEqual(lastSaved, messages)) {
- return
+ if (pendingThreadLoad && currentChatId === pendingThreadLoad.id) {
+ setMessages(pendingThreadLoad.messages)
+ setPendingThreadLoad(null)
}
-
- lastSavedMessagesRef.current = messages
- setConversation(activeId, messages)
- }, [messages, currentChatId, setConversation])
+ }, [currentChatId, pendingThreadLoad, setMessages])
// Generate follow-up questions after assistant messages are complete
useEffect(() => {
@@ -362,6 +304,7 @@ export function ChatSidebar({
const handleSend = () => {
if (!input.trim() || status === "submitted" || status === "streaming")
return
+ analytics.chatMessageSent({ source: "typed" })
sendMessage({ text: input })
setInput("")
scrollToBottom()
@@ -379,27 +322,50 @@ export function ChatSidebar({
}
const handleCopyMessage = useCallback((messageId: string, text: string) => {
+ analytics.chatMessageCopied({ message_id: messageId })
navigator.clipboard.writeText(text)
setCopiedMessageId(messageId)
setTimeout(() => setCopiedMessageId(null), 2000)
}, [])
- const handleLikeMessage = useCallback((messageId: string) => {
- setMessageFeedback((prev) => ({
- ...prev,
- [messageId]: prev[messageId] === "like" ? null : "like",
- }))
- }, [])
+ const handleLikeMessage = useCallback(
+ (messageId: string) => {
+ const wasLiked = messageFeedback[messageId] === "like"
+ setMessageFeedback((prev) => ({
+ ...prev,
+ [messageId]: prev[messageId] === "like" ? null : "like",
+ }))
+ if (!wasLiked) {
+ analytics.chatMessageLiked({ message_id: messageId })
+ }
+ },
+ [messageFeedback],
+ )
- const handleDislikeMessage = useCallback((messageId: string) => {
- setMessageFeedback((prev) => ({
- ...prev,
- [messageId]: prev[messageId] === "dislike" ? null : "dislike",
- }))
- }, [])
+ const handleDislikeMessage = useCallback(
+ (messageId: string) => {
+ const wasDisliked = messageFeedback[messageId] === "dislike"
+ setMessageFeedback((prev) => ({
+ ...prev,
+ [messageId]: prev[messageId] === "dislike" ? null : "dislike",
+ }))
+ if (!wasDisliked) {
+ analytics.chatMessageDisliked({ message_id: messageId })
+ }
+ },
+ [messageFeedback],
+ )
const handleToggleMemories = useCallback((messageId: string) => {
- setExpandedMemories((prev) => (prev === messageId ? null : messageId))
+ setExpandedMemories((prev) => {
+ const isExpanding = prev !== messageId
+ if (isExpanding) {
+ analytics.chatMemoryExpanded({ message_id: messageId })
+ } else {
+ analytics.chatMemoryCollapsed({ message_id: messageId })
+ }
+ return prev === messageId ? null : messageId
+ })
}, [])
const handleNewChat = useCallback(() => {
@@ -408,7 +374,7 @@ export function ChatSidebar({
setCurrentChatId(newId)
setMessages([])
setInput("")
- }, [setCurrentChatId, setMessages])
+ }, [setMessages])
const fetchThreads = useCallback(async () => {
setIsLoadingThreads(true)
@@ -428,41 +394,37 @@ export function ChatSidebar({
}
}, [selectedProject])
- const loadThread = useCallback(
- async (threadId: string) => {
- try {
- const response = await fetch(
- `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`,
- { credentials: "include" },
+ const loadThread = useCallback(async (threadId: string) => {
+ try {
+ const response = await fetch(
+ `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`,
+ { credentials: "include" },
+ )
+ if (response.ok) {
+ const data = await response.json()
+ const uiMessages = data.messages.map(
+ (m: {
+ id: string
+ role: string
+ parts: unknown
+ createdAt: string
+ }) => ({
+ id: m.id,
+ role: m.role,
+ parts: m.parts || [],
+ createdAt: new Date(m.createdAt),
+ }),
)
- if (response.ok) {
- const data = await response.json()
- setCurrentChatId(threadId)
- // Convert API messages to UIMessage format
- const uiMessages = data.messages.map(
- (m: {
- id: string
- role: string
- parts: unknown
- createdAt: string
- }) => ({
- id: m.id,
- role: m.role,
- parts: m.parts || [],
- createdAt: new Date(m.createdAt),
- }),
- )
- setMessages(uiMessages)
- setConversation(threadId, uiMessages) // persist messages to store
- setIsHistoryOpen(false)
- setConfirmingDeleteId(null)
- }
- } catch (error) {
- console.error("Failed to load thread:", error)
+ setCurrentChatId(threadId)
+ setPendingThreadLoad({ id: threadId, messages: uiMessages })
+ analytics.chatThreadLoaded({ thread_id: threadId })
+ setIsHistoryOpen(false)
+ setConfirmingDeleteId(null)
}
- },
- [setCurrentChatId, setMessages, setConversation],
- )
+ } catch (error) {
+ console.error("Failed to load thread:", error)
+ }
+ }, [])
const deleteThread = useCallback(
async (threadId: string) => {
@@ -472,6 +434,7 @@ export function ChatSidebar({
{ method: "DELETE", credentials: "include" },
)
if (response.ok) {
+ analytics.chatThreadDeleted({ thread_id: threadId })
setThreads((prev) => prev.filter((t) => t.id !== threadId))
if (currentChatId === threadId) {
handleNewChat()
@@ -524,6 +487,7 @@ export function ChatSidebar({
status !== "submitted" &&
status !== "streaming"
) {
+ analytics.chatMessageSent({ source: "highlight" })
sendMessage({ text: queuedMessage })
onConsumeQueuedMessage?.()
}
@@ -667,7 +631,8 @@ export function ChatSidebar({
variant="headers"
className="rounded-full text-base gap-2 h-10! border-[#73737333] bg-[#0D121A] cursor-pointer"
style={{
- boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
+ boxShadow:
+ "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
>
<HistoryIcon className="size-4 text-[#737373]" />
@@ -833,6 +798,8 @@ export function ChatSidebar({
{messages.length === 0 && (
<ChatEmptyStatePlaceholder
onSuggestionClick={(suggestion) => {
+ analytics.chatSuggestedQuestionClicked()
+ analytics.chatMessageSent({ source: "suggested" })
sendMessage({ text: suggestion })
}}
suggestions={emptyStateSuggestions}
@@ -883,6 +850,10 @@ export function ChatSidebar({
onDislike={handleDislikeMessage}
onToggleMemories={handleToggleMemories}
onQuestionClick={(question) => {
+ analytics.chatFollowUpClicked({
+ thread_id: currentChatId || undefined,
+ })
+ analytics.chatMessageSent({ source: "follow_up" })
setInput(question)
}}
/>
diff --git a/apps/web/components/new/chat/model-selector.tsx b/apps/web/components/new/chat/model-selector.tsx
index c1952bea..db77a5c7 100644
--- a/apps/web/components/new/chat/model-selector.tsx
+++ b/apps/web/components/new/chat/model-selector.tsx
@@ -6,6 +6,7 @@ import { Button } from "@ui/components/button"
import { dmSansClassName } from "@/lib/fonts"
import { ChevronDownIcon } from "lucide-react"
import { models, type ModelId, modelNames } from "@/lib/models"
+import { analytics } from "@/lib/analytics"
interface ChatModelSelectorProps {
selectedModel?: ModelId
@@ -28,6 +29,7 @@ export default function ChatModelSelector({
} else {
setInternalModel(modelId)
}
+ analytics.modelChanged({ model: modelId })
setIsOpen(false)
}
diff --git a/apps/web/components/new/document-cards/tweet-preview.tsx b/apps/web/components/new/document-cards/tweet-preview.tsx
index 2d9baacf..c9a30ecd 100644
--- a/apps/web/components/new/document-cards/tweet-preview.tsx
+++ b/apps/web/components/new/document-cards/tweet-preview.tsx
@@ -60,12 +60,12 @@ function CustomTweetHeader({
</div>
<div className="flex flex-col items-start">
<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]",
- dmSansClassName(),
- )}
- >
+ <p
+ className={cn(
+ "font-semibold leading-tight overflow-hidden text-[#fafafa] text-[12px] truncate tracking-[-0.12px]",
+ dmSansClassName(),
+ )}
+ >
{user.name}
</p>
{isVerified && <VerifiedBadge />}
diff --git a/apps/web/components/new/feedback-modal.tsx b/apps/web/components/new/feedback-modal.tsx
new file mode 100644
index 00000000..82f4623c
--- /dev/null
+++ b/apps/web/components/new/feedback-modal.tsx
@@ -0,0 +1,179 @@
+"use client"
+
+import { useState } from "react"
+import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@ui/components/dialog"
+import { Textarea } from "@ui/components/textarea"
+import { Button } from "@ui/components/button"
+import { cn } from "@lib/utils"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { XIcon, Loader2 } from "lucide-react"
+import { usePostHog } from "@lib/posthog"
+import { toast } from "sonner"
+
+interface FeedbackModalProps {
+ isOpen: boolean
+ onClose: () => void
+}
+
+const FEEDBACK_SURVEY_ID = "019bf2dd-f002-0000-d819-8a914cb23999"
+const FEEDBACK_QUESTION_ID = "0af81ccd-cb43-43a3-a61b-3a74c08a922a"
+const FEEDBACK_QUESTION = "What can we do to improve our product?"
+
+export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) {
+ const [feedback, setFeedback] = useState("")
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const posthog = usePostHog()
+
+ const handleSubmit = async () => {
+ if (!feedback.trim() || isSubmitting) return
+
+ setIsSubmitting(true)
+
+ try {
+ if (posthog.__loaded) {
+ const responseKey = FEEDBACK_QUESTION_ID.replace(/-/g, "_")
+ posthog.capture("survey sent", {
+ $survey_id: FEEDBACK_SURVEY_ID,
+ $survey_questions: [
+ {
+ id: FEEDBACK_QUESTION_ID,
+ question: FEEDBACK_QUESTION,
+ },
+ ],
+ [`$survey_response_${responseKey}`]: feedback.trim(),
+ $survey_name: "Nova feedback",
+ $survey_completed: true,
+ })
+ }
+
+ setFeedback("")
+ toast.success("Thank you for your feedback!")
+ onClose()
+ } catch (error) {
+ console.error("Failed to submit feedback:", error)
+ toast.error("Failed to submit feedback. Please try again.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleClose = () => {
+ if (!isSubmitting) {
+ setFeedback("")
+ onClose()
+ }
+ }
+
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+ if (
+ e.key === "Enter" &&
+ (e.metaKey || e.ctrlKey) &&
+ feedback.trim() &&
+ !isSubmitting
+ ) {
+ e.preventDefault()
+ handleSubmit()
+ }
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
+ <DialogContent
+ className={cn(
+ "w-[90%]! max-w-[500px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-4 rounded-[22px]",
+ dmSansClassName(),
+ )}
+ style={{
+ boxShadow:
+ "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
+ }}
+ showCloseButton={false}
+ >
+ <div className="flex flex-col gap-4">
+ <div className="flex justify-between items-start gap-4">
+ <DialogHeader className="pl-1 space-y-1 flex-1">
+ <DialogTitle
+ className={cn(
+ "font-semibold text-[#fafafa]",
+ dmSans125ClassName(),
+ )}
+ >
+ Feedback
+ </DialogTitle>
+ <p
+ className={cn(
+ "text-[#737373] font-medium text-[16px] leading-[1.35]",
+ )}
+ >
+ {FEEDBACK_QUESTION}
+ </p>
+ </DialogHeader>
+ <DialogPrimitive.Close
+ onClick={handleClose}
+ disabled={isSubmitting}
+ className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
+ style={{
+ boxShadow:
+ "0 0.711px 2.842px 0 rgba(0, 0, 0, 0.25), 0.178px 0.178px 0.178px 0 rgba(255, 255, 255, 0.10) inset",
+ }}
+ >
+ <XIcon className="size-4 text-[#737373]" />
+ <span className="sr-only">Close</span>
+ </DialogPrimitive.Close>
+ </div>
+
+ <div className="flex flex-col gap-3">
+ <Textarea
+ value={feedback}
+ onChange={(e) => setFeedback(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="Share your thoughts..."
+ className={cn(
+ "min-h-[120px] bg-[#0D121A] border border-[rgba(115,115,115,0.2)] text-[#fafafa] placeholder:text-[#737373] focus-visible:border-[#2261CA] focus-visible:ring-[#2261CA33] resize-none",
+ dmSansClassName(),
+ )}
+ disabled={isSubmitting}
+ />
+
+ <div className="flex justify-end gap-2">
+ <Button
+ variant="ghost"
+ onClick={handleClose}
+ disabled={isSubmitting}
+ className={cn(
+ "text-[#737373] hover:text-[#fafafa] hover:bg-[#293952]/40",
+ dmSansClassName(),
+ )}
+ >
+ Cancel
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={!feedback.trim() || isSubmitting}
+ className={cn(
+ "bg-[#2261CA] hover:bg-[#1a4fa0] text-white",
+ dmSansClassName(),
+ )}
+ >
+ {isSubmitting ? (
+ <>
+ <Loader2 className="size-4 animate-spin mr-2" />
+ Submitting...
+ </>
+ ) : (
+ "Submit"
+ )}
+ </Button>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/apps/web/components/new/header.tsx b/apps/web/components/new/header.tsx
index 9691f733..c244bf88 100644
--- a/apps/web/components/new/header.tsx
+++ b/apps/web/components/new/header.tsx
@@ -15,6 +15,7 @@ import {
HelpCircle,
MenuIcon,
MessageCircleIcon,
+ RotateCcw,
} from "lucide-react"
import { Button } from "@ui/components/button"
import { cn } from "@lib/utils"
@@ -34,6 +35,9 @@ import { useRouter } from "next/navigation"
import Link from "next/link"
import { SpaceSelector } from "./space-selector"
import { useIsMobile } from "@hooks/use-mobile"
+import { useOrgOnboarding } from "@hooks/use-org-onboarding"
+import { useState } from "react"
+import { FeedbackModal } from "./feedback-modal"
interface HeaderProps {
onAddMemory?: () => void
@@ -53,6 +57,17 @@ export function Header({
const { switchProject } = useProjectMutations()
const router = useRouter()
const isMobile = useIsMobile()
+ const { resetOrgOnboarded } = useOrgOnboarding()
+ const [isFeedbackOpen, setIsFeedbackOpen] = useState(false)
+
+ const handleTryOnboarding = () => {
+ resetOrgOnboarded()
+ router.push("/new/onboarding?step=input&flow=welcome")
+ }
+
+ const handleFeedback = () => {
+ setIsFeedbackOpen(true)
+ }
const displayName =
user?.displayUsername ||
@@ -214,6 +229,13 @@ export function Header({
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuItem
+ 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]" />
+ Feedback
+ </DropdownMenuItem>
+ <DropdownMenuItem
onClick={() => router.push("/new/settings")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
@@ -275,6 +297,16 @@ export function Header({
<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>
</>
)}
{user && (
@@ -316,6 +348,13 @@ export function Header({
<Settings className="h-4 w-4 text-[#737373]" />
Settings
</DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={handleTryOnboarding}
+ 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
+ </DropdownMenuItem>
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuItem
asChild
@@ -361,6 +400,7 @@ export function Header({
</DropdownMenu>
)}
</div>
+ <FeedbackModal isOpen={isFeedbackOpen} onClose={() => setIsFeedbackOpen(false)} />
</div>
)
}
diff --git a/apps/web/components/new/highlights-card.tsx b/apps/web/components/new/highlights-card.tsx
index eb3e0b44..8ca07637 100644
--- a/apps/web/components/new/highlights-card.tsx
+++ b/apps/web/components/new/highlights-card.tsx
@@ -12,6 +12,7 @@ import {
Link2,
} from "lucide-react"
import { Logo } from "@ui/assets/Logo"
+import { analytics } from "@/lib/analytics"
export type HighlightFormat = "paragraph" | "bullets" | "quote" | "one_liner"
@@ -83,12 +84,20 @@ export function HighlightsCard({
const handleChat = useCallback(() => {
if (!currentItem) return
+ analytics.highlightClicked({
+ highlight_id: currentItem.id,
+ action: "chat",
+ })
const seed = `Tell me more about "${currentItem.title}"`
onChat(seed)
}, [currentItem, onChat])
const handleShowRelated = useCallback(() => {
if (!currentItem) return
+ analytics.highlightClicked({
+ highlight_id: currentItem.id,
+ action: "related",
+ })
onShowRelated(currentItem.query || currentItem.title)
}, [currentItem, onShowRelated])
diff --git a/apps/web/components/new/mobile-banner.tsx b/apps/web/components/new/mobile-banner.tsx
new file mode 100644
index 00000000..245b6952
--- /dev/null
+++ b/apps/web/components/new/mobile-banner.tsx
@@ -0,0 +1,24 @@
+"use client"
+
+import { useIsMobile } from "@hooks/use-mobile"
+import { cn } from "@lib/utils"
+
+export function MobileBanner() {
+ const isMobile = useIsMobile()
+
+ if (!isMobile) {
+ return null
+ }
+
+ return (
+ <div
+ className={cn(
+ "bg-yellow-50 dark:bg-yellow-950/20 border-b border-yellow-200 dark:border-yellow-900/30",
+ "px-4 py-2 text-xs text-yellow-800 dark:text-yellow-200 text-center",
+ )}
+ id="mobile-development-banner"
+ >
+ 🚧 Mobile responsive in development. Desktop recommended.
+ </div>
+ )
+}
diff --git a/apps/web/components/new/onboarding/setup/chat-sidebar.tsx b/apps/web/components/new/onboarding/setup/chat-sidebar.tsx
index 22e8cae1..47af432d 100644
--- a/apps/web/components/new/onboarding/setup/chat-sidebar.tsx
+++ b/apps/web/components/new/onboarding/setup/chat-sidebar.tsx
@@ -6,7 +6,13 @@ import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
import NovaOrb from "@/components/nova/nova-orb"
import { Button } from "@ui/components/button"
-import { PanelRightCloseIcon, SendIcon, CheckIcon, XIcon } from "lucide-react"
+import {
+ PanelRightCloseIcon,
+ SendIcon,
+ CheckIcon,
+ XIcon,
+ Loader2,
+} from "lucide-react"
import { collectValidUrls } from "@/lib/url-helpers"
import { $fetch } from "@lib/api"
import { cn } from "@lib/utils"
@@ -61,10 +67,14 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
"correct" | "incorrect" | null
>(null)
const [isConfirmed, setIsConfirmed] = useState(false)
+ const [processingByUrl, setProcessingByUrl] = useState<Record<string, boolean>>(
+ {},
+ )
const displayedMemoriesRef = useRef<Set<string>>(new Set())
const contextInjectedRef = useRef(false)
const draftsBuiltRef = useRef(false)
const isProcessingRef = useRef(false)
+ const draftRequestIdRef = useRef(0)
const {
messages: chatMessages,
@@ -225,9 +235,27 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
if (!hasContent) return
+ const requestId = ++draftRequestIdRef.current
+
setIsFetchingDrafts(true)
const drafts: DraftDoc[] = []
+ const urls = collectValidUrls(formData.linkedin, formData.otherLinks)
+ const allProcessingUrls: string[] = [...urls]
+ if (formData.twitter) {
+ allProcessingUrls.push(formData.twitter)
+ }
+
+ if (allProcessingUrls.length > 0) {
+ setProcessingByUrl((prev) => {
+ const next = { ...prev }
+ for (const url of allProcessingUrls) {
+ next[url] = true
+ }
+ return next
+ })
+ }
+
try {
if (formData.description?.trim()) {
drafts.push({
@@ -241,37 +269,66 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
})
}
- const urls = collectValidUrls(formData.linkedin, formData.otherLinks)
+ // Fetch each URL separately for per-link loading state
+ const linkPromises = urls.map(async (url) => {
+ try {
+ const response = await fetch("/api/onboarding/extract-content", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ urls: [url] }),
+ })
+ const data = await response.json()
+ return data.results?.[0] || null
+ } catch {
+ return null
+ } finally {
+ // Clear this URL's processing state
+ if (draftRequestIdRef.current === requestId) {
+ setProcessingByUrl((prev) => ({ ...prev, [url]: false }))
+ }
+ }
+ })
+
+ // Fetch X/Twitter research
+ const xResearchPromise = formData.twitter
+ ? (async () => {
+ try {
+ const response = await fetch("/api/onboarding/research", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ xUrl: formData.twitter,
+ name: user?.name,
+ email: user?.email,
+ }),
+ })
+ if (!response.ok) return null
+ const data = await response.json()
+ return data?.text?.trim() || null
+ } catch {
+ return null
+ } finally {
+ // Clear twitter URL's processing state
+ if (draftRequestIdRef.current === requestId) {
+ setProcessingByUrl((prev) => ({
+ ...prev,
+ [formData.twitter]: false,
+ }))
+ }
+ }
+ })()
+ : Promise.resolve(null)
const [exaResults, xResearchResult] = await Promise.all([
- urls.length > 0
- ? fetch("/api/onboarding/extract-content", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ urls }),
- })
- .then((r) => r.json())
- .then((data) => data.results || [])
- .catch(() => [])
- : Promise.resolve([]),
- formData.twitter
- ? fetch("/api/onboarding/research", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- xUrl: formData.twitter,
- name: user?.name,
- email: user?.email,
- }),
- })
- .then((r) => (r.ok ? r.json() : null))
- .then((data) => data?.text?.trim() || null)
- .catch(() => null)
- : Promise.resolve(null),
+ Promise.all(linkPromises),
+ xResearchPromise,
])
+ // Guard against stale request completing after a newer one
+ if (draftRequestIdRef.current !== requestId) return
+
for (const result of exaResults) {
- if (result.text || result.description) {
+ if (result && (result.text || result.description)) {
drafts.push({
kind: "link",
content: result.text || result.description || "",
@@ -304,7 +361,9 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
} catch (error) {
console.warn("Error building draft docs:", error)
} finally {
- setIsFetchingDrafts(false)
+ if (draftRequestIdRef.current === requestId) {
+ setIsFetchingDrafts(false)
+ }
}
}, [formData, user])
@@ -502,18 +561,23 @@ export function ChatSidebar({ formData }: ChatSidebarProps) {
{msg.type === "formData" && (
<div className="bg-[#293952]/40 rounded-lg p-2 px-3 space-y-1 flex-1">
{msg.title && (
- <h3
- className="text-sm font-medium"
- style={{
- background:
- "linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
- WebkitBackgroundClip: "text",
- WebkitTextFillColor: "transparent",
- backgroundClip: "text",
- }}
- >
- {msg.title}
- </h3>
+ <div className="flex items-center gap-2">
+ <h3
+ className="text-sm font-medium"
+ style={{
+ background:
+ "linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
+ WebkitBackgroundClip: "text",
+ WebkitTextFillColor: "transparent",
+ backgroundClip: "text",
+ }}
+ >
+ {msg.title}
+ </h3>
+ {msg.url && processingByUrl[msg.url] && (
+ <Loader2 className="h-3 w-3 animate-spin text-blue-400" />
+ )}
+ </div>
)}
{msg.url && (
<a
diff --git a/apps/web/components/new/onboarding/setup/integrations-step.tsx b/apps/web/components/new/onboarding/setup/integrations-step.tsx
index 75e0d782..951f26c0 100644
--- a/apps/web/components/new/onboarding/setup/integrations-step.tsx
+++ b/apps/web/components/new/onboarding/setup/integrations-step.tsx
@@ -7,7 +7,8 @@ import { XBookmarksDetailView } from "@/components/new/onboarding/x-bookmarks-de
import { useRouter } from "next/navigation"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
-import { useOnboardingStorage } from "@hooks/use-onboarding-storage"
+import { useOrgOnboarding } from "@hooks/use-org-onboarding"
+import { analytics } from "@/lib/analytics"
const integrationCards = [
{
@@ -60,10 +61,11 @@ const integrationCards = [
export function IntegrationsStep() {
const router = useRouter()
const [selectedCard, setSelectedCard] = useState<string | null>(null)
- const { markOnboardingCompleted } = useOnboardingStorage()
+ const { markOrgOnboarded } = useOrgOnboarding()
const handleContinue = () => {
- markOnboardingCompleted()
+ markOrgOnboarded()
+ analytics.onboardingCompleted()
router.push("/new")
}
@@ -108,11 +110,22 @@ export function IntegrationsStep() {
)}
onClick={() => {
if (card.title === "Capture") {
+ analytics.onboardingChromeExtensionClicked({
+ source: "onboarding",
+ })
window.open(
"https://chromewebstore.google.com/detail/supermemory/afpgkkipfdpeaflnpoaffkcankadgjfc",
"_blank",
)
} else {
+ analytics.onboardingIntegrationClicked({
+ integration: card.title,
+ })
+ if (card.title === "Connect to AI") {
+ analytics.onboardingMcpDetailOpened()
+ } else if (card.title === "Import") {
+ analytics.onboardingXBookmarksDetailOpened()
+ }
setSelectedCard(card.title)
}
}}
diff --git a/apps/web/components/new/onboarding/setup/relatable-question.tsx b/apps/web/components/new/onboarding/setup/relatable-question.tsx
index 5edb4a99..b65bdab4 100644
--- a/apps/web/components/new/onboarding/setup/relatable-question.tsx
+++ b/apps/web/components/new/onboarding/setup/relatable-question.tsx
@@ -6,6 +6,7 @@ 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 = [
{
@@ -35,6 +36,10 @@ export function RelatableQuestion() {
const [selectedOptions, setSelectedOptions] = useState<number[]>([])
const handleContinueOrSkip = () => {
+ const selectedTexts = selectedOptions.map(
+ (idx) => relatableOptions[idx]?.text || "",
+ )
+ analytics.onboardingRelatableSelected({ options: selectedTexts })
router.push("/new/onboarding/setup?step=integrations")
}
diff --git a/apps/web/components/new/onboarding/welcome/profile-step.tsx b/apps/web/components/new/onboarding/welcome/profile-step.tsx
index 34393dad..8d05997a 100644
--- a/apps/web/components/new/onboarding/welcome/profile-step.tsx
+++ b/apps/web/components/new/onboarding/welcome/profile-step.tsx
@@ -10,6 +10,7 @@ import {
toXProfileUrl,
toLinkedInProfileUrl,
} from "@/lib/url-helpers"
+import { analytics } from "@/lib/analytics"
interface ProfileStepProps {
onSubmit: (data: {
@@ -291,6 +292,12 @@ export function ProfileStep({ onSubmit }: ProfileStepProps) {
description: description,
otherLinks: otherLinks.filter((l) => l.trim()),
}
+ analytics.onboardingProfileSubmitted({
+ has_twitter: !!twitterHandle.trim(),
+ has_linkedin: !!linkedinProfile.trim(),
+ other_links_count: otherLinks.filter((l) => l.trim()).length,
+ description_length: description.trim().length,
+ })
onSubmit(formData)
router.push("/new/onboarding/setup?step=relatable")
}}
diff --git a/apps/web/components/new/settings/integrations.tsx b/apps/web/components/new/settings/integrations.tsx
index deccf5e4..f28695e7 100644
--- a/apps/web/components/new/settings/integrations.tsx
+++ b/apps/web/components/new/settings/integrations.tsx
@@ -283,6 +283,7 @@ export default function Integrations() {
"_blank",
"noopener,noreferrer",
)
+ analytics.onboardingChromeExtensionClicked({ source: "settings" })
analytics.extensionInstallClicked()
}
@@ -310,6 +311,7 @@ export default function Integrations() {
const handleRaycastInstall = () => {
window.open(RAYCAST_EXTENSION_URL, "_blank")
+ analytics.onboardingChromeExtensionClicked({ source: "settings" })
analytics.extensionInstallClicked()
}
diff --git a/apps/web/components/new/space-selector.tsx b/apps/web/components/new/space-selector.tsx
index be2fcb50..45b51512 100644
--- a/apps/web/components/new/space-selector.tsx
+++ b/apps/web/components/new/space-selector.tsx
@@ -27,6 +27,7 @@ import {
SelectValue,
} from "@repo/ui/components/select"
import { Button } from "@repo/ui/components/button"
+import { analytics } from "@/lib/analytics"
export interface SpaceSelectorProps {
value: string
@@ -98,6 +99,9 @@ export function SpaceSelector({
const selectedProjectEmoji = selectedProject.emoji || "📁"
const handleSelect = (containerTag: string) => {
+ if (containerTag !== value) {
+ analytics.spaceSwitched({ space_id: containerTag })
+ }
onValueChange(containerTag)
setIsOpen(false)
}
diff --git a/apps/web/components/onboarding/new-onboarding-modal.tsx b/apps/web/components/onboarding/new-onboarding-modal.tsx
new file mode 100644
index 00000000..5dc545c1
--- /dev/null
+++ b/apps/web/components/onboarding/new-onboarding-modal.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { useFeatureFlagEnabled } from "posthog-js/react"
+import { useRouter } from "next/navigation"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@ui/components/dialog"
+import { Button } from "@ui/components/button"
+import { useIsMobile } from "@hooks/use-mobile"
+
+export function NewOnboardingModal() {
+ const router = useRouter()
+ const flagEnabled = useFeatureFlagEnabled("nova-alpha-access")
+ const isMobile = useIsMobile()
+ const [open, setOpen] = useState(false)
+
+ useEffect(() => {
+ if (flagEnabled) {
+ setOpen(true)
+ }
+ }, [flagEnabled])
+
+ const handleContinue = () => {
+ setOpen(false)
+ router.push("/new/onboarding")
+ }
+
+ if (!flagEnabled) {
+ return null
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={(isOpen) => {
+ if (!isOpen) {
+ setOpen(false)
+ }
+ }}>
+ <DialogContent onInteractOutside={(e) => e.preventDefault()}>
+ <DialogHeader>
+ <DialogTitle>Experience the new onboarding</DialogTitle>
+ <DialogDescription>
+ We've redesigned the onboarding experience. Would you like to try
+ it?
+ {isMobile && (
+ <span className="block mt-2 text-yellow-600 dark:text-yellow-500">
+ Desktop view is recommended for the best experience.
+ </span>
+ )}
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setOpen(false)}>
+ Stay here
+ </Button>
+ <Button onClick={handleContinue}>Continue to new onboarding</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/apps/web/components/onboarding/onboarding-wrapper.tsx b/apps/web/components/onboarding/onboarding-wrapper.tsx
new file mode 100644
index 00000000..ee1fcabb
--- /dev/null
+++ b/apps/web/components/onboarding/onboarding-wrapper.tsx
@@ -0,0 +1,12 @@
+"use client"
+
+import { NewOnboardingModal } from "./new-onboarding-modal"
+
+export function OnboardingWrapper({ children }: { children: React.ReactNode }) {
+ return (
+ <>
+ <NewOnboardingModal />
+ {children}
+ </>
+ )
+}
diff --git a/apps/web/hooks/use-document-mutations.ts b/apps/web/hooks/use-document-mutations.ts
index ea3c3783..3480c247 100644
--- a/apps/web/hooks/use-document-mutations.ts
+++ b/apps/web/hooks/use-document-mutations.ts
@@ -359,7 +359,8 @@ export function useDocumentMutations({
return response.data
},
- onSuccess: () => {
+ onSuccess: (_data, variables) => {
+ analytics.documentEdited({ document_id: variables.documentId })
toast.success("Document saved successfully!")
queryClient.invalidateQueries({
queryKey: ["documents-with-memories"],
@@ -451,7 +452,8 @@ export function useDocumentMutations({
description: _error instanceof Error ? _error.message : "Unknown error",
})
},
- onSuccess: () => {
+ onSuccess: (_data, variables) => {
+ analytics.documentDeleted({ document_id: variables.documentId })
toast.success("Document deleted successfully!")
queryClient.invalidateQueries({
queryKey: ["documents-with-memories"],
diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts
index 84eda62b..6d8c00eb 100644
--- a/apps/web/lib/analytics.ts
+++ b/apps/web/lib/analytics.ts
@@ -62,4 +62,105 @@ export const analytics = {
mcpModalOpened: () => safeCapture("mcp_modal_opened"),
addDocumentModalOpened: () => safeCapture("add_document_modal_opened"),
+
+ // onboarding analytics
+ onboardingStepViewed: (props: { step: string; trigger: "user" | "auto" }) =>
+ safeCapture("onboarding_step_viewed", props),
+
+ onboardingNameSubmitted: (props: { name_length: number }) =>
+ safeCapture("onboarding_name_submitted", props),
+
+ onboardingProfileSubmitted: (props: {
+ has_twitter: boolean
+ has_linkedin: boolean
+ other_links_count: number
+ description_length: number
+ }) => safeCapture("onboarding_profile_submitted", props),
+
+ onboardingRelatableSelected: (props: { options: string[] }) =>
+ safeCapture("onboarding_relatable_selected", props),
+
+ onboardingIntegrationClicked: (props: { integration: string }) =>
+ safeCapture("onboarding_integration_clicked", props),
+
+ onboardingChromeExtensionClicked: (props: {
+ source: "onboarding" | "settings"
+ }) => safeCapture("onboarding_chrome_extension_clicked", props),
+
+ onboardingMcpDetailOpened: () => safeCapture("onboarding_mcp_detail_opened"),
+
+ onboardingXBookmarksDetailOpened: () =>
+ safeCapture("onboarding_x_bookmarks_detail_opened"),
+
+ onboardingCompleted: () => safeCapture("onboarding_completed"),
+
+ // main app analytics
+ searchOpened: (props: {
+ source: "hotkey" | "header" | "highlight_related"
+ }) => safeCapture("search_opened", props),
+
+ documentModalOpened: (props: { document_id: string }) =>
+ safeCapture("document_modal_opened", props),
+
+ fullscreenNoteModalOpened: () => safeCapture("fullscreen_note_modal_opened"),
+
+ highlightClicked: (props: {
+ highlight_id: string
+ action: "chat" | "related"
+ }) => safeCapture("highlight_clicked", props),
+
+ // chat analytics
+ chatMessageSent: (props: {
+ source: "typed" | "suggested" | "highlight" | "follow_up"
+ }) => safeCapture("chat_message_sent", props),
+
+ chatFollowUpClicked: (props: { thread_id?: string }) =>
+ safeCapture("chat_follow_up_clicked", props),
+
+ chatSuggestedQuestionClicked: () =>
+ safeCapture("chat_suggested_question_clicked"),
+
+ chatMessageLiked: (props: { message_id: string }) =>
+ safeCapture("chat_message_liked", props),
+
+ chatMessageDisliked: (props: { message_id: string }) =>
+ safeCapture("chat_message_disliked", props),
+
+ chatMessageCopied: (props: { message_id: string }) =>
+ safeCapture("chat_message_copied", props),
+
+ chatMemoryExpanded: (props: { message_id: string }) =>
+ safeCapture("chat_memory_expanded", props),
+
+ chatMemoryCollapsed: (props: { message_id: string }) =>
+ safeCapture("chat_memory_collapsed", props),
+
+ chatThreadLoaded: (props: { thread_id: string }) =>
+ safeCapture("chat_thread_loaded", props),
+
+ chatThreadDeleted: (props: { thread_id: string }) =>
+ safeCapture("chat_thread_deleted", props),
+
+ modelChanged: (props: { model: string }) =>
+ safeCapture("model_changed", props),
+
+ // settings / spaces / docs analytics
+ settingsTabChanged: (props: {
+ tab: "account" | "integrations" | "connections" | "support"
+ }) => safeCapture("settings_tab_changed", props),
+
+ spaceCreated: () => safeCapture("space_created"),
+
+ spaceSwitched: (props: { space_id: string }) =>
+ safeCapture("space_switched", props),
+
+ quickNoteCreated: () => safeCapture("quick_note_created"),
+
+ quickNoteEdited: () => safeCapture("quick_note_edited"),
+
+ documentDeleted: (props: { document_id: string }) =>
+ safeCapture("document_deleted", props),
+
+ documentEdited: (props: { document_id: string }) =>
+ safeCapture("document_edited", props),
}
diff --git a/apps/web/stores/quick-note-draft.ts b/apps/web/stores/quick-note-draft.ts
index afd2bc6d..31efa1d7 100644
--- a/apps/web/stores/quick-note-draft.ts
+++ b/apps/web/stores/quick-note-draft.ts
@@ -35,9 +35,7 @@ export const useQuickNoteDraftStore = create<QuickNoteDraftState>()(
)
export function useQuickNoteDraft(projectId: string) {
- const draft = useQuickNoteDraftStore(
- (s) => s.draftByProject[projectId] ?? "",
- )
+ const draft = useQuickNoteDraftStore((s) => s.draftByProject[projectId] ?? "")
const setDraft = useQuickNoteDraftStore((s) => s.setDraft)
const resetDraft = useQuickNoteDraftStore((s) => s.resetDraft)