From 6641080efb4bff570fa3dda6b2f8ee946ee2e184 Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommu Date: Fri, 3 Jul 2026 21:04:21 -0700 Subject: [PATCH 1/3] feat(web): company brain onboarding research UI - Confirm-domain step, then live research transcript + action rail - Poll research status; client-side force-start fallback --- apps/web/app/(app)/onboarding/page.tsx | 91 +- apps/web/components/app-experience.tsx | 2 +- apps/web/components/company-brain-header.tsx | 604 +++++++++++++ apps/web/components/header.tsx | 12 +- .../company-brain-onboarding.tsx | 695 +++++++++++++++ .../onboarding-brain/research-action-rail.tsx | 792 ++++++++++++++++++ .../onboarding-brain/step-about.tsx | 12 +- apps/web/components/onboarding-brain/types.ts | 20 + apps/web/components/user-profile-menu.tsx | 14 + apps/web/hooks/use-research-status.ts | 64 ++ apps/web/lib/billing-utils.ts | 20 + 11 files changed, 2314 insertions(+), 12 deletions(-) create mode 100644 apps/web/components/company-brain-header.tsx create mode 100644 apps/web/components/onboarding-brain/company-brain-onboarding.tsx create mode 100644 apps/web/components/onboarding-brain/research-action-rail.tsx create mode 100644 apps/web/hooks/use-research-status.ts diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 27bd853d7..b4d145c2b 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -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,9 +242,14 @@ 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 ensureOrg = useCallback( + async (domainOverride?: string): Promise => { + 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 } = { @@ -246,7 +258,9 @@ export default function BrainOnboardingPage() { brainMode: effectiveMode, brainWorkspaceName: name, brainWorkspaceDomain: - effectiveMode === "team" ? about.workspaceDomain || domain : null, + effectiveMode === "team" + ? domainOverride || about.workspaceDomain || domain + : null, brainContainerTag: containerTag, ...(about.about.trim() ? { brainAbout: about.about.trim() } : {}), } @@ -281,6 +295,7 @@ export default function BrainOnboardingPage() { url.searchParams.delete("name") router.replace(url.pathname + url.search, { scroll: false }) } + return true }, [ organizations, about, @@ -320,6 +335,61 @@ 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 => { + 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) { + try { + await fetch(`${BACKEND}/brain/research/start`, { + method: "POST", + credentials: "include", + headers: { + "content-type": "application/json", + "X-App-Source": "nova", + }, + body: JSON.stringify({ domain: confirmedDomain }), + }) + } catch {} + } + queryClient.invalidateQueries({ queryKey: ["brain-research-status"] }) + 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 +445,19 @@ export default function BrainOnboardingPage() { const mcpUrl = "https://mcp.supermemory.ai/mcp" + if (isCompanyBrain) { + return ( + + ) + } + return ( )} - {isDashboardShell && ( + {isDashboardShell && !isCompanyBrain && (
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 => { + 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 | 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 ( +
+
+ + + + + + {hasOrgs && ( + <> +

+ Switch brain +

+
+ {organizations?.map((o) => { + const active = org?.id === o.id + const plan = resolveOrgPlan( + o.id, + active, + currentPlan, + planByOrgId, + ) + return ( + selectOrg(o.slug, active)} + className={cn(brainItemClass(active))} + > + + {o.name?.trim().charAt(0).toUpperCase() || "?"} + + {o.name} + + + ) + })} +
+ + + )} + + + + Home + + + + + Connections + + + + Integrations + + openSettings()} + className={menuItemClass} + > + + Settings + + + + + + Developer console + + + + + + supermemory.ai + + +
+
+ {!isMobile && ( + <> + + + + )} +
+ + {!isMobile && ( +
+ + + + + + Overview + + +
+ + + +
+ +
+ )} + +
+ {isMobile ? ( + <> + + + + + + + + + Overview + + + + Graph + + + + Memories + + + + Connections + + + + Integrations + + {slackConnected ? ( + + + Slack connected + + ) : ( + + + + Add to Slack + + + )} + + {canInvite && ( + + + Invite teammates + + )} + + + Search + + + + + Feedback + + openSettings()} + className={menuItemClass} + > + + Settings + + + + + ) : ( + <> + {canInvite && ( + + + + + + Invite teammates + + + )} + + + + + + Search (⌘K) + + + + )} + +
+ + setFeedbackOpen(false)} + /> +
+ ) +} + +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 ( + + + + + + + + Add to Slack + + + ) + } + + return ( + + + + + + {label} + + + ) +} diff --git a/apps/web/components/header.tsx b/apps/web/components/header.tsx index 3d9772098..c30f84c4d 100644 --- a/apps/web/components/header.tsx +++ b/apps/web/components/header.tsx @@ -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 + } + return +} + +function PersonalBrainHeader({ onAddMemory, onOpenSearch }: HeaderProps) { const { user, isRestoring, org, organizations, setActiveOrg } = useAuth() const autumn = useCustomer() const { currentPlan } = useTokenUsage(autumn) diff --git a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx new file mode 100644 index 000000000..27fb29b14 --- /dev/null +++ b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx @@ -0,0 +1,695 @@ +"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, useCallback, 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 + 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("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 ( +
+ + +
+ +
+ +
+ {/* Persistent card: full confirm card, then morphs into a slim docked header. */} + + + {phase === "confirm" ? ( + + + + ) : ( + + + + )} + + + + {phase === "confirm" && ( +
+ +
+ )} + + + {phase === "research" && ( + +
+
+ +
+ + + +
+
+ )} +
+
+
+ ) +} + +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 ( + <> +
+ +
+

+ {firstName ? `Hey ${firstName} 👋` : "Hey there 👋"} +

+

+ I'll research your company and set up its Brain. +

+
+
+ +
+

Company domain

+
+
+ +
+ 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} + /> +
+
+ + ) +} + +function DockedHeader({ + domain, + done, + onContinue, +}: { + domain: string + done: boolean + onContinue: () => void +}) { + const brandName = workspaceNameFromDomain(domain) || domain + return ( +
+
+ +
+ {brandName} + + {done ? "Company Brain ready" : "Building your Company Brain…"} + + {done ? ( + + ) : ( + + )} +
+ ) +} + +function ResearchTranscript() { + const { status, events } = useResearchStatus() + const running = status !== "done" + const scrollRef = useRef(null) + + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new events + useEffect(() => { + scrollRef.current?.scrollTo({ + top: scrollRef.current.scrollHeight, + behavior: "smooth", + }) + }, [events.length]) + + return ( +
+
+
+ +
+ {/* Top-only overlay in the card color: text slides up under a solid edge. */} +
+
+
+ ) +} + +function Timeline({ + events, + running, +}: { + events: ResearchEvent[] + running: boolean +}) { + if (events.length === 0) { + return ( +
+ + Starting deep research… +
+ ) + } + + return ( +
    + + {events.map((e, i) => { + const isLast = i === events.length - 1 + const active = running && e.status === "in_progress" + return ( + +
    + + {!isLast && ( + + )} +
    +
    + + {e.label} + + {e.detail && e.status !== "in_progress" && ( + + + + )} + {e.status === "complete" && e.stats.length > 0 && ( + + )} + {e.status === "complete" && e.highlights.length > 0 && ( + + )} + {e.status === "complete" && e.sources.length > 0 && ( + + )} + {active && } +
    +
    + ) + })} +
    +
+ ) +} + +// 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( + + {m[1]} + , + ) + 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 ( + + {THINKING_PHRASES[i]} + + ) +} + +// 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 ( + + {shown.map((s, i) => ( +
0 && "border-l border-[rgba(82,89,102,0.2)] pl-5", + )} + > + + {s.value} + + + {s.label} + +
+ ))} +
+ ) +} + +// 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 ( + + {shown.map((t) => ( + + {t} + + ))} + + ) +} + +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 + } + return ( + setIdx((i) => i + 1)} + /> + ) +} + +// Minimal, deduped source row: favicon + muted domain, no boxes. +function SourceChips({ urls }: { urls: string[] }) { + const seen = new Set() + 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 ( + + + Sources + + {shown.map(({ host, url }) => ( + + + + + {host} + + ))} + {extra > 0 && ( + +{extra} + )} + + ) +} + +// 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 + } + if (status === "complete") { + return ( + + + + ) + } + return ( + + + + ) +} + +function Backdrop() { + return ( + <> +
+
+ + ) +} diff --git a/apps/web/components/onboarding-brain/research-action-rail.tsx b/apps/web/components/onboarding-brain/research-action-rail.tsx new file mode 100644 index 000000000..85084a35f --- /dev/null +++ b/apps/web/components/onboarding-brain/research-action-rail.tsx @@ -0,0 +1,792 @@ +"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(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() + openSettings("company-brain") + }} + /> + )} + {id === "invite" && ( + + setInvites((prev) => + prev.filter((i) => i.email !== email), + ) + } + onSendInvites={sendInvites} + onFocus={() => pauseRotation()} + /> + )} +
    +
    + )} +
    +
    +
  2. + ) + })} +
