feat(web): company brain onboarding research UI (#1197)

- Confirm-domain step, then live research transcript + action rail
- Poll research status; client-side force-start fallback

---
**Session Details**
- Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/4f4c1321-9b65-4dac-9906-8b78c4c32926)
- Requested by: Unknown
- Address comments on this PR. Add `(aside)` to your comment to have me ignore it.
This commit is contained in:
MaheshtheDev 2026-07-05 00:06:02 +00:00
parent 8c35c1ecad
commit d7050ed332
11 changed files with 2371 additions and 65 deletions

View file

@ -1,6 +1,7 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { useRouter, useSearchParams } from "next/navigation"
import { toast } from "sonner"
import { useAuth } from "@lib/auth-context"
@ -17,6 +18,7 @@ import {
type SourcesValues,
} from "@/components/onboarding-brain/step-sources"
import { StepIngest } from "@/components/onboarding-brain/step-ingest"
import { CompanyBrainOnboarding } from "@/components/onboarding-brain/company-brain-onboarding"
import { useFeatureFlagEnabled } from "posthog-js/react"
import {
StepTeam,
@ -32,10 +34,14 @@ import {
generateOrgSlug,
generateUsername,
workspaceDomainFromEmail,
workspaceNameFromDomain,
workspaceNameFromEmail,
type CompanyBrainConfirmResult,
} from "@/components/onboarding-brain/types"
const STORAGE_KEY = "supermemory-brain-onboarding-v1"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const countsAsConnectedSource = (state: unknown) =>
state === "connected" || state === "waitlist"
@ -53,6 +59,7 @@ const getErrorMessage = (error: unknown, fallback: string) => {
export default function BrainOnboardingPage() {
const router = useRouter()
const params = useSearchParams()
const queryClient = useQueryClient()
const { user, org, organizations, setActiveOrg, refetchOrganizations } =
useAuth()
@ -235,65 +242,78 @@ export default function BrainOnboardingPage() {
const [creatingOrg, setCreatingOrg] = useState(false)
const creatingOrgRef = useRef(false)
const ensureOrg = useCallback(async () => {
if (!forceCreate && organizations && organizations.length > 0) return
const name = (about.workspaceName || suggestedWorkspaceName).trim()
const slug = generateOrgSlug(name)
const effectiveMode = allowTeam ? mode : "personal"
const metadata: BrainMetadata & { signupSource: string } = {
signupSource: "consumer",
brainOnboardingVersion: "v1",
brainMode: effectiveMode,
brainWorkspaceName: name,
brainWorkspaceDomain:
effectiveMode === "team" ? about.workspaceDomain || domain : null,
brainContainerTag: containerTag,
...(about.about.trim() ? { brainAbout: about.about.trim() } : {}),
}
const result = await authClient.organization.create({
name,
slug,
metadata,
})
if (result.error || !result.data?.slug) {
throw new Error(
getErrorMessage(result.error, "Organization was not created."),
)
}
await setActiveOrg(result.data.slug)
if (about.name.trim()) {
await authClient.updateUser({
name: about.name.trim(),
displayUsername: about.name.trim(),
username: generateUsername(about.name),
const ensureOrg = useCallback(
async (domainOverride?: string): Promise<boolean> => {
if (!forceCreate && organizations && organizations.length > 0)
return false
const name = (
domainOverride
? workspaceNameFromDomain(domainOverride)
: about.workspaceName || suggestedWorkspaceName
).trim()
const slug = generateOrgSlug(name)
const effectiveMode = allowTeam ? mode : "personal"
const metadata: BrainMetadata & { signupSource: string } = {
signupSource: "consumer",
brainOnboardingVersion: "v1",
brainMode: effectiveMode,
brainWorkspaceName: name,
brainWorkspaceDomain:
effectiveMode === "team"
? domainOverride || about.workspaceDomain || domain
: null,
brainContainerTag: containerTag,
...(about.about.trim() ? { brainAbout: about.about.trim() } : {}),
}
const result = await authClient.organization.create({
name,
slug,
metadata,
})
}
await refetchOrganizations()
analytics.onboardingWorkspaceCreated({
if (result.error || !result.data?.slug) {
throw new Error(
getErrorMessage(result.error, "Organization was not created."),
)
}
await setActiveOrg(result.data.slug)
if (about.name.trim()) {
await authClient.updateUser({
name: about.name.trim(),
displayUsername: about.name.trim(),
username: generateUsername(about.name),
})
}
await refetchOrganizations()
analytics.onboardingWorkspaceCreated({
mode,
has_about: Boolean(about.about.trim()),
has_domain: Boolean(
mode === "team" && (about.workspaceDomain || domain),
),
})
// Drop new=1 so a reload or back+Continue reuses this org instead of creating a duplicate.
if (forceCreate) {
const url = new URL(window.location.href)
url.searchParams.delete("new")
url.searchParams.delete("name")
router.replace(url.pathname + url.search, { scroll: false })
}
return true
},
[
organizations,
about,
suggestedWorkspaceName,
mode,
has_about: Boolean(about.about.trim()),
has_domain: Boolean(mode === "team" && (about.workspaceDomain || domain)),
})
// Drop new=1 so a reload or back+Continue reuses this org instead of creating a duplicate.
if (forceCreate) {
const url = new URL(window.location.href)
url.searchParams.delete("new")
url.searchParams.delete("name")
router.replace(url.pathname + url.search, { scroll: false })
}
}, [
organizations,
about,
suggestedWorkspaceName,
mode,
allowTeam,
domain,
containerTag,
setActiveOrg,
refetchOrganizations,
forceCreate,
router,
])
allowTeam,
domain,
containerTag,
setActiveOrg,
refetchOrganizations,
forceCreate,
router,
],
)
const handleAboutContinue = useCallback(async () => {
if (creatingOrgRef.current) return
@ -320,6 +340,73 @@ export default function BrainOnboardingPage() {
}
}, [ensureOrg, goNext, forceCreate, organizations, router])
// Company Brain (team) onboarding is a single research surface, no stepper.
const isCompanyBrain = allowTeam && mode === "team"
const handleBrainConfirm = useCallback(
async (confirmedDomain: string): Promise<CompanyBrainConfirmResult> => {
if (creatingOrgRef.current) return { ok: false }
creatingOrgRef.current = true
setCreatingOrg(true)
try {
const workspaceName = workspaceNameFromDomain(confirmedDomain)
setAbout((a) => ({
...a,
workspaceDomain: confirmedDomain,
workspaceName: workspaceName || a.workspaceName,
}))
const orgCreated = await ensureOrg(confirmedDomain)
// 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.
if (!orgCreated) {
const started = await fetch(`${BACKEND}/brain/research/start`, {
method: "POST",
credentials: "include",
headers: {
"content-type": "application/json",
"X-App-Source": "nova",
},
body: JSON.stringify({ domain: confirmedDomain }),
})
.then((res) => res.ok)
.catch(() => false)
// Don't advance into the research phase if it never started, else the
// poller sits on empty state until timeout.
if (!started) {
toast.error("Couldn't start research", {
description: "Please try again in a moment.",
})
return { ok: false }
}
}
queryClient.invalidateQueries({ queryKey: ["brain-research-status"] })
// ensureOrg already fires this when it creates the org; only emit here
// for the existing-org path to avoid a duplicate event.
if (!orgCreated) {
analytics.onboardingWorkspaceCreated({
mode: "team",
has_about: false,
has_domain: Boolean(confirmedDomain),
})
}
return { ok: true, serverSchedulesResearch: orgCreated }
} catch (e) {
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.",
})
return { ok: false }
} finally {
creatingOrgRef.current = false
setCreatingOrg(false)
}
},
[ensureOrg, queryClient],
)
const [sendingInvites, setSendingInvites] = useState(false)
const sendingInvitesRef = useRef(false)
@ -375,6 +462,19 @@ export default function BrainOnboardingPage() {
const mcpUrl = "https://mcp.supermemory.ai/mcp"
if (isCompanyBrain) {
return (
<CompanyBrainOnboarding
name={about.name || user?.name || ""}
avatarUrl={user?.image ?? null}
domain={about.workspaceDomain || domain || ""}
submitting={creatingOrg}
onConfirm={handleBrainConfirm}
onDone={finish}
/>
)
}
return (
<BrainShell
step={step}

View file

@ -814,7 +814,7 @@ export function AppExperience() {
{isDashboardShell && showBottomNav && (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-20 h-64 bg-gradient-to-t from-[#05080D] via-[#05080D]/95 to-transparent" />
)}
{isDashboardShell && (
{isDashboardShell && !isCompanyBrain && (
<div
className={cn(
"pointer-events-none fixed inset-x-0 z-30",

View file

@ -0,0 +1,604 @@
"use client"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
import { useIsMobile } from "@hooks/use-mobile"
import { useQuery } from "@tanstack/react-query"
import {
Building2,
Code2,
ExternalLink,
Home,
LifeBuoy,
Link2,
LayoutGrid,
MenuIcon,
SearchIcon,
Settings,
UserPlus,
ChevronRight,
Sun,
} from "lucide-react"
import Link from "next/link"
import { useQueryState } from "nuqs"
import { useCallback } from "react"
import { DomainLogo } from "@/components/onboarding-brain/step-about"
import { FeedbackModal } from "@/components/feedback-modal"
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
import { SlackMark } from "@/components/brain-connector-icons"
import { GraphIcon, IntegrationsIcon } from "@/components/integration-icons"
import { SpaceSelector } from "@/components/space-selector"
import { UserProfileMenu } from "@/components/user-profile-menu"
import { useTokenUsage } from "@/hooks/use-token-usage"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
import { getBrainWorkspaceDomain } from "@/lib/billing-utils"
import { dmSansClassName } from "@/lib/fonts"
import { useViewMode } from "@/lib/view-mode-context"
import { feedbackParam } from "@/lib/search-params"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { useProject } from "@/stores"
import { useCustomer } from "autumn-js/react"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
type SlackStatus = { connected: boolean; teamName: string | null }
interface CompanyBrainHeaderProps {
onOpenSearch?: () => void
}
const brainItemClass = (active: boolean) =>
cn(
"gap-2.5 rounded-lg px-2 py-1.5 text-sm font-medium cursor-pointer transition-colors",
"hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white",
active ? "bg-white/[0.06] text-white" : "text-white/85",
)
const brainTileClass = (active: boolean) =>
cn(
"flex size-7 shrink-0 items-center justify-center rounded-lg border text-[11px] font-semibold",
active
? "border-[#2261CA66] bg-[#0B2A57] text-[#7EB0FF]"
: "border-white/[0.08] bg-white/[0.04] text-white/70",
)
const menuItemClass =
"gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
const circleNavClass = (active: boolean) =>
cn(
"flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
active
? "border-[#2261CA33] bg-[#00173C] text-white"
: "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5",
dmSansClassName(),
)
const tabClass = (active: boolean) =>
cn(
"inline-flex h-[calc(100%-1px)] min-h-0 cursor-pointer snap-start items-center justify-center gap-1 rounded-full border border-transparent px-2.5 text-xs font-medium whitespace-nowrap transition-colors sm:gap-1.5 sm:px-3 sm:text-sm",
active
? "border-[#2261CA33] bg-[#00173C] text-white"
: "text-foreground hover:bg-white/5",
dmSansClassName(),
)
function useSlackStatus() {
return useQuery({
queryKey: ["brain-slack-status"],
queryFn: async (): Promise<SlackStatus> => {
const res = await fetch(`${BACKEND}/brain/slack/status`, {
credentials: "include",
})
if (!res.ok) return { connected: false, teamName: null }
return (await res.json()) as SlackStatus
},
staleTime: 30_000,
})
}
export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
const { user, org, organizations, setActiveOrg } = useAuth()
const autumn = useCustomer()
const { currentPlan } = useTokenUsage(autumn)
const { data: orgSummaries } = useOrgSummaries()
const { viewMode, setViewMode } = useViewMode()
const { selectedProjects, setSelectedProjects } = useProject()
const { openSettings } = useSettingsModal()
const isMobile = useIsMobile()
const [feedbackOpen, setFeedbackOpen] = useQueryState(
"feedback",
feedbackParam,
)
const [, setInvite] = useQueryState("invite")
const [settingsTab] = useQueryState("settings")
const { data: slackStatus } = useSlackStatus()
const planByOrgId = new Map(
(orgSummaries ?? []).map((s) => [s.orgId, s.plan] as const),
)
const orgLabel = org?.name.replace(/\s*organizations?\s*$/i, "").trim()
const brandLabel = orgLabel || "Workspace"
const domain = getBrainWorkspaceDomain(
org?.metadata as Record<string, unknown> | string | null | undefined,
)
const hasOrgs = (organizations?.length ?? 0) > 0
const memberRole = org?.members
?.find((m) => m.userId === user?.id)
?.role?.toLowerCase()
const canInvite = memberRole === "owner" || memberRole === "admin"
const isOverview = viewMode === "dashboard" && settingsTab !== "company-brain"
const isGraph = viewMode === "graph"
const isMemories = viewMode === "list"
const isConnections = settingsTab === "company-brain"
const slackConnected = slackStatus?.connected ?? false
const selectOrg = useCallback(
(slug: string, isActive: boolean) => {
if (isActive) return
void setActiveOrg(slug).then(() => window.location.reload())
},
[setActiveOrg],
)
const goOverview = useCallback(() => {
void setViewMode("dashboard")
}, [setViewMode])
const goGraph = useCallback(() => {
void setViewMode("graph")
}, [setViewMode])
const goMemories = useCallback(() => {
void setViewMode("list")
}, [setViewMode])
const goConnections = useCallback(() => {
openSettings("company-brain")
}, [openSettings])
const goIntegrations = useCallback(() => {
void setViewMode("integrations")
}, [setViewMode])
const handleInvite = useCallback(() => {
setInvite("1")
openSettings("account")
}, [openSettings, setInvite])
const handleFeedback = useCallback(() => {
void setFeedbackOpen(true)
}, [setFeedbackOpen])
return (
<div className="relative z-10 flex shrink-0 items-center justify-between gap-1.5 p-2.5 md:gap-2 md:p-3">
<div className="z-10! flex min-w-0 shrink items-center justify-center gap-1.5 md:gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="relative flex max-w-[min(52vw,240px)] shrink-0 cursor-pointer items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/5 outline-none focus-visible:outline-none md:-ml-2 before:absolute before:-inset-x-2 before:-inset-y-2.5 before:content-['']"
>
<div
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[8px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
style={{
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08)",
}}
>
{domain ? (
<DomainLogo domain={domain} />
) : (
<Building2 className="size-4 text-[#737373]" />
)}
</div>
<div className="ml-2 min-w-0 flex flex-col items-start justify-center">
<p className="max-w-full truncate text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
Company Brain
</p>
<p className="-mt-0.5 max-w-full truncate text-sm leading-none font-semibold text-white/90 sm:text-[15px]">
{brandLabel}
</p>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
alignOffset={12}
className={cn(
"min-w-[244px] p-1.5 rounded-xl border border-white/[0.08] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
dmSansClassName(),
)}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
{hasOrgs && (
<>
<p className="px-2 pt-1 pb-1.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-[#5B6675]">
Switch brain
</p>
<div className="flex max-h-[40vh] flex-col gap-0.5 overflow-y-auto overscroll-contain">
{organizations?.map((o) => {
const active = org?.id === o.id
const plan = resolveOrgPlan(
o.id,
active,
currentPlan,
planByOrgId,
)
return (
<DropdownMenuItem
key={o.id}
onClick={() => selectOrg(o.slug, active)}
className={cn(brainItemClass(active))}
>
<span className={cn(brainTileClass(active))}>
{o.name?.trim().charAt(0).toUpperCase() || "?"}
</span>
<span className="flex-1 truncate">{o.name}</span>
<OrgPlanBadge plan={plan} />
</DropdownMenuItem>
)
})}
</div>
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
</>
)}
<DropdownMenuItem asChild className={menuItemClass}>
<Link href="/">
<Home className="size-4 text-[#737373]" />
Home
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={goConnections} className={menuItemClass}>
<Link2 className="size-4 text-[#737373]" />
Connections
</DropdownMenuItem>
<DropdownMenuItem
onClick={goIntegrations}
className={menuItemClass}
>
<Sun className="size-4 text-[#737373]" />
Integrations
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openSettings()}
className={menuItemClass}
>
<Settings className="size-4 text-[#737373]" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
<DropdownMenuItem asChild className={menuItemClass}>
<a
href="https://console.supermemory.ai"
target="_blank"
rel="noreferrer"
>
<Code2 className="size-4 text-[#737373]" />
Developer console
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild className={menuItemClass}>
<a href="https://supermemory.ai" target="_blank" rel="noreferrer">
<ExternalLink className="size-4 text-[#737373]" />
supermemory.ai
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{!isMobile && (
<>
<ChevronRight
className="size-4 shrink-0 text-[#3F4853]"
aria-hidden
/>
<SpaceSelector
selectedProjects={selectedProjects}
onValueChange={setSelectedProjects}
enableDelete={false}
enableEdit={false}
/>
</>
)}
</div>
{!isMobile && (
<div className="z-10! flex min-w-0 max-w-full flex-1 items-center justify-center gap-1.5 overflow-hidden px-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Overview"
aria-current={isOverview ? "page" : undefined}
onClick={goOverview}
className={circleNavClass(isOverview)}
>
<Home className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Overview
</TooltipContent>
</Tooltip>
<div
role="tablist"
aria-label="Content"
aria-orientation="horizontal"
className="text-muted-foreground z-10! inline-flex h-10 w-fit min-w-0 max-w-full items-center justify-center gap-0.5 overflow-x-auto snap-x snap-mandatory scroll-fade-x rounded-full border border-[#161F2C] bg-muted p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<button
type="button"
role="tab"
aria-selected={isGraph}
onClick={goGraph}
className={tabClass(isGraph)}
>
<GraphIcon className="size-3.5 shrink-0 sm:size-4" />
Graph
</button>
<button
type="button"
role="tab"
aria-selected={isMemories}
onClick={goMemories}
className={tabClass(isMemories)}
>
<LayoutGrid className="size-3.5 shrink-0 sm:size-4" />
Memories
</button>
<button
type="button"
role="tab"
aria-selected={isConnections}
onClick={goConnections}
className={tabClass(isConnections)}
>
<IntegrationsIcon className="size-3.5 shrink-0 sm:size-4" />
Connections
</button>
</div>
<SlackNavButton
connected={slackConnected}
teamName={slackStatus?.teamName ?? null}
active={isConnections && slackConnected}
onManage={goConnections}
/>
</div>
)}
<div className="z-10! flex shrink-0 items-center gap-1.5">
{isMobile ? (
<>
<SpaceSelector
selectedProjects={selectedProjects}
onValueChange={setSelectedProjects}
enableDelete={false}
compact
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
>
<MenuIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={cn(
"min-w-[200px] p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
dmSansClassName(),
)}
style={{
background:
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<DropdownMenuItem
onClick={goOverview}
className={menuItemClass}
>
<Home className="size-4 text-[#737373]" />
Overview
</DropdownMenuItem>
<DropdownMenuItem onClick={goGraph} className={menuItemClass}>
<GraphIcon className="size-4 text-[#737373]" />
Graph
</DropdownMenuItem>
<DropdownMenuItem
onClick={goMemories}
className={menuItemClass}
>
<LayoutGrid className="size-4 text-[#737373]" />
Memories
</DropdownMenuItem>
<DropdownMenuItem
onClick={goConnections}
className={menuItemClass}
>
<IntegrationsIcon className="size-4 text-[#737373]" />
Connections
</DropdownMenuItem>
<DropdownMenuItem
onClick={goIntegrations}
className={menuItemClass}
>
<Sun className="size-4 text-[#737373]" />
Integrations
</DropdownMenuItem>
{slackConnected ? (
<DropdownMenuItem
onClick={goConnections}
className={menuItemClass}
>
<SlackMark className="size-4" />
Slack connected
</DropdownMenuItem>
) : (
<DropdownMenuItem asChild className={menuItemClass}>
<a href={`${BACKEND}/brain/slack/oauth/install`}>
<SlackMark className="size-4" />
Add to Slack
</a>
</DropdownMenuItem>
)}
<DropdownMenuSeparator className="bg-[#2E3033]" />
{canInvite && (
<DropdownMenuItem
onClick={handleInvite}
className={menuItemClass}
>
<UserPlus className="size-4 text-[#737373]" />
Invite teammates
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={onOpenSearch}
className={menuItemClass}
>
<SearchIcon className="size-4 text-[#737373]" />
Search
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuItem
onClick={handleFeedback}
className={menuItemClass}
>
<LifeBuoy className="size-4 text-[#737373]" />
Feedback
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openSettings()}
className={menuItemClass}
>
<Settings className="size-4 text-[#737373]" />
Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
{canInvite && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="headers"
className={cn(
"rounded-full! h-9! min-h-9 shrink-0",
"max-lg:w-9 max-lg:min-w-9 max-lg:justify-center max-lg:gap-0 max-lg:px-0",
"lg:min-w-0 lg:gap-1.5 lg:px-3 lg:font-medium",
dmSansClassName(),
)}
onClick={handleInvite}
aria-label="Invite teammates"
>
<UserPlus className="size-3.5 shrink-0 lg:size-4" />
<span className="max-lg:sr-only">Invite</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Invite teammates
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="headers"
className={cn(
"size-9! min-h-9 min-w-9 shrink-0 rounded-full! border-[#161F2C]/90 px-0! text-muted-foreground hover:text-foreground",
dmSansClassName(),
)}
onClick={onOpenSearch}
aria-label="Search"
>
<SearchIcon className="size-4 shrink-0" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Search (K)
</TooltipContent>
</Tooltip>
</>
)}
<UserProfileMenu onOpenFeedback={handleFeedback} />
</div>
<FeedbackModal
isOpen={feedbackOpen}
onClose={() => setFeedbackOpen(false)}
/>
</div>
)
}
function SlackNavButton({
connected,
teamName,
active,
onManage,
}: {
connected: boolean
teamName: string | null
active: boolean
onManage: () => void
}) {
const label = connected
? `Slack${teamName ? ` · ${teamName}` : ""}`
: "Add to Slack"
if (!connected) {
return (
<Tooltip>
<TooltipTrigger asChild>
<a
href={`${BACKEND}/brain/slack/oauth/install`}
aria-label="Add to Slack"
className={circleNavClass(false)}
>
<SlackMark className="size-4" />
</a>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Add to Slack
</TooltipContent>
</Tooltip>
)
}
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
onClick={onManage}
className={cn(circleNavClass(active), "relative")}
>
<SlackMark className="size-4" />
<span className="absolute top-1.5 right-1.5 size-2 rounded-full bg-[#2EB67D] ring-2 ring-[#00173C]" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
{label}
</TooltipContent>
</Tooltip>
)
}

View file

@ -44,6 +44,8 @@ import { useTokenUsage } from "@/hooks/use-token-usage"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { CompanyBrainHeader } from "@/components/company-brain-header"
interface HeaderProps {
onAddMemory?: () => void
@ -65,7 +67,15 @@ const brainTileClass = (active: boolean) =>
: "border-white/[0.08] bg-white/[0.04] text-white/70",
)
export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
export function Header(props: HeaderProps) {
const hasCompanyBrain = useHasCompanyBrain()
if (hasCompanyBrain) {
return <CompanyBrainHeader onOpenSearch={props.onOpenSearch} />
}
return <PersonalBrainHeader {...props} />
}
function PersonalBrainHeader({ onAddMemory, onOpenSearch }: HeaderProps) {
const { user, isRestoring, org, organizations, setActiveOrg } = useAuth()
const autumn = useCustomer()
const { currentPlan } = useTokenUsage(autumn)

View file

@ -0,0 +1,697 @@
"use client"
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 { useQueryClient } from "@tanstack/react-query"
import { AnimatePresence, motion } from "motion/react"
import { type ReactNode, useEffect, useRef, useState } from "react"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import {
type ResearchEvent,
type ResearchStat,
useResearchStatus,
} from "@/hooks/use-research-status"
import {
cardSurfaceStyle,
DomainLogo,
fieldLabel,
inputBevelStyle,
inputClass,
UserAvatar,
} from "./step-about"
import { ResearchActionRail } from "./research-action-rail"
import {
type CompanyBrainConfirmResult,
workspaceNameFromDomain,
} from "./types"
interface CompanyBrainOnboardingProps {
name: string
avatarUrl: string | null
domain: string
submitting: boolean
onConfirm: (domain: string) => Promise<CompanyBrainConfirmResult>
onDone: () => void
}
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
type Phase = "confirm" | "research"
function normalizeDomain(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.replace(/\/.*$/, "")
}
export function CompanyBrainOnboarding({
name,
avatarUrl,
domain: initialDomain,
submitting,
onConfirm,
onDone,
}: CompanyBrainOnboardingProps) {
const [phase, setPhase] = useState<Phase>("confirm")
const [domain, setDomain] = useState(initialDomain)
const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false)
const firstName = name.trim().split(/\s+/)[0] ?? ""
const clean = normalizeDomain(domain)
const queryClient = useQueryClient()
const { status: researchStatus } = useResearchStatus(phase === "research")
const researchDone = researchStatus === "done"
const handleConfirm = async () => {
if (!clean || submitting) return
const result = await onConfirm(clean)
if (!result.ok) return
setServerSchedulesResearch(result.serverSchedulesResearch)
setPhase("research")
}
// New-org signup schedules research after provisioning; if that hook is slow
// or fails, force-start from the client so onboarding doesn't stall.
useEffect(() => {
if (phase !== "research" || !serverSchedulesResearch || !clean) return
const timer = window.setTimeout(() => {
void (async () => {
try {
const res = await fetch(`${BACKEND}/brain/research/status`, {
credentials: "include",
headers: { "X-App-Source": "nova" },
})
if (!res.ok) return
const state = (await res.json()) as {
status?: string | null
events?: { aspect: string }[]
}
if (state.status === "done") return
// Real research aspects use ord >= 2; bail if one is already underway.
if ((state.events?.length ?? 0) >= 3) return
await fetch(`${BACKEND}/brain/research/start`, {
method: "POST",
credentials: "include",
headers: {
"content-type": "application/json",
"X-App-Source": "nova",
},
body: JSON.stringify({ domain: clean }),
})
queryClient.invalidateQueries({ queryKey: ["brain-research-status"] })
} catch {}
})()
}, 40_000)
return () => window.clearTimeout(timer)
}, [phase, serverSchedulesResearch, clean, queryClient])
return (
<div
className={cn(
"relative h-dvh bg-[#05080D] text-[#FAFAFA] flex flex-col overflow-hidden",
dmSansClassName(),
)}
>
<Backdrop />
<header className="relative z-10 flex items-center px-6 md:px-10 py-4">
<LogoFull className="h-5 md:h-6 text-[#fafafa]" />
</header>
<main
className={cn(
"relative z-10 flex-1 flex flex-col min-h-0",
phase === "confirm"
? "justify-center items-center px-4 md:px-10"
: "justify-start items-stretch pt-2 px-4 md:px-8 xl:px-14",
)}
>
{/* Persistent card: full confirm card, then morphs into a slim docked header. */}
<motion.div
layout
transition={{ type: "spring", stiffness: 260, damping: 30 }}
style={cardSurfaceStyle}
className={cn(
"w-full mx-auto rounded-[22px] bg-[#1B1F24]",
phase === "confirm"
? "max-w-xl p-6 md:p-8"
: "max-w-7xl px-5 py-3 xl:max-w-[1360px]",
)}
>
<AnimatePresence mode="wait" initial={false}>
{phase === "confirm" ? (
<motion.div
key="confirm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<ConfirmBody
firstName={firstName}
name={name}
avatarUrl={avatarUrl}
domain={domain}
onDomainChange={setDomain}
onConfirm={handleConfirm}
submitting={submitting}
/>
</motion.div>
) : (
<motion.div
key="docked"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.25, delay: 0.1 }}
>
<DockedHeader
domain={clean}
done={researchDone}
onContinue={onDone}
/>
</motion.div>
)}
</AnimatePresence>
</motion.div>
{phase === "confirm" && (
<div className="w-full max-w-xl mx-auto mt-5 flex items-center justify-end px-1">
<Button
variant="insideOut"
onClick={handleConfirm}
disabled={!clean || submitting}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
{submitting ? (
<>
Starting
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
Confirm
<ArrowRight className="size-3.5" />
</>
)}
</Button>
</div>
)}
<AnimatePresence>
{phase === "research" && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15, duration: 0.3 }}
className="relative w-full max-w-7xl xl:max-w-[1360px] mx-auto flex flex-col flex-1 min-h-0 mt-4 mb-8 gap-4"
>
<div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row lg:items-stretch lg:gap-4">
<div className="flex min-h-[280px] min-w-0 flex-[1.15] flex-col lg:min-w-0">
<ResearchTranscript />
</div>
<motion.div
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.35, duration: 0.4 }}
className="flex min-h-[200px] min-w-0 flex-1 flex-col lg:min-w-[340px] lg:max-w-[420px]"
>
<ResearchActionRail domain={clean} />
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
</main>
</div>
)
}
function ConfirmBody({
firstName,
name,
avatarUrl,
domain,
onDomainChange,
onConfirm,
submitting,
}: {
firstName: string
name: string
avatarUrl: string | null
domain: string
onDomainChange: (v: string) => void
onConfirm: () => void
submitting: boolean
}) {
return (
<>
<div className="flex items-center gap-4">
<UserAvatar url={avatarUrl} name={name} className="size-12 shrink-0" />
<div className="min-w-0">
<p
className={cn(
"font-semibold text-[#fafafa] text-[20px]",
dmSans125ClassName(),
)}
>
{firstName ? `Hey ${firstName} 👋` : "Hey there 👋"}
</p>
<p className="text-[#737373] font-medium text-[14px] leading-[1.4] mt-0.5">
I'll research your company and set up its Brain.
</p>
</div>
</div>
<div className="mt-6">
<p className={fieldLabel}>Company domain</p>
<div className="relative">
<div
className="absolute left-1.5 top-1/2 -translate-y-1/2 size-9 rounded-[8px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden"
style={inputBevelStyle}
>
<DomainLogo domain={normalizeDomain(domain) || "supermemory.ai"} />
</div>
<Input
value={domain}
onChange={(e) => onDomainChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !submitting) onConfirm()
}}
placeholder="your-team.com"
spellCheck={false}
autoComplete="off"
className={cn(inputClass, "pl-14")}
style={inputBevelStyle}
/>
</div>
</div>
</>
)
}
function DockedHeader({
domain,
done,
onContinue,
}: {
domain: string
done: boolean
onContinue: () => void
}) {
const brandName = workspaceNameFromDomain(domain) || domain
return (
<div className="flex items-center gap-3">
<div
className="size-8 rounded-[8px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden shrink-0"
style={inputBevelStyle}
>
<DomainLogo domain={domain} />
</div>
<span className="text-[14px] font-semibold text-[#fafafa]">
{brandName}
</span>
<span
className={cn(
"text-[12px] font-medium",
done ? "text-[#5CD68A]" : "text-[#737373]",
)}
>
{done ? "Company Brain ready" : "Building your Company Brain…"}
</span>
{done ? (
<Button
type="button"
onClick={onContinue}
className={cn(
"ml-auto rounded-full bg-white px-4 py-2 text-[13px] font-semibold text-[#1D1C1D] shadow-[0_4px_24px_rgba(75,160,250,0.25)] hover:bg-white/95",
dmSans125ClassName(),
)}
>
Continue
<ArrowRight className="size-3.5" />
</Button>
) : (
<Loader2 className="size-3.5 animate-spin text-[#4BA0FA] ml-auto" />
)}
</div>
)
}
function ResearchTranscript() {
const { status, events } = useResearchStatus()
const running = status !== "done"
const scrollRef = useRef<HTMLDivElement>(null)
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new events
useEffect(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
})
}, [events.length])
return (
<div className="flex flex-1 min-h-0 flex-col gap-4">
<div
style={cardSurfaceStyle}
className="relative flex min-h-0 flex-col overflow-hidden rounded-[22px] bg-[#1B1F24]"
>
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto px-6 pt-8 pb-8"
>
<Timeline events={events} running={running} />
</div>
{/* Top-only overlay in the card color: text slides up under a solid edge. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-9 rounded-t-[22px]"
style={{
background:
"linear-gradient(to bottom, #1B1F24 0%, rgba(27,31,36,0) 100%)",
}}
/>
</div>
</div>
)
}
function Timeline({
events,
running,
}: {
events: ResearchEvent[]
running: boolean
}) {
if (events.length === 0) {
return (
<div className="flex items-center gap-3 text-[13px] font-medium text-[#737373]">
<Loader2 className="size-4 animate-spin text-[#4BA0FA]" />
Starting deep research
</div>
)
}
return (
<ol className="flex flex-col gap-0">
<AnimatePresence initial={false}>
{events.map((e, i) => {
const isLast = i === events.length - 1
const active = running && e.status === "in_progress"
return (
<motion.li
key={e.aspect}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className="relative flex gap-3.5 pb-6 last:pb-0"
>
<div className="relative flex w-3.5 shrink-0 justify-center">
<EventDot status={e.status} active={active} />
{!isLast && (
<span className="absolute left-1/2 top-[19px] -bottom-[18px] w-px -translate-x-1/2 bg-[#2E353D]" />
)}
</div>
<div className="flex flex-1 flex-col gap-2 -mt-px">
<span
className={cn(
"text-[14px] font-medium leading-[14px]",
e.status === "error"
? "text-[#E5735A]"
: active
? "text-[#fafafa]"
: "text-[#A1A1AA]",
)}
>
{e.label}
</span>
{e.detail && e.status !== "in_progress" && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-[12.5px] leading-[1.6] text-[#737373]"
>
<CitedText text={e.detail} />
</motion.p>
)}
{e.status === "complete" && e.stats.length > 0 && (
<StatStrip stats={e.stats} />
)}
{e.status === "complete" && e.highlights.length > 0 && (
<Chips items={e.highlights} />
)}
{e.status === "complete" && e.sources.length > 0 && (
<SourceChips urls={e.sources} />
)}
{active && <ThinkingLine />}
</div>
</motion.li>
)
})}
</AnimatePresence>
</ol>
)
}
// grok inlines citations as `[[1]](url)` (sometimes consecutive). Render them
// as compact superscript links and collapse any leftover `[n]` bare markers.
const CITE_RE = /\[\[(\d+)\]\]\((https?:\/\/[^)\s]+)\)/g
function CitedText({ text }: { text: string }) {
const nodes: ReactNode[] = []
let last = 0
let m: RegExpExecArray | null
CITE_RE.lastIndex = 0
// biome-ignore lint/suspicious/noAssignInExpressions: regex walk
while ((m = CITE_RE.exec(text)) !== null) {
if (m.index > last) {
nodes.push(cleanBareCites(text.slice(last, m.index)))
}
nodes.push(
<a
key={`${m.index}-${m[1]}`}
href={m[2]}
target="_blank"
rel="noreferrer"
className="mx-px align-super text-[9px] font-semibold text-[#4BA0FA] hover:underline"
>
{m[1]}
</a>,
)
last = m.index + m[0].length
}
if (last < text.length) nodes.push(cleanBareCites(text.slice(last)))
return <>{nodes}</>
}
function cleanBareCites(s: string): string {
return s.replace(/\[\[?\d+\]\]?/g, "").replace(/\s{2,}/g, " ")
}
const THINKING_PHRASES = [
"Searching the web…",
"Reading sources…",
"Cross-checking facts…",
"Summarizing findings…",
]
function ThinkingLine() {
const [i, setI] = useState(0)
useEffect(() => {
const t = setInterval(
() => setI((v) => (v + 1) % THINKING_PHRASES.length),
1600,
)
return () => clearInterval(t)
}, [])
return (
<motion.p
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-[12px] leading-[1.5] text-[#525D6E]"
>
{THINKING_PHRASES[i]}
</motion.p>
)
}
// Boxless stat strip: values over tiny labels, split by hairline dividers.
function StatStrip({ stats }: { stats: ResearchStat[] }) {
const shown = stats.slice(0, 3)
if (!shown.length) return null
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-2.5 flex flex-wrap items-stretch gap-x-5 gap-y-3"
>
{shown.map((s, i) => (
<div
key={`${s.label}-${s.value}`}
className={cn(
"flex flex-col gap-1",
i > 0 && "border-l border-[rgba(82,89,102,0.2)] pl-5",
)}
>
<span className="text-[15px] font-semibold leading-none text-[#fafafa]">
{s.value}
</span>
<span className="text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
{s.label}
</span>
</div>
))}
</motion.div>
)
}
// Quiet outline chips, short entities only (no sentences).
function Chips({ items }: { items: string[] }) {
const shown = items.filter((t) => t.length <= 42).slice(0, 4)
if (!shown.length) return null
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-2.5 flex flex-wrap gap-1.5"
>
{shown.map((t) => (
<span
key={t}
className="rounded-md border border-[rgba(82,89,102,0.22)] px-2 py-[3px] text-[11px] font-medium text-[#8B8B8B]"
>
{t}
</span>
))}
</motion.div>
)
}
function hostname(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "")
} catch {
return url.replace(/^https?:\/\//, "").replace(/\/.*$/, "")
}
}
function SourceFavicon({ host }: { host: string }) {
const sources = [
`https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${host}&size=32`,
`https://icons.duckduckgo.com/ip3/${host}.ico`,
]
const [idx, setIdx] = useState(0)
if (idx >= sources.length) {
return <Globe className="size-3 text-[#525D6E]" />
}
return (
<img
src={sources[idx]}
alt=""
className="size-4 rounded-[3px] object-contain"
onError={() => setIdx((i) => i + 1)}
/>
)
}
// Minimal, deduped source row: favicon + muted domain, no boxes.
function SourceChips({ urls }: { urls: string[] }) {
const seen = new Set<string>()
const hosts: { host: string; url: string }[] = []
for (const url of urls) {
const host = hostname(url)
if (seen.has(host)) continue
seen.add(host)
hosts.push({ host, url })
}
const shown = hosts.slice(0, 4)
const extra = hosts.length - shown.length
if (!shown.length) return null
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-3 flex flex-wrap items-center gap-x-3.5 gap-y-1.5"
>
<span className="text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
Sources
</span>
{shown.map(({ host, url }) => (
<a
key={url}
href={url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[11px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
>
<span className="flex size-4 items-center justify-center">
<SourceFavicon host={host} />
</span>
{host}
</a>
))}
{extra > 0 && (
<span className="text-[11px] font-medium text-[#525D6E]">+{extra}</span>
)}
</motion.div>
)
}
// All dots render in a 14px box so their centers align regardless of shape.
function EventDot({ status, active }: { status: string; active: boolean }) {
if (active) {
return <Loader2 className="size-3.5 shrink-0 animate-spin text-[#4BA0FA]" />
}
if (status === "complete") {
return (
<span className="flex size-3.5 shrink-0 items-center justify-center rounded-full bg-[#4BA0FA]">
<Check className="size-2.5 text-[#05080D]" />
</span>
)
}
return (
<span className="flex size-3.5 shrink-0 items-center justify-center">
<span
className={cn(
"size-2.5 rounded-full",
status === "error" ? "bg-[#E5735A]" : "bg-[#4BA0FA]/60",
)}
/>
</span>
)
}
function Backdrop() {
return (
<>
<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%)",
}}
/>
</>
)
}

View file

@ -0,0 +1,777 @@
"use client"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import { AnimatePresence, motion } from "motion/react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { ArrowRight, Check, Loader2, Mail, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import {
brainConnectorIcon,
SlackMark,
} from "@/components/brain-connector-icons"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { useResearchStatus } from "@/hooks/use-research-status"
import { dmSans125ClassName } from "@/lib/fonts"
import { cardSurfaceStyle, inputBevelStyle, inputClass } from "./step-about"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const MCP_BASE = `${BACKEND}/brain/mcp-connections`
const FEATURED_SLUGS = ["linear", "granola", "sentry"] as const
const SETUP_STEPS = ["slack", "apps", "invite"] as const
type SetupStepId = (typeof SETUP_STEPS)[number]
const ROTATE_MS = 35_000
const REVEAL_DELAY_MS = 1_800
const tileStyle = {
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)",
}
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi
type AuthType = "oauth" | "static" | "none"
type CatalogEntry = {
slug: string
name: string
category: string
authType: AuthType
tokenHint?: string
}
type ConnRow = {
serverSlug: string
status: "active" | "pending" | "error"
userId: string | null
}
export type ParallelSetupStats = {
slackConnected: boolean
appsConnected: number
invitesSent: number
}
const STEP_META: Record<SetupStepId, { label: string; hint: string }> = {
slack: {
label: "Add to Slack",
hint: "Ask your company brain in-channel.",
},
apps: {
label: "Connect apps",
hint: "",
},
invite: {
label: "Invite teammates",
hint: "Multiply what the brain remembers.",
},
}
function titleCase(s: string) {
return s.replace(/\b\w/g, (c) => c.toUpperCase())
}
export function ResearchActionRail({
domain,
onStatsChange,
}: {
domain: string
onStatsChange?: (stats: ParallelSetupStats) => void
}) {
const { org } = useAuth()
const { openSettings } = useSettingsModal()
const { events } = useResearchStatus()
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
const [rows, setRows] = useState<ConnRow[]>([])
const [slack, setSlack] = useState<{
connected: boolean
teamName: string | null
} | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const [invites, setInvites] = useState<{ email: string }[]>([])
const [draft, setDraft] = useState("")
const [invitesSent, setInvitesSent] = useState(0)
const [sendingInvites, setSendingInvites] = useState(false)
const [spotlight, setSpotlight] = useState<SetupStepId>("slack")
const [rotationPaused, setRotationPaused] = useState(false)
const [minDelayPassed, setMinDelayPassed] = useState(false)
const pauseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const domainOrFallback = (domain || "your-team.com").trim().toLowerCase()
const load = useCallback(async () => {
try {
const [cat, conn, s] = await Promise.all([
fetch(`${MCP_BASE}/catalog`, { credentials: "include" }),
fetch(`${MCP_BASE}/`, { credentials: "include" }),
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
])
try {
if (cat.ok) {
const data: { catalog?: CatalogEntry[] } = await cat.json()
setCatalog(data.catalog ?? [])
} else setCatalog([])
} catch {
setCatalog([])
}
try {
if (conn.ok) {
const data: { connections?: ConnRow[] } = await conn.json()
setRows(data.connections ?? [])
} else setRows([])
} catch {
setRows([])
}
try {
if (s.ok) setSlack(await s.json())
} catch {}
} catch {
setCatalog([])
setRows([])
}
}, [])
useEffect(() => {
void load()
const onFocus = () => void load()
window.addEventListener("focus", onFocus)
return () => window.removeEventListener("focus", onFocus)
}, [load])
useEffect(() => {
const t = window.setTimeout(() => setMinDelayPassed(true), REVEAL_DELAY_MS)
return () => window.clearTimeout(t)
}, [])
const isConnected = useCallback(
(slug: string) =>
rows.some((r) => r.serverSlug === slug && r.status === "active"),
[rows],
)
const apps = catalog ?? []
const featured = FEATURED_SLUGS.map((slug) =>
apps.find((a) => a.slug === slug),
).filter((a): a is CatalogEntry => Boolean(a))
const appsConnected = apps.filter((a) => isConnected(a.slug)).length
const slackConnected = slack?.connected ?? false
const stepDone = useCallback(
(id: SetupStepId) => {
if (id === "slack") return slackConnected
if (id === "apps") return appsConnected > 0
return invitesSent > 0
},
[slackConnected, appsConnected, invitesSent],
)
const incompleteSteps = useMemo(
() => SETUP_STEPS.filter((id) => !stepDone(id)),
[stepDone],
)
const pauseRotation = useCallback((ms = 60_000) => {
setRotationPaused(true)
if (pauseTimerRef.current) clearTimeout(pauseTimerRef.current)
pauseTimerRef.current = setTimeout(() => setRotationPaused(false), ms)
}, [])
useEffect(() => {
return () => {
if (pauseTimerRef.current) clearTimeout(pauseTimerRef.current)
}
}, [])
useEffect(() => {
if (rotationPaused || incompleteSteps.length === 0) return
if (!incompleteSteps.includes(spotlight)) {
setSpotlight(incompleteSteps[0] ?? "slack")
}
const timer = window.setInterval(() => {
setSpotlight((prev) => {
const pool = SETUP_STEPS.filter((id) => !stepDone(id))
if (pool.length === 0) return prev
const idx = pool.indexOf(prev)
return pool[(idx + 1) % pool.length] ?? pool[0] ?? "slack"
})
}, ROTATE_MS)
return () => window.clearInterval(timer)
}, [rotationPaused, incompleteSteps, spotlight, stepDone])
useEffect(() => {
onStatsChange?.({
slackConnected,
appsConnected,
invitesSent,
})
}, [slackConnected, appsConnected, invitesSent, onStatsChange])
const setupBeatDone = events.some(
(e) => e.aspect === "prepare" && e.status === "complete",
)
const revealed = minDelayPassed && (setupBeatDone || events.length >= 2)
const focusStep = (id: SetupStepId) => {
setSpotlight(id)
pauseRotation()
}
const connect = async (entry: CatalogEntry) => {
pauseRotation()
setBusy(entry.slug)
try {
if (entry.authType === "static") {
const token = window.prompt(
`Paste a token for ${entry.name}.${entry.tokenHint ? `\n${entry.tokenHint}` : ""}`,
)
if (!token) return
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect-static`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token, shared: false }),
})
if (!res.ok) {
toast.error("Couldn't connect.")
return
}
toast.success(`${entry.name} connected.`)
await load()
return
}
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
shared: false,
redirectUrl: window.location.href,
}),
})
if (!res.ok) {
toast.error("Couldn't start the connection.")
return
}
const data: { authUrl?: string; ok?: boolean } = await res.json()
if (data.authUrl) window.open(data.authUrl, "_blank", "noopener")
else if (data.ok) {
toast.success(`${entry.name} connected.`)
await load()
} else toast.error("Couldn't start the connection.")
} catch {
toast.error("Couldn't start the connection.")
} finally {
setBusy(null)
}
}
const addInvites = (text: string) => {
const found = text.match(EMAIL_RE) ?? []
if (found.length === 0) return
const existing = new Set(invites.map((i) => i.email.toLowerCase()))
const next: { email: string }[] = []
for (const raw of found) {
const email = raw.trim().toLowerCase()
if (!email || existing.has(email)) continue
existing.add(email)
next.push({ email })
}
if (next.length === 0) {
setDraft("")
return
}
setInvites((prev) => [...prev, ...next])
setDraft("")
}
const sendInvites = async () => {
if (sendingInvites || invites.length === 0) return
if (!org?.id) {
toast.error("Organization isn't ready yet.")
return
}
setSendingInvites(true)
try {
const results = await Promise.allSettled(
invites.map((inv) =>
authClient.organization.inviteMember({
email: inv.email,
role: "member",
organizationId: org.id,
resend: true,
}),
),
)
const failed = results.filter(
(r) =>
r.status === "rejected" ||
(r.status === "fulfilled" && Boolean(r.value?.error)),
).length
const sent = invites.length - failed
if (sent > 0) {
setInvitesSent((n) => n + sent)
setInvites([])
toast.success(
`Sent ${sent} invite${sent === 1 ? "" : "s"} to your team.`,
)
}
if (failed > 0) {
toast.error(
`${failed} invite${failed === 1 ? "" : "s"} couldn't be sent.`,
)
}
} catch {
toast.error("Couldn't send invites. Try again in a moment.")
} finally {
setSendingInvites(false)
}
}
const doneSummary = (id: SetupStepId) => {
if (id === "slack") return slack?.teamName ?? "Connected"
if (id === "apps") return `${appsConnected} connected`
return `${invitesSent} sent`
}
return (
<div
style={cardSurfaceStyle}
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[20px] bg-[#161A20]/95"
>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-6">
<p
className={cn(
"text-[13px] font-medium text-[#737373]",
dmSans125ClassName(),
)}
>
While we research
</p>
{!revealed ? (
<ol className="mt-5 flex flex-col opacity-40">
{SETUP_STEPS.map((id, i) => {
const isLast = i === SETUP_STEPS.length - 1
const meta = STEP_META[id]
return (
<li key={id} className="relative flex gap-3.5 pb-5 last:pb-0">
<div className="relative flex w-3.5 shrink-0 justify-center">
<span className="flex size-3.5 shrink-0 items-center justify-center">
<span className="size-2 rounded-full bg-[#4BA0FA]/35" />
</span>
{!isLast && (
<span className="absolute left-1/2 top-[18px] -bottom-[14px] w-px -translate-x-1/2 bg-[#2E353D]/70" />
)}
</div>
<span className="text-[13px] font-medium text-[#525D6E]">
{meta.label}
</span>
</li>
)
})}
</ol>
) : (
<ol className="mt-5 flex flex-col">
{SETUP_STEPS.map((id, i) => {
const done = stepDone(id)
const active = !done && spotlight === id
const isLast = i === SETUP_STEPS.length - 1
const meta = STEP_META[id]
return (
<li key={id} className="relative flex gap-3.5 pb-5 last:pb-0">
<div className="relative flex w-3.5 shrink-0 justify-center">
<SetupDot done={done} active={active} />
{!isLast && (
<span className="absolute left-1/2 top-[18px] -bottom-[14px] w-px -translate-x-1/2 bg-[#2E353D]/70" />
)}
</div>
<div className="min-w-0 flex-1 -mt-px">
<button
type="button"
onClick={() => focusStep(id)}
className="w-full text-left"
>
<span
className={cn(
"text-[13px] font-medium leading-[14px] transition-colors",
active && "text-[#FAFAFA]",
done && "text-[#525D6E]",
!active &&
!done &&
"text-[#737373] hover:text-[#A1A1AA]",
)}
>
{meta.label}
</span>
{done && (
<p className="mt-1 text-[12px] font-medium text-[#525D6E]">
{doneSummary(id)}
</p>
)}
</button>
<AnimatePresence initial={false} mode="popLayout">
{active && (
<motion.div
key={`${id}-body`}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -2 }}
transition={{ duration: 0.18 }}
className="mt-3"
>
{meta.hint ? (
<p className="text-[12px] font-medium leading-[1.5] text-[#525D6E]">
{meta.hint}
</p>
) : null}
<div className={cn(meta.hint ? "mt-3" : "mt-2")}>
{id === "slack" && (
<SlackStepBody
connected={slackConnected}
teamName={slack?.teamName ?? null}
/>
)}
{id === "apps" && (
<AppsStepBody
catalog={catalog}
featured={featured}
isConnected={isConnected}
busy={busy}
onConnect={connect}
onBrowse={() => {
pauseRotation()
openSettings("company-brain")
}}
/>
)}
{id === "invite" && (
<InviteStepBody
domain={domainOrFallback}
draft={draft}
invites={invites}
invitesSent={invitesSent}
sendingInvites={sendingInvites}
onDraftChange={setDraft}
onAddInvites={addInvites}
onRemoveInvite={(email) =>
setInvites((prev) =>
prev.filter((i) => i.email !== email),
)
}
onSendInvites={sendInvites}
onFocus={() => pauseRotation()}
/>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</li>
)
})}
</ol>
)}
</div>
</div>
)
}
function SetupDot({ done, active }: { done: boolean; active: boolean }) {
if (done) {
return (
<span className="flex size-3.5 shrink-0 items-center justify-center rounded-full bg-[#4BA0FA]">
<Check className="size-2.5 text-[#05080D]" strokeWidth={3} />
</span>
)
}
if (active) {
return (
<span className="flex size-3.5 shrink-0 items-center justify-center">
<span className="size-2.5 rounded-full bg-[#4BA0FA]" />
</span>
)
}
return (
<span className="flex size-3.5 shrink-0 items-center justify-center">
<span className="size-2 rounded-full bg-[#4BA0FA]/35" />
</span>
)
}
function SlackStepBody({
connected,
teamName,
}: {
connected: boolean
teamName: string | null
}) {
if (connected) {
return (
<p className="text-[12px] font-medium text-[#525D6E]">
Live in {teamName ?? "your workspace"}
</p>
)
}
return (
<a
href={`${BACKEND}/brain/slack/oauth/install`}
className={cn(
"inline-flex w-full items-center justify-center gap-2 rounded-full bg-white px-4 py-2.5 text-[13px] font-semibold text-[#1D1C1D] transition-opacity hover:opacity-90",
dmSans125ClassName(),
)}
>
<SlackMark className="size-4" />
Add to Slack
</a>
)
}
function AppsStepBody({
catalog,
featured,
isConnected,
busy,
onConnect,
onBrowse,
}: {
catalog: CatalogEntry[] | null
featured: CatalogEntry[]
isConnected: (slug: string) => boolean
busy: string | null
onConnect: (entry: CatalogEntry) => void
onBrowse: () => void
}) {
if (catalog === null) {
return (
<div className="overflow-hidden rounded-[12px] border border-white/[0.06] bg-[#14161A]/80">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className={cn(
"flex h-12 items-center gap-3 px-3",
i < 2 && "border-b border-white/[0.04]",
)}
>
<div className="size-8 animate-pulse rounded-[8px] bg-[#1c2128]" />
<div className="h-2.5 flex-1 animate-pulse rounded bg-[#1c2128]" />
</div>
))}
</div>
)
}
if (featured.length === 0) {
return (
<button
type="button"
onClick={onBrowse}
className="text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A1A1AA]"
>
Browse connections
</button>
)
}
const allConnected = featured.every((e) => isConnected(e.slug))
return (
<div className="space-y-2.5">
<div
className="overflow-hidden rounded-[12px] border border-white/[0.06] bg-[#14161A]/80"
style={inputBevelStyle}
>
{featured.map((entry, i) => (
<AppTile
key={entry.slug}
icon={brainConnectorIcon(entry.slug, entry.name, "size-4")}
name={entry.name}
subtitle={titleCase(entry.category)}
connected={isConnected(entry.slug)}
busy={busy === entry.slug}
onConnect={() => onConnect(entry)}
showDivider={i < featured.length - 1}
/>
))}
</div>
<button
type="button"
onClick={onBrowse}
className="text-[12px] font-medium text-[#525D6E] transition-colors hover:text-[#A1A1AA]"
>
{allConnected ? "Browse more connections →" : "More connections →"}
</button>
</div>
)
}
function InviteStepBody({
domain,
draft,
invites,
invitesSent,
sendingInvites,
onDraftChange,
onAddInvites,
onRemoveInvite,
onSendInvites,
onFocus,
}: {
domain: string
draft: string
invites: { email: string }[]
invitesSent: number
sendingInvites: boolean
onDraftChange: (v: string) => void
onAddInvites: (text: string) => void
onRemoveInvite: (email: string) => void
onSendInvites: () => void
onFocus: () => void
}) {
return (
<div className="space-y-2.5">
<form
onSubmit={(e) => {
e.preventDefault()
onAddInvites(draft)
}}
className="flex gap-2"
>
<div className="relative flex-1">
<Mail className="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-[#525D6E]" />
<Input
value={draft}
onChange={(e) => onDraftChange(e.target.value)}
onFocus={onFocus}
onPaste={(e) => {
const pasted = e.clipboardData.getData("text")
if (pasted && EMAIL_RE.test(pasted)) {
e.preventDefault()
onAddInvites(pasted)
}
}}
placeholder={`teammate@${domain}`}
className={cn(inputClass, "h-10 pl-9 text-[13px]")}
style={inputBevelStyle}
/>
</div>
<Button
type="submit"
variant="insideOut"
disabled={!draft.trim()}
className="rounded-full size-10 p-0 text-[#fafafa] shrink-0"
>
<Plus className="size-4" />
</Button>
</form>
{invites.length > 0 && (
<div className="space-y-1.5">
{invites.map((inv) => (
<div key={inv.email} className="flex items-center gap-2 py-1">
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[#737373]">
{inv.email}
</span>
<button
type="button"
onClick={() => onRemoveInvite(inv.email)}
className="text-[#525D6E] hover:text-[#A1A1AA] p-0.5"
aria-label={`Remove ${inv.email}`}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
<Button
variant="insideOut"
onClick={onSendInvites}
disabled={sendingInvites}
className="w-full rounded-full h-9 text-[13px] font-medium text-[#fafafa]"
>
{sendingInvites ? (
<>
Sending
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
Send {invites.length} invite
{invites.length === 1 ? "" : "s"}
<ArrowRight className="size-3.5" />
</>
)}
</Button>
</div>
)}
{invitesSent > 0 && invites.length === 0 && (
<p className="text-[12px] font-medium text-[#525D6E]">
{invitesSent} invite{invitesSent === 1 ? "" : "s"} sent
</p>
)}
</div>
)
}
function AppTile({
icon,
name,
subtitle,
connected,
busy,
onConnect,
showDivider = false,
}: {
icon: React.ReactNode
name: string
subtitle: string
connected: boolean
busy: boolean
onConnect: () => void
showDivider?: boolean
}) {
return (
<div
className={cn(
"flex min-h-[48px] items-center gap-2.5 px-3 py-2",
showDivider && "border-b border-white/[0.04]",
)}
>
<div
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[8px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
style={tileStyle}
>
{icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-[12px] font-semibold leading-none text-[#E4E4E7]">
{name}
</p>
<p className="mt-0.5 truncate text-[11px] font-medium text-[#525D6E]">
{subtitle}
</p>
</div>
{connected ? (
<Check className="size-3.5 shrink-0 text-[#4BA0FA]" strokeWidth={2.5} />
) : (
<button
type="button"
onClick={onConnect}
disabled={busy}
className={cn(
dmSans125ClassName(),
"flex shrink-0 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.06] px-3 py-1 text-[11px] font-medium text-[#FAFAFA] transition-opacity hover:bg-white/[0.1] disabled:opacity-50",
)}
>
{busy ? <Loader2 className="size-3 animate-spin" /> : "Connect"}
</button>
)}
</div>
)
}

View file

@ -46,18 +46,18 @@ interface Props {
submitting?: boolean
}
const cardSurfaceStyle = {
export const cardSurfaceStyle = {
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 = {
export 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)",
}
const fieldLabel = "pl-2 pb-2 font-semibold text-[14px] text-[#737373]"
const inputClass =
export const fieldLabel = "pl-2 pb-2 font-semibold text-[14px] text-[#737373]"
export const inputClass =
"bg-[#0F1217] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#525D6E] h-12 px-4 shadow-none focus-visible:ring-0 focus-visible:border-[rgba(115,115,115,0.3)] transition-colors"
export function StepAbout({
@ -409,7 +409,7 @@ const TEAM_PERKS: Perk[] = [
},
]
function DomainLogo({ domain }: { domain: string }) {
export function DomainLogo({ domain }: { domain: string }) {
const sources = [
`https://logo.clearbit.com/${domain}`,
`https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${domain}&size=64`,
@ -527,7 +527,7 @@ function ModeToggle({
)
}
function UserAvatar({
export function UserAvatar({
url,
name,
className,

View file

@ -1,3 +1,7 @@
export type CompanyBrainConfirmResult =
| { ok: true; serverSchedulesResearch: boolean }
| { ok: false }
export type BrainMode = "personal" | "team"
export type BrainStep = "about" | "sources" | "ingest" | "team"
@ -66,6 +70,22 @@ export function workspaceNameFromEmail(
return root.charAt(0).toUpperCase() + root.slice(1)
}
/** e.g. duolingo.com → Duolingo (first hostname label, title-cased). */
export function workspaceNameFromDomain(
domain: string | undefined | null,
): string {
if (!domain) return ""
const clean = domain
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.replace(/\/.*$/, "")
const host = clean.split(".")[0] ?? ""
if (!host) return ""
return host.charAt(0).toUpperCase() + host.slice(1)
}
export function workspaceDomainFromEmail(
email: string | undefined | null,
): string | null {

View file

@ -19,12 +19,15 @@ import {
HelpCircle,
LifeBuoy,
Building2,
Sun,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
import { useTokenUsage } from "@/hooks/use-token-usage"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { useViewMode } from "@/lib/view-mode-context"
export function UserProfileMenu({
className,
@ -38,6 +41,8 @@ export function UserProfileMenu({
const { user } = useAuth()
const router = useRouter()
const { openSettings } = useSettingsModal()
const { setViewMode } = useViewMode()
const isCompanyBrain = useHasCompanyBrain()
const { resetOrgOnboarded } = useOrgOnboarding()
const autumn = useCustomer()
const { currentPlan, isLoading: planLoading } = useTokenUsage(autumn)
@ -166,6 +171,15 @@ export function UserProfileMenu({
<Building2 className="size-4 text-[#737373]" />
Company Brain
</DropdownMenuItem>
{isCompanyBrain ? (
<DropdownMenuItem
onClick={() => void setViewMode("integrations")}
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<Sun className="size-4 text-[#737373]" />
Integrations
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onClick={handleTryOnboarding}
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"

View file

@ -0,0 +1,64 @@
"use client"
import { useQuery } from "@tanstack/react-query"
import { useAuth } from "@lib/auth-context"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const POLL_INTERVAL_MS = 2_000
const MAX_POLLS = 150
export type ResearchStat = { label: string; value: string }
export type ResearchEvent = {
aspect: string
label: string
status: "in_progress" | "complete" | "error" | string
detail: string | null
stats: ResearchStat[]
highlights: string[]
sources: string[]
createdAt: number
}
export type ResearchState = {
status: "queued" | "running" | "done" | null
domain: string | null
findings: number
events: ResearchEvent[]
}
const EMPTY: ResearchState = {
status: null,
domain: null,
findings: 0,
events: [],
}
export function useResearchStatus(enabled = true) {
const { org } = useAuth()
const orgId = org?.id
const { data } = useQuery<ResearchState>({
queryKey: ["brain-research-status", orgId],
queryFn: async () => {
const res = await fetch(`${BACKEND}/brain/research/status`, {
credentials: "include",
headers: { "X-App-Source": "nova" },
})
if (!res.ok) return EMPTY
return (await res.json()) as ResearchState
},
enabled: Boolean(enabled && orgId),
refetchInterval: (query) => {
const status = query.state.data?.status
const polls = query.state.dataUpdateCount
if (status === "done" || polls >= MAX_POLLS) return false
return POLL_INTERVAL_MS
},
staleTime: 0,
})
return data ?? EMPTY
}

View file

@ -71,6 +71,26 @@ export function getSignupSource(
: null
}
// Company domain captured during team onboarding.
export function getBrainWorkspaceDomain(
metadataRaw: Record<string, unknown> | string | null | undefined,
): string | null {
if (!metadataRaw) return null
let metadata: Record<string, unknown>
if (typeof metadataRaw === "string") {
try {
metadata = JSON.parse(metadataRaw) as Record<string, unknown>
} catch {
return null
}
} else {
metadata = metadataRaw
}
return typeof metadata.brainWorkspaceDomain === "string"
? (metadata.brainWorkspaceDomain as string)
: null
}
// Brain mode chosen during onboarding ("personal" | "team"). Set synchronously
// at org creation, so it's the reliable pre-webhook signal for company brain.
export function getBrainMode(