supermemory/apps/web/app/(app)/brain/page.tsx
MaheshtheDev db7f5c3f64 fix(web): select the correct Company Brain workspace (#1372)
## What changed

- Make `/brain` reuse the active Company Brain, switch to a single existing Company Brain, show a picker for multiple choices, or create one only when none exists.
- Wait for active-organization restoration before making that decision.
- Make Company Brain onboarding match organizations by the confirmed company domain and never offer unrelated-domain workspaces.
- Create a new Company Brain when no matching workspace exists and show an actionable workspace-limit toast when creation is blocked.
- Improve the organization picker, loading, and error states.

## Why

The previous flows could use or mutate the currently active normal organization, start research against stale Company Brain metadata, or offer unrelated Company Brain workspaces after a quota failure.

## Impact

Normal organizations are no longer silently converted. Research is scoped to the Company Brain for the confirmed domain, and users receive a clear recovery path when they reach their workspace limit.

This keeps the existing `{ domain }` research API contract; no organization-reconfiguration API is required.

## Validation

- Biome checks passed for all five changed web files
- Targeted web TypeScript diagnostics reported no errors for the changed files
- React Doctor against `origin/main`: no issues found
- `git diff --check`

No test files were added.
2026-07-28 06:14:22 +00:00

315 lines
10 KiB
TypeScript

"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { useRouter } from "next/navigation"
import { LogoFull } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { AlertTriangle, ChevronRight, Loader2, RotateCw } 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 {
type BrainEntryOrganization,
resolveCompanyBrainEntry,
} from "@/lib/company-brain-entry"
import { dmSans125ClassName, 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"
const modalCardStyle = {
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",
}
const inputBevelStyle = {
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
}
// No forms: sign up → org auto-created → Slack install.
// After OAuth, mono attaches api_scale (14d trial) + company_brain (200 credits).
export default function BrainEntryPage() {
const router = useRouter()
const {
user,
org,
organizations,
isRestoring,
setActiveOrg,
refetchOrganizations,
} = useAuth()
const { email = null } = user ?? {}
const [error, setError] = useState<string | null>(null)
const [choices, setChoices] = useState<BrainEntryOrganization[] | null>(null)
const [attempt, setAttempt] = useState(0)
const startedRef = useRef(false)
const continueWithOrganization = useCallback(
async (organization: BrainEntryOrganization) => {
if (org?.id !== organization.id) {
await setActiveOrg(organization.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`
},
[org?.id, router, setActiveOrg],
)
const createCompanyBrain = useCallback(async () => {
// 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, refetchOrganizations, setActiveOrg])
const run = useCallback(async () => {
const organizationsWithActiveMetadata = (organizations ?? []).map(
(organization) =>
organization.id === org?.id
? { ...organization, metadata: org.metadata }
: organization,
)
const decision = resolveCompanyBrainEntry(
org?.id,
organizationsWithActiveMetadata,
)
if (decision.action === "use" || decision.action === "switch") {
await continueWithOrganization(decision.organization)
return
}
if (decision.action === "choose") {
setChoices(decision.organizations)
return
}
await createCompanyBrain()
}, [continueWithOrganization, createCompanyBrain, org, organizations])
const handleChoice = useCallback(
(organization: BrainEntryOrganization) => {
setChoices(null)
setError(null)
continueWithOrganization(organization).catch((e) => {
startedRef.current = false
console.error("Company Brain organization selection failed:", e)
setError(e instanceof Error ? e.message : "Something went wrong.")
})
},
[continueWithOrganization],
)
// 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 || isRestoring || 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, isRestoring, run, attempt])
return (
<EntryShell>
{choices ? (
<section
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] p-6 text-left md:p-8"
style={modalCardStyle}
>
<p
className={cn(
"text-[20px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Choose your Company Brain
</p>
<p className="mt-1.5 text-[14px] font-medium leading-[1.5] text-[#737373]">
You're a member of more than one workspace. Pick the one to open.
</p>
<div className="mt-6 flex flex-col gap-2">
{choices.map((organization) => (
<button
key={organization.id}
type="button"
onClick={() => handleChoice(organization)}
style={inputBevelStyle}
className="group flex w-full items-center gap-3 rounded-[14px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] px-3 py-3 text-left transition-colors hover:bg-[#1E2228] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/60"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.15)] bg-[#0D121A] text-[13px] font-semibold uppercase text-[#A1A1AA]">
{(organization.name.trim()[0] ?? "?").toUpperCase()}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-medium text-[#fafafa]">
{organization.name}
</span>
{generateOrgSlug(organization.name) !== organization.slug && (
<span className="mt-0.5 block truncate text-[12px] font-medium text-[#525D6E]">
{organization.slug}
</span>
)}
</span>
<ChevronRight className="size-4 shrink-0 text-[#525D6E] transition-[color,transform] group-hover:translate-x-0.5 group-hover:text-[#A1A1AA]" />
</button>
))}
</div>
{email && (
<p className="mt-5 border-t border-white/[0.06] pt-4 text-[12px] font-medium text-[#525D6E]">
Signed in as {email}
</p>
)}
</section>
) : error ? (
<section
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] p-8 text-center"
style={modalCardStyle}
>
<div
className="mx-auto flex size-12 items-center justify-center rounded-[14px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
style={inputBevelStyle}
>
<AlertTriangle className="size-5 text-[#E5A94B]" />
</div>
<p
className={cn(
"mt-5 text-[20px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Couldn't set up your Company Brain
</p>
<p className="mt-2 text-[14px] font-medium leading-[1.5] text-[#737373]">
{error}
</p>
<Button
variant="insideOut"
onClick={() => {
setError(null)
setAttempt((a) => a + 1)
}}
className="mt-6 rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
<RotateCw className="size-3.5" />
Try again
</Button>
</section>
) : (
<div className="flex flex-col items-center text-center">
<div className="relative flex size-14 items-center justify-center">
<span className="absolute inset-0 animate-ping rounded-[18px] bg-[#4BA0FA]/10" />
<span
className="absolute inset-0 rounded-[18px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
style={inputBevelStyle}
/>
<Loader2 className="relative size-5 animate-spin text-[#4BA0FA]" />
</div>
<p
className={cn(
"mt-6 text-[20px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Setting up your Company Brain
</p>
<p className="mt-2 max-w-sm text-[14px] font-medium leading-[1.5] text-[#737373]">
Preparing your workspace, then we'll connect it to Slack.
</p>
</div>
)}
</EntryShell>
)
}
function EntryShell({ children }: { children: React.ReactNode }) {
return (
<div
className={cn(
"relative min-h-dvh overflow-hidden bg-[#05080D] text-[#fafafa]",
dmSansClassName(),
)}
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 select-none"
style={{
background:
"radial-gradient(ellipse 80% 60% at 50% 40%, rgba(75,160,250,0.08) 0%, rgba(34,97,202,0.04) 35%, transparent 70%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 select-none"
style={{
backgroundImage:
"radial-gradient(circle at center, rgba(105,167,240,0.22) 1px, transparent 1px)",
backgroundSize: "28px 28px",
maskImage:
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
WebkitMaskImage:
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
}}
/>
<header className="pointer-events-none absolute inset-x-0 top-0 z-20 px-4 py-4 md:px-10">
<LogoFull className="h-5 text-[#fafafa] md:h-6" />
</header>
<main className="relative z-10 flex min-h-dvh flex-col items-center justify-center px-4 py-20 md:px-10">
{children}
</main>
</div>
)
}