+ )} +
+
+ ) +} + +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 + + ) +} + +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 ? ( + + ) : ( + + )} +
+ ) +} \ No newline at end of file diff --git a/apps/web/components/onboarding-brain/step-about.tsx b/apps/web/components/onboarding-brain/step-about.tsx index eedb2de8b..22db13877 100644 --- a/apps/web/components/onboarding-brain/step-about.tsx +++ b/apps/web/components/onboarding-brain/step-about.tsx @@ -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, diff --git a/apps/web/components/onboarding-brain/types.ts b/apps/web/components/onboarding-brain/types.ts index 2e016f3fa..b77eeec71 100644 --- a/apps/web/components/onboarding-brain/types.ts +++ b/apps/web/components/onboarding-brain/types.ts @@ -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 { diff --git a/apps/web/components/user-profile-menu.tsx b/apps/web/components/user-profile-menu.tsx index 13b98155a..81226aed1 100644 --- a/apps/web/components/user-profile-menu.tsx +++ b/apps/web/components/user-profile-menu.tsx @@ -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({ Company Brain + {isCompanyBrain ? ( + 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" + > + + Integrations + + ) : null} ({ + 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 +} diff --git a/apps/web/lib/billing-utils.ts b/apps/web/lib/billing-utils.ts index 4169c2576..cc652768e 100644 --- a/apps/web/lib/billing-utils.ts +++ b/apps/web/lib/billing-utils.ts @@ -71,6 +71,26 @@ export function getSignupSource( : null } +// Company domain captured during team onboarding. +export function getBrainWorkspaceDomain( + metadataRaw: Record | string | null | undefined, +): string | null { + if (!metadataRaw) return null + let metadata: Record + if (typeof metadataRaw === "string") { + try { + metadata = JSON.parse(metadataRaw) as Record + } 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( From ee6745ca6a9ed1e58524caac21ff014c996d94df Mon Sep 17 00:00:00 2001 From: Vorflux AI <249966464+vorflux[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:50:34 +0000 Subject: [PATCH 2/3] fix(web): compact company brain research rail --- .../onboarding-brain/company-brain-onboarding.tsx | 6 +++--- .../components/onboarding-brain/research-action-rail.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx index 27fb29b14..32c37c00a 100644 --- a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx +++ b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx @@ -211,15 +211,15 @@ export function CompanyBrainOnboarding({ 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" > -
-
+
+
diff --git a/apps/web/components/onboarding-brain/research-action-rail.tsx b/apps/web/components/onboarding-brain/research-action-rail.tsx index 85084a35f..6d1f095cf 100644 --- a/apps/web/components/onboarding-brain/research-action-rail.tsx +++ b/apps/web/components/onboarding-brain/research-action-rail.tsx @@ -351,9 +351,9 @@ export function ResearchActionRail({ return (
-
+

Date: Sat, 4 Jul 2026 18:54:23 +0000 Subject: [PATCH 3/3] ci: rerun checks after draft conversion