"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 { useRouter } from "next/navigation" import { useResearchStatus } from "@/hooks/use-research-status" import { dmSans125ClassName } from "@/lib/fonts" import { SetupCallButton } from "./setup-call-button" 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 = { 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 router = useRouter() const { events } = useResearchStatus() const [catalog, setCatalog] = useState(null) const [rows, setRows] = useState([]) const [slack, setSlack] = useState<{ connected: boolean teamName: string | null } | null>(null) const [busy, setBusy] = useState(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("slack") const [rotationPaused, setRotationPaused] = useState(false) const [minDelayPassed, setMinDelayPassed] = useState(false) const pauseTimerRef = useRef | 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 (

While we research

{!revealed ? (
    {SETUP_STEPS.map((id, i) => { const isLast = i === SETUP_STEPS.length - 1 const meta = STEP_META[id] return (
  1. {!isLast && ( )}
    {meta.label}
  2. ) })}
) : (
    {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 (
  1. {!isLast && ( )}
    {active && ( {meta.hint ? (

    {meta.hint}

    ) : null}
    {id === "slack" && ( )} {id === "apps" && ( { pauseRotation() router.push("/?view=configure") }} /> )} {id === "invite" && ( setInvites((prev) => prev.filter((i) => i.email !== email), ) } onSendInvites={sendInvites} onFocus={() => pauseRotation()} /> )}
    )}
  2. ) })}
)}

Want us to wire it up live? Slack, connectors, plugins, and a working walkthrough.

) } function SetupDot({ done, active }: { done: boolean; active: boolean }) { if (done) { return ( ) } if (active) { return ( ) } return ( ) } function SlackStepBody({ connected, teamName, }: { connected: boolean teamName: string | null }) { if (connected) { return (

Live in {teamName ?? "your workspace"}

) } return (
Add to Slack

Included in your 14-day trial.

) } 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 (
{Array.from({ length: 3 }).map((_, i) => (
))}
) } if (featured.length === 0) { return ( ) } const allConnected = featured.every((e) => isConnected(e.slug)) return (
{featured.map((entry, i) => ( onConnect(entry)} showDivider={i < featured.length - 1} /> ))}
) } 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 (
{ e.preventDefault() onAddInvites(draft) }} className="flex gap-2" >
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} />
{invites.length > 0 && (
{invites.map((inv) => (
{inv.email}
))}
)} {invitesSent > 0 && invites.length === 0 && (

{invitesSent} invite{invitesSent === 1 ? "" : "s"} sent

)}
) } 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 (
{icon}

{name}

{subtitle}

{connected ? ( ) : ( )}
) }