@@ -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/components/new/header.tsx b/apps/web/components/new/header.tsx
index 9691f733..4275e5f8 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,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
+
+
+ Restart Onboarding
+
(null)
const [isConfirmed, setIsConfirmed] = useState(false)
+ const [processingByUrl, setProcessingByUrl] = useState>(
+ {},
+ )
const displayedMemoriesRef = useRef>(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" && (
{msg.title && (
-
- {msg.title}
-
+
+
+ {msg.title}
+
+ {msg.url && processingByUrl[msg.url] && (
+
+ )}
+
)}
{msg.url && (
(null)
- const { markOnboardingCompleted } = useOnboardingStorage()
+ const { markOrgOnboarded } = useOrgOnboarding()
const handleContinue = () => {
+ markOrgOnboarded()
analytics.onboardingCompleted()
- markOnboardingCompleted()
router.push("/new")
}
diff --git a/packages/hooks/use-org-onboarding.ts b/packages/hooks/use-org-onboarding.ts
new file mode 100644
index 00000000..e71eab8f
--- /dev/null
+++ b/packages/hooks/use-org-onboarding.ts
@@ -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,
+ }
+}
diff --git a/packages/lib/auth-context.tsx b/packages/lib/auth-context.tsx
index 4bfdc2d7..67e57d49 100644
--- a/packages/lib/auth-context.tsx
+++ b/packages/lib/auth-context.tsx
@@ -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
+ updateOrgMetadata: (
+ partial: Record,
+ ) => void
}
const AuthContext = createContext(undefined)
@@ -35,6 +39,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setOrg(activeOrg)
}
+ const updateOrgMetadata = useCallback(
+ (partial: Record) => {
+ 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}