From db7f5c3f649736a659a5766132fcaff45ac2695c Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:14:22 +0000 Subject: [PATCH] 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. --- apps/web/app/(app)/brain/page.tsx | 246 +++++++++++++++--- apps/web/app/(app)/onboarding/page.tsx | 99 ++++++- .../company-brain-onboarding.tsx | 120 +++++++-- apps/web/components/onboarding-brain/types.ts | 7 +- apps/web/lib/company-brain-entry.ts | 81 ++++++ 5 files changed, 493 insertions(+), 60 deletions(-) create mode 100644 apps/web/lib/company-brain-entry.ts diff --git a/apps/web/app/(app)/brain/page.tsx b/apps/web/app/(app)/brain/page.tsx index 95541281..3a96950d 100644 --- a/apps/web/app/(app)/brain/page.tsx +++ b/apps/web/app/(app)/brain/page.tsx @@ -2,13 +2,19 @@ import { useCallback, useEffect, useRef, useState } from "react" import { useRouter } from "next/navigation" -import { Loader2 } from "lucide-react" +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 { dmSansClassName } from "@/lib/fonts" +import { + type BrainEntryOrganization, + resolveCompanyBrainEntry, +} from "@/lib/company-brain-entry" +import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts" import { detectModeFromEmail, generateOrgSlug, @@ -21,22 +27,39 @@ import { 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, setActiveOrg, refetchOrganizations } = - useAuth() + const { + user, + org, + organizations, + isRestoring, + setActiveOrg, + refetchOrganizations, + } = useAuth() const { email = null } = user ?? {} const [error, setError] = useState(null) + const [choices, setChoices] = 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 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" }, @@ -48,9 +71,11 @@ export default function BrainEntryPage() { return } window.location.href = `${BACKEND}/brain/slack/oauth/install` - return - } + }, + [org?.id, router, setActiveOrg], + ) + const createCompanyBrain = useCallback(async () => { // Personal email → shell org; the Slack workspace resolves identity later. const domain = detectModeFromEmail(email) === "team" @@ -85,53 +110,206 @@ export default function BrainEntryPage() { has_domain: Boolean(domain), }) window.location.href = `${BACKEND}/brain/slack/oauth/install` - }, [email, org, organizations, setActiveOrg, refetchOrganizations, router]) + }, [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 || startedRef.current) return + 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, run, attempt]) + }, [user, organizations, isRestoring, run, attempt]) return ( -
- {error ? ( - <> -

+ + {choices ? ( +

+

+ Choose your Company Brain +

+

+ You're a member of more than one workspace. Pick the one to open. +

+ +
+ {choices.map((organization) => ( + + ))} +
+ + {email && ( +

+ Signed in as {email} +

+ )} +
+ ) : error ? ( +
+
+ +
+

Couldn't set up your Company Brain

-

{error}

- - + +
) : ( - <> - -

- Setting up your Company Brain… +

+
+ + + +
+

+ Setting up your Company Brain

- +

+ Preparing your workspace, then we'll connect it to Slack. +

+
)} + + ) +} + +function EntryShell({ children }: { children: React.ReactNode }) { + return ( +
+
+
+
+ +
+
+ {children} +
) } diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 0bd70af1..1cde19cf 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -8,6 +8,7 @@ import { useAuth } from "@lib/auth-context" import { authClient } from "@lib/auth" import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants" import { analytics } from "@/lib/analytics" +import { resolveCompanyBrainEntry } from "@/lib/company-brain-entry" import { BrainShell } from "@/components/onboarding-brain/shell" import { StepAbout, @@ -55,6 +56,20 @@ const getErrorMessage = (error: unknown, fallback: string) => { return fallback } +const getWorkspaceCreationErrorCopy = (message: string) => { + const limit = message.match(/maximum number of workspaces \((\d+)\)/i)?.[1] + if (limit) { + return { + title: "Workspace limit reached", + description: `You can own up to ${limit} workspaces. Delete one in Settings or contact support@supermemory.com for a higher limit.`, + } + } + return { + title: "Couldn't create workspace", + description: message, + } +} + export default function BrainOnboardingPage() { const router = useRouter() const params = useSearchParams() @@ -245,8 +260,16 @@ export default function BrainOnboardingPage() { const creatingOrgRef = useRef(false) const ensureOrg = useCallback( - async (domainOverride?: string): Promise => { - if (!forceCreate && organizations && organizations.length > 0) + async ( + domainOverride?: string, + createEvenIfExisting = false, + ): Promise => { + if ( + !createEvenIfExisting && + !forceCreate && + organizations && + organizations.length > 0 + ) return false const name = ( domainOverride @@ -328,8 +351,14 @@ export default function BrainOnboardingPage() { analytics.onboardingWorkspaceCreateFailed({ error: message, }) - toast.error("Organization was not created", { - description: "Please try again from Settings.", + const errorCopy = getWorkspaceCreationErrorCopy(message) + toast.error(errorCopy.title, { + description: errorCopy.description, + duration: 8000, + action: { + label: "Open Settings", + onClick: () => router.push("/settings"), + }, }) if (forceCreate && (organizations?.length ?? 0) > 0) { router.replace("/") @@ -342,7 +371,10 @@ export default function BrainOnboardingPage() { const isCompanyBrain = mode === "team" const handleBrainConfirm = useCallback( - async (confirmedDomain: string): Promise => { + async ( + confirmedDomain: string, + organizationId?: string, + ): Promise => { if (creatingOrgRef.current) return { ok: false } creatingOrgRef.current = true setCreatingOrg(true) @@ -353,7 +385,42 @@ export default function BrainOnboardingPage() { workspaceDomain: confirmedDomain, workspaceName: workspaceName || a.workspaceName, })) - const orgCreated = await ensureOrg(confirmedDomain) + let orgCreated = false + if (forceCreate) { + orgCreated = await ensureOrg(confirmedDomain, true) + } else if (organizationId) { + const selected = organizations?.find( + (organization) => organization.id === organizationId, + ) + if (!selected) return { ok: false } + if (selected.id !== org?.id) await setActiveOrg(selected.slug) + } else { + const organizationsWithActiveMetadata = (organizations ?? []).map( + (organization) => + organization.id === org?.id + ? { ...organization, metadata: org.metadata } + : organization, + ) + const decision = resolveCompanyBrainEntry( + org?.id, + organizationsWithActiveMetadata, + confirmedDomain, + ) + if (decision.action === "choose") { + return { + ok: false, + choices: decision.organizations.map((organization) => ({ + id: organization.id, + name: organization.name, + })), + } + } + if (decision.action === "switch") { + await setActiveOrg(decision.organization.slug) + } else if (decision.action === "create") { + orgCreated = await ensureOrg(confirmedDomain, true) + } + } // Re-entering onboarding on an existing org ("Try onboarding") must // kick research from the client. New orgs rely on the signup hook after // provisioning — a duplicate /start races and can strand the DO task. @@ -393,8 +460,14 @@ export default function BrainOnboardingPage() { const message = getErrorMessage(e, "Organization was not created.") console.error("Failed to create organization:", e) analytics.onboardingWorkspaceCreateFailed({ error: message }) - toast.error("Organization was not created", { - description: "Please try again.", + const errorCopy = getWorkspaceCreationErrorCopy(message) + toast.error(errorCopy.title, { + description: errorCopy.description, + duration: 8000, + action: { + label: "Open Settings", + onClick: () => router.push("/settings"), + }, }) return { ok: false } } finally { @@ -402,7 +475,15 @@ export default function BrainOnboardingPage() { setCreatingOrg(false) } }, - [ensureOrg, queryClient], + [ + ensureOrg, + forceCreate, + org, + organizations, + queryClient, + setActiveOrg, + router, + ], ) const [sendingInvites, setSendingInvites] = useState(false) diff --git a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx index 2d11ea48..d510d462 100644 --- a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx +++ b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx @@ -4,7 +4,14 @@ import { LogoFull } from "@ui/assets/Logo" import { Button } from "@ui/components/button" import { Input } from "@ui/components/input" import { cn } from "@lib/utils" -import { ArrowRight, Check, Globe, Loader2 } from "lucide-react" +import { + ArrowRight, + Building2, + Check, + ChevronRight, + Globe, + Loader2, +} from "lucide-react" import { useQueryClient } from "@tanstack/react-query" import { AnimatePresence, motion } from "motion/react" import { type ReactNode, useEffect, useRef, useState } from "react" @@ -25,6 +32,7 @@ import { import { ResearchActionRail } from "./research-action-rail" import { type CompanyBrainConfirmResult, + type CompanyBrainOrganizationChoice, workspaceNameFromDomain, } from "./types" @@ -33,7 +41,10 @@ interface CompanyBrainOnboardingProps { avatarUrl: string | null domain: string submitting: boolean - onConfirm: (domain: string) => Promise + onConfirm: ( + domain: string, + organizationId?: string, + ) => Promise onDone: () => void onUsePersonal: () => void } @@ -72,6 +83,9 @@ export function CompanyBrainOnboarding({ }: CompanyBrainOnboardingProps) { const [phase, setPhase] = useState("confirm") const [domain, setDomain] = useState(initialDomain) + const [organizationChoices, setOrganizationChoices] = useState< + CompanyBrainOrganizationChoice[] | null + >(null) const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false) const firstName = name.trim().split(/\s+/)[0] ?? "" const clean = normalizeDomain(domain) @@ -84,10 +98,14 @@ export function CompanyBrainOnboarding({ ) const [retryUi, setRetryUi] = useState(null) - const handleConfirm = async () => { + const handleConfirm = async (organizationId?: string) => { if (!clean || submitting) return - const result = await onConfirm(clean) - if (!result.ok) return + const result = await onConfirm(clean, organizationId) + if (!result.ok) { + if (result.choices?.length) setOrganizationChoices(result.choices) + return + } + setOrganizationChoices(null) setServerSchedulesResearch(result.serverSchedulesResearch) setPhase("research") } @@ -211,15 +229,24 @@ export function CompanyBrainOnboarding({ exit={{ opacity: 0 }} transition={{ duration: 0.15 }} > - + {organizationChoices ? ( + handleConfirm(organizationId)} + onBack={() => setOrganizationChoices(null)} + /> + ) : ( + handleConfirm()} + submitting={submitting} + /> + )} ) : ( - {phase === "confirm" && ( + {phase === "confirm" && !organizationChoices && (
+ ))} +
+ + + ) +} + function ConfirmBody({ firstName, name, diff --git a/apps/web/components/onboarding-brain/types.ts b/apps/web/components/onboarding-brain/types.ts index 56cff5dd..330914dc 100644 --- a/apps/web/components/onboarding-brain/types.ts +++ b/apps/web/components/onboarding-brain/types.ts @@ -1,6 +1,11 @@ +export type CompanyBrainOrganizationChoice = { + id: string + name: string +} + export type CompanyBrainConfirmResult = | { ok: true; serverSchedulesResearch: boolean } - | { ok: false } + | { ok: false; choices?: CompanyBrainOrganizationChoice[] } export type BrainMode = "personal" | "team" diff --git a/apps/web/lib/company-brain-entry.ts b/apps/web/lib/company-brain-entry.ts new file mode 100644 index 00000000..94ff5b33 --- /dev/null +++ b/apps/web/lib/company-brain-entry.ts @@ -0,0 +1,81 @@ +import { + getBrainMode, + getBrainWorkspaceDomain, + getCompanyBrainOverride, + hasCompanyBrain, +} from "./billing-utils" + +export type BrainEntryOrganization = { + id: string + name: string + slug: string + metadata?: Record | string | null +} + +export type CompanyBrainEntryDecision = + | { action: "use"; organization: BrainEntryOrganization } + | { action: "switch"; organization: BrainEntryOrganization } + | { action: "choose"; organizations: BrainEntryOrganization[] } + | { action: "create" } + +export function isCompanyBrainOrganization( + organization: BrainEntryOrganization, +): boolean { + const override = getCompanyBrainOverride(organization.metadata) + if (override !== undefined) return override + return ( + hasCompanyBrain(organization.metadata) || + getBrainMode(organization.metadata) === "team" + ) +} + +export function getCompanyBrainOrganizations( + organizations: BrainEntryOrganization[], +): BrainEntryOrganization[] { + return organizations.filter(isCompanyBrainOrganization) +} + +function normalizeDomain(domain: string): string { + return domain + .trim() + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/^www\./, "") + .replace(/\/.*$/, "") +} + +export function resolveCompanyBrainEntry( + activeOrganizationId: string | null | undefined, + organizations: BrainEntryOrganization[], + requestedDomain?: string, +): CompanyBrainEntryDecision { + const normalizedRequestedDomain = requestedDomain + ? normalizeDomain(requestedDomain) + : null + const companyBrains = getCompanyBrainOrganizations(organizations).filter( + (organization) => { + if (!normalizedRequestedDomain) return true + const organizationDomain = getBrainWorkspaceDomain(organization.metadata) + return ( + organizationDomain !== null && + normalizeDomain(organizationDomain) === normalizedRequestedDomain + ) + }, + ) + const active = organizations.find( + (organization) => organization.id === activeOrganizationId, + ) + if ( + active && + companyBrains.some((organization) => organization.id === active.id) + ) { + return { action: "use", organization: active } + } + if (companyBrains.length === 1 && companyBrains[0]) { + return { action: "switch", organization: companyBrains[0] } + } + if (companyBrains.length > 1) { + return { action: "choose", organizations: companyBrains } + } + return { action: "create" } +}