mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Onboarding now opens a trial step that collects a card through Stripe checkout before the brain is enabled, with a timeline showing today's $0, the day-12 reminder, and the day-14 charge. - Only leaves the card step once the API confirms the trial is live - Brain home shows a setup banner and dims what the trial unlocks - Recovers orgs that abandoned checkout instead of stranding them - Adds the organization ID to account settings, copyable from the label
927 lines
26 KiB
TypeScript
927 lines
26 KiB
TypeScript
"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,
|
|
Building2,
|
|
Check,
|
|
ChevronRight,
|
|
Globe,
|
|
Loader2,
|
|
} from "lucide-react"
|
|
import { useQuery, 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 { CHECKOUT_RETURN_PARAM, StepTrial } from "./step-trial"
|
|
import { useTrialStatus } from "@/hooks/use-trial-status"
|
|
import { analytics } from "@/lib/analytics"
|
|
import {
|
|
type CompanyBrainConfirmResult,
|
|
type CompanyBrainOrganizationChoice,
|
|
workspaceNameFromDomain,
|
|
} from "./types"
|
|
|
|
interface CompanyBrainOnboardingProps {
|
|
name: string
|
|
avatarUrl: string | null
|
|
domain: string
|
|
submitting: boolean
|
|
onConfirm: (
|
|
domain: string,
|
|
organizationId?: string,
|
|
) => Promise<CompanyBrainConfirmResult>
|
|
onDone: () => void
|
|
onUsePersonal: () => void
|
|
}
|
|
|
|
const BACKEND =
|
|
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
|
|
|
type Phase = "confirm" | "trial" | "research"
|
|
|
|
function normalizeDomain(input: string): string {
|
|
const host = input
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/^https?:\/\//, "")
|
|
.replace(/^www\./, "")
|
|
.replace(/\/.*$/, "")
|
|
|
|
// The confirmation card is often filled with a company name (for example,
|
|
// "Zomato") even though research needs a hostname. Preserve explicit TLDs,
|
|
// and make the common single-label case usable without a failed API round trip.
|
|
if (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(host)) {
|
|
return `${host}.com`
|
|
}
|
|
|
|
return host
|
|
}
|
|
|
|
export function CompanyBrainOnboarding({
|
|
name,
|
|
avatarUrl,
|
|
domain: initialDomain,
|
|
submitting,
|
|
onConfirm,
|
|
onDone,
|
|
onUsePersonal,
|
|
}: CompanyBrainOnboardingProps) {
|
|
const [phase, setPhase] = useState<Phase>("confirm")
|
|
const { needsSetup } = useTrialStatus()
|
|
const resumedRef = useRef(false)
|
|
useEffect(() => {
|
|
if (resumedRef.current) return
|
|
const url = new URL(window.location.href)
|
|
if (url.searchParams.get(CHECKOUT_RETURN_PARAM) !== "complete") return
|
|
resumedRef.current = true
|
|
url.searchParams.delete(CHECKOUT_RETURN_PARAM)
|
|
window.history.replaceState({}, "", `${url.pathname}${url.search}`)
|
|
setPhase("research")
|
|
}, [])
|
|
useEffect(() => {
|
|
if (resumedRef.current || !needsSetup || phase !== "confirm") return
|
|
resumedRef.current = true
|
|
setPhase("trial")
|
|
analytics.brainTrialCardViewed()
|
|
}, [needsSetup, phase])
|
|
const [domain, setDomain] = useState(initialDomain)
|
|
const [organizationChoices, setOrganizationChoices] = useState<
|
|
CompanyBrainOrganizationChoice[] | null
|
|
>(null)
|
|
const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false)
|
|
const firstName = name.trim().split(/\s+/)[0] ?? ""
|
|
const clean = normalizeDomain(domain)
|
|
const queryClient = useQueryClient()
|
|
const { status: researchStatus } = useResearchStatus(phase === "research")
|
|
const researchDone = researchStatus === "done"
|
|
// One-shot retry: re-kick research once on a terminal error.
|
|
const retryStage = useRef<"idle" | "started" | "rerunning" | "exhausted">(
|
|
"idle",
|
|
)
|
|
const [retryUi, setRetryUi] = useState<null | "retrying" | "exhausted">(null)
|
|
|
|
const handleConfirm = async (organizationId?: string) => {
|
|
if (!clean || submitting) return
|
|
const result = await onConfirm(clean, organizationId)
|
|
if (!result.ok) {
|
|
if (result.choices?.length) setOrganizationChoices(result.choices)
|
|
return
|
|
}
|
|
setOrganizationChoices(null)
|
|
setServerSchedulesResearch(result.serverSchedulesResearch)
|
|
setPhase("trial")
|
|
analytics.brainTrialCardViewed()
|
|
}
|
|
|
|
// 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])
|
|
|
|
// "error" lingers a render after re-kicking, so only arm the second-error
|
|
// branch once the re-run is observed running.
|
|
useEffect(() => {
|
|
if (phase !== "research" || !clean) return
|
|
const stage = retryStage.current
|
|
if (researchStatus === "error" && stage === "idle") {
|
|
retryStage.current = "started"
|
|
setRetryUi("retrying")
|
|
void (async () => {
|
|
// A failed restart never reaches queued/running, and polling is off on
|
|
// error — without this the UI would say "retrying" forever.
|
|
const ok = await fetch(`${BACKEND}/brain/research/start`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"X-App-Source": "nova",
|
|
},
|
|
body: JSON.stringify({ domain: clean }),
|
|
})
|
|
.then((res) => res.ok)
|
|
.catch(() => false)
|
|
if (!ok) {
|
|
retryStage.current = "exhausted"
|
|
setRetryUi("exhausted")
|
|
return
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ["brain-research-status"] })
|
|
})()
|
|
} else if (
|
|
(researchStatus === "queued" || researchStatus === "running") &&
|
|
stage === "started"
|
|
) {
|
|
retryStage.current = "rerunning"
|
|
} else if (researchStatus === "error" && stage === "rerunning") {
|
|
retryStage.current = "exhausted"
|
|
setRetryUi("exhausted")
|
|
} else if (researchStatus === "done") {
|
|
setRetryUi(null)
|
|
}
|
|
}, [phase, clean, researchStatus, 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 === "research"
|
|
? "justify-start items-stretch pt-2 px-4 md:px-8 xl:px-14"
|
|
: "justify-center items-center px-4 md:px-10",
|
|
)}
|
|
>
|
|
{/* 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 === "research"
|
|
? "max-w-7xl px-5 py-3 xl:max-w-[1360px]"
|
|
: phase === "trial"
|
|
? "max-w-4xl p-6 md:p-7"
|
|
: "max-w-xl p-6 md:p-8",
|
|
)}
|
|
>
|
|
<AnimatePresence mode="wait" initial={false}>
|
|
{phase === "trial" ? (
|
|
<motion.div
|
|
key="trial"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
>
|
|
<StepTrial onActive={() => setPhase("research")} />
|
|
</motion.div>
|
|
) : phase === "confirm" ? (
|
|
<motion.div
|
|
key="confirm"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
>
|
|
{organizationChoices ? (
|
|
<OrganizationChoiceBody
|
|
organizations={organizationChoices}
|
|
submitting={submitting}
|
|
onSelect={(organizationId) => handleConfirm(organizationId)}
|
|
onBack={() => setOrganizationChoices(null)}
|
|
/>
|
|
) : (
|
|
<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}
|
|
retrying={retryUi === "retrying"}
|
|
exhausted={retryUi === "exhausted"}
|
|
onContinue={onDone}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
|
|
{phase === "confirm" && !organizationChoices && (
|
|
<div className="w-full max-w-xl mx-auto mt-5 flex items-center justify-between gap-4 px-1">
|
|
<button
|
|
type="button"
|
|
onClick={onUsePersonal}
|
|
disabled={submitting}
|
|
className="text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa] disabled:opacity-50"
|
|
>
|
|
Use a personal workspace instead
|
|
</button>
|
|
<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 OrganizationChoiceBody({
|
|
organizations,
|
|
submitting,
|
|
onSelect,
|
|
onBack,
|
|
}: {
|
|
organizations: CompanyBrainOrganizationChoice[]
|
|
submitting: boolean
|
|
onSelect: (organizationId: string) => void
|
|
onBack: () => void
|
|
}) {
|
|
return (
|
|
<>
|
|
<div className="flex items-start gap-4">
|
|
<div className="flex size-12 shrink-0 items-center justify-center rounded-full border border-[rgba(82,89,102,0.2)] bg-[#14161A] text-[#4BA0FA]">
|
|
<Building2 className="size-5" />
|
|
</div>
|
|
<div>
|
|
<p className="text-[20px] font-semibold leading-tight text-[#FAFAFA]">
|
|
Choose your Company Brain
|
|
</p>
|
|
<p className="mt-1 text-[14px] font-medium leading-[1.4] text-[#737373]">
|
|
You already belong to more than one Company Brain workspace.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-6 flex flex-col gap-2">
|
|
{organizations.map((organization) => (
|
|
<button
|
|
key={organization.id}
|
|
type="button"
|
|
disabled={submitting}
|
|
onClick={() => onSelect(organization.id)}
|
|
className="flex w-full items-center gap-3 rounded-xl border border-white/[0.08] bg-white/[0.04] px-3 py-3 text-left transition-colors hover:bg-white/[0.08] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] disabled:opacity-50"
|
|
>
|
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-[#4BA0FA]/10 text-[#4BA0FA]">
|
|
<Building2 className="size-4" />
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate text-[14px] font-medium text-[#FAFAFA]">
|
|
{organization.name}
|
|
</span>
|
|
{submitting ? (
|
|
<Loader2 className="size-4 shrink-0 animate-spin text-[#8A94A6]" />
|
|
) : (
|
|
<ChevronRight className="size-4 shrink-0 text-[#8A94A6]" />
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
disabled={submitting}
|
|
onClick={onBack}
|
|
className="mt-4 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#FAFAFA] disabled:opacity-50"
|
|
>
|
|
Use a different domain
|
|
</button>
|
|
</>
|
|
)
|
|
}
|
|
|
|
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,
|
|
retrying,
|
|
exhausted,
|
|
onContinue,
|
|
}: {
|
|
domain: string
|
|
done: boolean
|
|
retrying: boolean
|
|
exhausted: boolean
|
|
onContinue: () => void
|
|
}) {
|
|
// Same key as the rail/header queries so a connect in another tab is picked up on refocus.
|
|
const { data: slack } = useQuery({
|
|
queryKey: ["brain-slack-status"],
|
|
queryFn: async (): Promise<{ connected: boolean }> => {
|
|
const res = await fetch(`${BACKEND}/brain/slack/status`, {
|
|
credentials: "include",
|
|
})
|
|
if (!res.ok) return { connected: false }
|
|
return (await res.json()) as { connected: boolean }
|
|
},
|
|
staleTime: 30_000,
|
|
})
|
|
const slackConnected = slack?.connected ?? false
|
|
const brandName = workspaceNameFromDomain(domain) || domain
|
|
const showSpinner = !done && !exhausted
|
|
const statusLabel = done
|
|
? "Company Brain ready"
|
|
: exhausted
|
|
? "Couldn't finish — you can continue"
|
|
: retrying
|
|
? "Retrying research…"
|
|
: "Building your Company Brain…"
|
|
const statusColor = done
|
|
? "text-[#5CD68A]"
|
|
: exhausted
|
|
? "text-[#E5A45A]"
|
|
: "text-[#737373]"
|
|
return (
|
|
<div className="flex min-w-0 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>
|
|
<div className="flex min-w-0 flex-1 flex-col md:flex-row md:items-center md:gap-3">
|
|
<span
|
|
title={brandName}
|
|
className="min-w-0 truncate text-[14px] font-semibold text-[#fafafa] md:flex-1"
|
|
>
|
|
{brandName}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[12px] font-medium",
|
|
statusColor,
|
|
)}
|
|
>
|
|
{showSpinner && (
|
|
<Loader2 className="size-3 animate-spin text-[#4BA0FA]" />
|
|
)}
|
|
{statusLabel}
|
|
</span>
|
|
{slackConnected && (
|
|
<span className="hidden shrink-0 items-center gap-1.5 whitespace-nowrap text-[12px] font-medium text-[#5CD68A] sm:flex">
|
|
<Check className="size-3" />
|
|
Slack connected · free trial started
|
|
</span>
|
|
)}
|
|
</div>
|
|
{/* Never gated on research; the admin can move on while it keeps working. */}
|
|
<Button
|
|
type="button"
|
|
onClick={onContinue}
|
|
className={cn(
|
|
"ml-auto shrink-0 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>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ResearchTranscript() {
|
|
const { status, events } = useResearchStatus()
|
|
const running = status !== "done" && status !== "error"
|
|
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%)",
|
|
}}
|
|
/>
|
|
</>
|
|
)
|
|
}
|