feat: onboarding config, reset onboarding, xai agentic migration (#701)

- Created a new `useOrgOnboarding` hook that uses `org.metadata.isOnboarded` to track onboarding state
- Updated the home page to conditionally use either the old localStorage-based onboarding or the new DB-backed onboarding based on feature flag
- Added a "Restart Onboarding" option in the user dropdown menu
- Improved the onboarding chat sidebar with per-link loading indicators
- Enhanced the X/Twitter research API to better handle different URL formats
- Updated the integrations step to use the new onboarding completion method
- Added `updateOrgMetadata` function to the auth context for easier metadata updates
This commit is contained in:
MaheshtheDev 2026-01-25 01:04:15 +00:00
parent 134861de3d
commit 6834bc6876
7 changed files with 285 additions and 84 deletions

View file

@ -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
}

View file

@ -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],
}),
},
})

View file

@ -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,7 @@ 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"
interface HeaderProps {
onAddMemory?: () => void
@ -53,6 +55,12 @@ export function Header({
const { switchProject } = useProjectMutations()
const router = useRouter()
const isMobile = useIsMobile()
const { resetOrgOnboarded } = useOrgOnboarding()
const handleTryOnboarding = () => {
resetOrgOnboarded()
router.push("/new/onboarding?step=input&flow=welcome")
}
const displayName =
user?.displayUsername ||
@ -316,6 +324,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

View file

@ -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

View file

@ -7,7 +7,7 @@ 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 = [
@ -61,11 +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 = () => {
markOrgOnboarded()
analytics.onboardingCompleted()
markOnboardingCompleted()
router.push("/new")
}

View file

@ -0,0 +1,85 @@
"use client"
import { useCallback, useMemo } from "react"
import { useAuth } from "@lib/auth-context"
import { authClient } from "@lib/auth"
/**
* DB-backed onboarding completion hook for the new app flow.
* Uses consumer org `metadata.isOnboarded` instead of localStorage.
*
* TODO: remove this after the feature flag is removed
* This hook is for the new app flow only (feature-flagged `nova-alpha-access`).
* The old onboarding flow will continue to use `useOnboardingStorage` (localStorage).
*/
export function useOrgOnboarding() {
const { org, updateOrgMetadata } = useAuth()
const isOrgOnboarded = useMemo(() => {
if (!org) return null
return org.metadata?.isOnboarded === true
}, [org])
const markOrgOnboarded = useCallback(() => {
if (!org?.id) {
console.error("No organization context when marking as onboarded")
return
}
// Optimistic update: update in-memory state immediately
updateOrgMetadata({ isOnboarded: true })
authClient.organization
.update({
organizationId: org.id,
data: {
metadata: {
...org.metadata,
isOnboarded: true,
},
},
})
.catch((error) => {
console.error("Failed to mark organization as onboarded:", error)
updateOrgMetadata({ isOnboarded: false })
})
}, [org, updateOrgMetadata])
const resetOrgOnboarded = useCallback(() => {
if (!org?.id) {
console.error("No organization context when resetting onboarding")
return
}
// Optimistic update: update in-memory state immediately
updateOrgMetadata({ isOnboarded: false })
authClient.organization
.update({
organizationId: org.id,
data: {
metadata: {
...org.metadata,
isOnboarded: false,
},
},
})
.catch((error) => {
console.error("Failed to reset organization onboarding:", error)
updateOrgMetadata({ isOnboarded: true })
})
}, [org, updateOrgMetadata])
const shouldShowOnboarding = useCallback(() => {
if (isOrgOnboarded === null) return null // Still loading (org not ready)
return !isOrgOnboarded
}, [isOrgOnboarded])
return {
isOrgOnboarded,
markOrgOnboarded,
resetOrgOnboarded,
shouldShowOnboarding,
isLoading: org === null,
}
}

View file

@ -3,6 +3,7 @@
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useState,
@ -17,6 +18,9 @@ interface AuthContextType {
user: SessionData["user"] | null
org: Organization | null
setActiveOrg: (orgSlug: string) => Promise<void>
updateOrgMetadata: (
partial: Record<string, unknown>,
) => void
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
@ -35,6 +39,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setOrg(activeOrg)
}
const updateOrgMetadata = useCallback(
(partial: Record<string, unknown>) => {
setOrg((prev) => {
if (!prev) return prev
return {
...prev,
metadata: {
...prev.metadata,
...partial,
},
}
})
},
[],
)
// biome-ignore lint/correctness/useExhaustiveDependencies: ignoring the setActiveOrg dependency
useEffect(() => {
if (session?.session.activeOrganizationId) {
@ -99,6 +119,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
session: session?.session ?? null,
user: session?.user ?? null,
setActiveOrg,
updateOrgMetadata,
}}
>
{children}