mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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:
parent
8c35c1ecad
commit
d7050ed332
11 changed files with 2371 additions and 65 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
604
apps/web/components/company-brain-header.tsx
Normal file
604
apps/web/components/company-brain-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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%)",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
777
apps/web/components/onboarding-brain/research-action-rail.tsx
Normal file
777
apps/web/components/onboarding-brain/research-action-rail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
64
apps/web/hooks/use-research-status.ts
Normal file
64
apps/web/hooks/use-research-status.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue