diff --git a/apps/web/app/(app)/brain/page.tsx b/apps/web/app/(app)/brain/page.tsx new file mode 100644 index 00000000..b27898e9 --- /dev/null +++ b/apps/web/app/(app)/brain/page.tsx @@ -0,0 +1,136 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { useRouter } from "next/navigation" +import { Loader2 } from "lucide-react" +import { authClient } from "@lib/auth" +import { useAuth } from "@lib/auth-context" +import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants" +import { cn } from "@lib/utils" +import { analytics } from "@/lib/analytics" +import { dmSansClassName } from "@/lib/fonts" +import { + detectModeFromEmail, + generateOrgSlug, + workspaceDomainFromEmail, + workspaceNameFromDomain, + workspaceNameFromEmail, + type BrainMetadata, +} from "@/components/onboarding-brain/types" + +const BACKEND = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + +// No forms: sign up → org auto-created → Slack install. The bot asks the rest. +export default function BrainEntryPage() { + const router = useRouter() + const { user, org, organizations, setActiveOrg, refetchOrganizations } = + useAuth() + const { email = null } = user ?? {} + const [error, setError] = useState(null) + const [attempt, setAttempt] = useState(0) + const startedRef = useRef(false) + + const run = useCallback(async () => { + if (organizations && organizations.length > 0) { + const active = + org ?? organizations.find((o) => o.slug) ?? organizations[0] + if (!org && active?.slug) await setActiveOrg(active.slug) + const status = await fetch(`${BACKEND}/brain/slack/status`, { + credentials: "include", + headers: { "X-App-Source": "nova" }, + }) + .then((res) => (res.ok ? res.json() : null)) + .catch(() => null) + if (status?.connected) { + router.replace("/") + return + } + window.location.href = `${BACKEND}/brain/slack/oauth/install` + return + } + + // Personal email → shell org; the Slack workspace resolves identity later. + const domain = + detectModeFromEmail(email) === "team" + ? workspaceDomainFromEmail(email) + : null + const name = + (domain + ? workspaceNameFromDomain(domain) + : workspaceNameFromEmail(email)) || "Company Brain" + const metadata: BrainMetadata & { signupSource: string } = { + signupSource: "consumer", + brainOnboardingVersion: "v1", + brainMode: "team", + brainWorkspaceName: name, + brainWorkspaceDomain: domain, + // Always the shared Team Brain; the CB UI never selects a slug space. + brainContainerTag: SHARED_TEAM_BRAIN_TAG, + } + const result = await authClient.organization.create({ + name, + slug: generateOrgSlug(name), + metadata, + }) + if (result.error || !result.data?.slug) { + throw new Error(result.error?.message || "Could not create workspace.") + } + await setActiveOrg(result.data.slug) + await refetchOrganizations() + analytics.onboardingWorkspaceCreated({ + mode: "team", + has_about: false, + has_domain: Boolean(domain), + }) + window.location.href = `${BACKEND}/brain/slack/oauth/install` + }, [email, org, organizations, setActiveOrg, refetchOrganizations, router]) + + // Sole caller of run(): the guard is only released on failure, so a dep change + // mid-flight can't kick off a second org creation. + // biome-ignore lint/correctness/useExhaustiveDependencies: attempt retriggers the retry + useEffect(() => { + if (!user || organizations === null || startedRef.current) return + startedRef.current = true + run().catch((e) => { + startedRef.current = false + console.error("Brain entry failed:", e) + setError(e instanceof Error ? e.message : "Something went wrong.") + }) + }, [user, organizations, run, attempt]) + + return ( +
+ {error ? ( + <> +

+ Couldn't set up your Company Brain +

+

{error}

+ + + ) : ( + <> + +

+ Setting up your Company Brain… +

+ + )} +
+ ) +} diff --git a/apps/web/components/app-experience.tsx b/apps/web/components/app-experience.tsx index 402fd6fd..28b93b18 100644 --- a/apps/web/components/app-experience.tsx +++ b/apps/web/components/app-experience.tsx @@ -29,6 +29,7 @@ import { RaycastDetail } from "@/components/integrations/raycast-detail" import { PluginsDetail } from "@/components/integrations/plugins-detail" import { AnimatedGradientBackground } from "@/components/animated-gradient-background" import { OnboardingConfetti } from "@/components/onboarding-brain/onboarding-confetti" +import { SlackHandoff } from "@/components/onboarding-brain/slack-handoff" import { AddDocumentModal } from "@/components/add-document" import { DocumentModal } from "@/components/document-modal" import { DocumentsCommandPalette } from "@/components/documents-command-palette" @@ -138,16 +139,23 @@ export function AppExperience() { const isCompanyBrain = useHasCompanyBrain() const backendUrl = getBackendUrl() - // Slack OAuth redirects back here with ?slack=connected — toast then clean up. + // ?slack=connected: CB orgs get the handoff takeover, everyone else a toast. + const [slackHandoff, setSlackHandoff] = useState<{ + team: string | null + } | null>(null) useEffect(() => { const sp = new URLSearchParams(window.location.search) if (sp.get("slack") !== "connected") return const team = sp.get("team") - toast.success( - team - ? `Supermemory added to ${team} on Slack` - : "Supermemory added to your Slack", - ) + if (isCompanyBrain) { + setSlackHandoff({ team }) + } else { + toast.success( + team + ? `Supermemory added to ${team} on Slack` + : "Supermemory added to your Slack", + ) + } sp.delete("slack") sp.delete("team") const qs = sp.toString() @@ -156,7 +164,7 @@ export function AppExperience() { "", window.location.pathname + (qs ? `?${qs}` : ""), ) - }, []) + }, [isCompanyBrain]) const queryClient = useQueryClient() const [highlightsForceAt, setHighlightsForceAt] = useState(0) @@ -616,6 +624,12 @@ export function AppExperience() { return ( + {slackHandoff && ( + setSlackHandoff(null)} + /> + )}
{ if (isGuestPublicAppPage) return diff --git a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx index 64937900..5f26f00b 100644 --- a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx +++ b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx @@ -76,6 +76,11 @@ export function CompanyBrainOnboarding({ const queryClient = useQueryClient() const { status: researchStatus } = useResearchStatus(phase === "research") const researchDone = researchStatus === "done" + // One-shot retry: re-kick research once on a terminal error. + const retryStage = useRef<"idle" | "started" | "rerunning" | "exhausted">( + "idle", + ) + const [retryUi, setRetryUi] = useState(null) const handleConfirm = async () => { if (!clean || submitting) return @@ -120,6 +125,48 @@ export function CompanyBrainOnboarding({ return () => window.clearTimeout(timer) }, [phase, serverSchedulesResearch, clean, queryClient]) + // "error" lingers a render after re-kicking, so only arm the second-error + // branch once the re-run is observed running. + useEffect(() => { + if (phase !== "research" || !clean) return + const stage = retryStage.current + if (researchStatus === "error" && stage === "idle") { + retryStage.current = "started" + setRetryUi("retrying") + void (async () => { + // A failed restart never reaches queued/running, and polling is off on + // error — without this the UI would say "retrying" forever. + const ok = await fetch(`${BACKEND}/brain/research/start`, { + method: "POST", + credentials: "include", + headers: { + "content-type": "application/json", + "X-App-Source": "nova", + }, + body: JSON.stringify({ domain: clean }), + }) + .then((res) => res.ok) + .catch(() => false) + if (!ok) { + retryStage.current = "exhausted" + setRetryUi("exhausted") + return + } + queryClient.invalidateQueries({ queryKey: ["brain-research-status"] }) + })() + } else if ( + (researchStatus === "queued" || researchStatus === "running") && + stage === "started" + ) { + retryStage.current = "rerunning" + } else if (researchStatus === "error" && stage === "rerunning") { + retryStage.current = "exhausted" + setRetryUi("exhausted") + } else if (researchStatus === "done") { + setRetryUi(null) + } + }, [phase, clean, researchStatus, queryClient]) + return (
@@ -307,13 +356,30 @@ function ConfirmBody({ function DockedHeader({ domain, done, + retrying, + exhausted, onContinue, }: { domain: string done: boolean + retrying: boolean + exhausted: boolean onContinue: () => void }) { const brandName = workspaceNameFromDomain(domain) || domain + const showSpinner = !done && !exhausted + const statusLabel = done + ? "Company Brain ready" + : exhausted + ? "Couldn't finish — you can continue" + : retrying + ? "Retrying research…" + : "Building your Company Brain…" + const statusColor = done + ? "text-[#5CD68A]" + : exhausted + ? "text-[#E5A45A]" + : "text-[#737373]" return (
- {done ? "Company Brain ready" : "Building your Company Brain…"} + {showSpinner && ( + + )} + {statusLabel}
- {done ? ( - - ) : ( - - )} + {/* Never gated on research; the admin can move on while it keeps working. */} +
) } function ResearchTranscript() { const { status, events } = useResearchStatus() - const running = status !== "done" + const running = status !== "done" && status !== "error" const scrollRef = useRef(null) // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new events diff --git a/apps/web/components/onboarding-brain/slack-handoff.tsx b/apps/web/components/onboarding-brain/slack-handoff.tsx new file mode 100644 index 00000000..cd436368 --- /dev/null +++ b/apps/web/components/onboarding-brain/slack-handoff.tsx @@ -0,0 +1,61 @@ +"use client" + +import { motion } from "motion/react" +import { ArrowRight } from "lucide-react" +import { cn } from "@lib/utils" +import { SlackMark } from "@/components/brain-connector-icons" +import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts" + +export function SlackHandoff({ + teamName, + onDismiss, +}: { + teamName: string | null + onDismiss: () => void +}) { + return ( +
+ +
+ +
+

+ {teamName + ? `Company Brain is live in ${teamName}` + : "Company Brain is live in your Slack"} +

+

+ We sent you a DM to get started. Ask it anything about your company — + it answers where your team already works. +

+ + Open Slack + + + +
+
+ ) +} diff --git a/apps/web/hooks/use-research-status.ts b/apps/web/hooks/use-research-status.ts index 862588f0..1fd3e43b 100644 --- a/apps/web/hooks/use-research-status.ts +++ b/apps/web/hooks/use-research-status.ts @@ -23,7 +23,7 @@ export type ResearchEvent = { } export type ResearchState = { - status: "queued" | "running" | "done" | null + status: "queued" | "running" | "done" | "error" | null domain: string | null findings: number events: ResearchEvent[] @@ -54,7 +54,9 @@ export function useResearchStatus(enabled = true) { refetchInterval: (query) => { const status = query.state.data?.status const polls = query.state.dataUpdateCount - if (status === "done" || polls >= MAX_POLLS) return false + // error is terminal too — keep polling only while it can still progress. + if (status === "done" || status === "error" || polls >= MAX_POLLS) + return false return POLL_INTERVAL_MS }, staleTime: 0,