feat(web): make brain onboarding the default flow (#1067)

feat(web): make brain onboarding the default flow

- Replace legacy /onboarding with the brain onboarding flow (about -> sources -> ingest -> team -> done)
- Add brain-home dashboard page and widgets
- Old /onboarding/brain deep links fall through to the existing catch-all redirect

feat(onboarding): create org on About step + remove skip

- Create the organization on About -> Continue (authClient.organization.create + setActiveOrg), persisting brain context to org metadata; fixes the no-org redirect loop
- Show a Creating… state on Continue while the org is created
- Remove Skip on the required About step (name + workspace)
- Add generateOrgSlug/generateUsername helpers to onboarding-brain/types
This commit is contained in:
MaheshtheDev 2026-06-09 17:13:32 +00:00
parent 7c2bd19cd8
commit 170133cf83
14 changed files with 4587 additions and 1326 deletions

View file

@ -0,0 +1,992 @@
"use client"
import { Suspense } from "react"
import { useSearchParams } from "next/navigation"
import { GoogleDrive, Notion } from "@ui/assets/icons"
import { cn } from "@lib/utils"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { ArrowRight, Mail, MessageSquare, Plus, Sparkles } from "lucide-react"
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
import { Header } from "@/components/header"
import { HomeChatComposer } from "@/components/chat/home-chat-composer"
import {
RecommendedRail,
SourcesHealthRail,
} from "@/components/brain-home/widgets"
const modalCardStyle = {
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 = {
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)",
}
type Space = {
id: string
name: string
icon: React.ReactNode
itemCount: number
lastSync: string
}
const _SPACES: Space[] = [
{
id: "drive",
name: "My Drive",
icon: <GoogleDrive className="size-4" />,
itemCount: 287,
lastSync: "2h ago",
},
{
id: "notion",
name: "Company Notion",
icon: <Notion className="size-4" />,
itemCount: 142,
lastSync: "12m ago",
},
{
id: "gmail",
name: "Gmail",
icon: <Mail className="size-4 text-[#EA4335]" />,
itemCount: 1843,
lastSync: "Just now",
},
]
type Stat = {
label: string
value: string
delta?: string
deltaTint?: "up" | "down" | "neutral"
}
const STATS: Stat[] = [
{
label: "Items ingested",
value: "2,272",
delta: "+148 this week",
deltaTint: "up",
},
{
label: "Agent sessions",
value: "184",
delta: "+42 vs last wk",
deltaTint: "up",
},
{
label: "Active contributors",
value: "4",
delta: "of 6 invited",
deltaTint: "neutral",
},
{
label: "Top space",
value: "Gmail",
delta: "1,843 items",
deltaTint: "neutral",
},
]
type AgentSeries = {
key: string
label: string
color: string
}
const AGENT_SERIES: AgentSeries[] = [
{ key: "cursor", label: "Cursor", color: "#4BA0FA" },
{ key: "claudeCode", label: "Claude Code", color: "#FF8A47" },
{ key: "claudeDesktop", label: "Claude Desktop", color: "#B19CFF" },
{ key: "chatgpt", label: "ChatGPT", color: "#10A37F" },
{ key: "other", label: "Other", color: "#525D6E" },
]
const SESSIONS_BY_DAY: {
label: string
values: Record<string, number>
}[] = [
{
label: "Mon",
values: {
cursor: 8,
claudeCode: 4,
claudeDesktop: 3,
chatgpt: 2,
other: 1,
},
},
{
label: "Tue",
values: {
cursor: 10,
claudeCode: 6,
claudeDesktop: 4,
chatgpt: 3,
other: 1,
},
},
{
label: "Wed",
values: {
cursor: 12,
claudeCode: 9,
claudeDesktop: 5,
chatgpt: 4,
other: 1,
},
},
{
label: "Thu",
values: {
cursor: 9,
claudeCode: 5,
claudeDesktop: 4,
chatgpt: 3,
other: 1,
},
},
{
label: "Fri",
values: {
cursor: 14,
claudeCode: 10,
claudeDesktop: 6,
chatgpt: 4,
other: 2,
},
},
{
label: "Sat",
values: {
cursor: 5,
claudeCode: 3,
claudeDesktop: 2,
chatgpt: 1,
other: 1,
},
},
{
label: "Sun",
values: {
cursor: 17,
claudeCode: 12,
claudeDesktop: 7,
chatgpt: 4,
other: 1,
},
},
]
const RECENT_ANSWERS = [
{
question: "What did we decide about pricing?",
answer: "Pro stays at $20/mo. No overages, top-up only.",
who: "You",
when: "2h ago",
},
{
question: "What's blocking the Acme deal?",
answer: "MSA review — Jane drafting a net-30 counter.",
who: "You",
when: "4h ago",
},
{
question: "Who owns the onboarding rewrite?",
answer: "Design team. Ship target Friday.",
who: "Sarah Kim",
when: "Yesterday",
},
]
const WEEKLY_DIGEST = {
range: "Mon Fri",
headline:
"Your brain captured 642 items, surfaced 12 decisions, and answered 184 questions across the team.",
bullets: [
{
label: "5 deadlines coming up next week",
tint: "text-[#4BA0FA]",
},
{
label: "2 conflicts surfaced between Notion and Slack",
tint: "text-[#FF8A47]",
},
{
label: "Sarah Kim led contributions with 14 docs",
tint: "text-[#A1A1AA]",
},
],
}
const TEAM_THIS_WEEK = [
{
name: "Sarah Kim",
email: "sarah@acme.com",
contributions: 14,
summary: "14 docs · 3 spaces",
color: "#FF8A47",
},
{
name: "Jane Doe",
email: "jane@acme.com",
contributions: 11,
summary: "11 docs · 2 spaces",
color: "#4BA0FA",
},
{
name: "Mahesh S.",
email: "mahesh@acme.com",
contributions: 9,
summary: "9 docs · 1 space",
color: "#A1A1AA",
},
{
name: "Alex Chen",
email: "alex@acme.com",
contributions: 7,
summary: "7 docs · 2 spaces",
color: "#10A37F",
},
]
const TEAM_RECENT = [
{ who: "Sarah Kim", what: "added a memo to acme-deal-q2", when: "14m" },
{ who: "Jane Doe", what: "updated the Acme MSA draft", when: "1h" },
{ who: "You", what: 'asked "What did we decide about pricing?"', when: "2h" },
{
who: "Sarah Kim",
what: "connected Notion to Sales space",
when: "Yesterday",
},
]
const ACTIVITY = [
{
when: "2m",
text: "23 new docs ingested from Drive",
source: "drive",
},
{
when: "14m",
text: "Jane added a memo to acme-deal-q2",
source: "notion",
},
{
when: "1h",
text: "Decision surfaced: pricing locked at $20 Pro",
source: "slack",
},
{
when: "3h",
text: "14 Notion pages updated by Sarah Kim",
source: "notion",
},
{
when: "5h",
text: "New entity formed: pricing-v3",
source: "system",
},
{
when: "Yesterday",
text: "MSA contract uploaded · auto-tagged contracts/MSA",
source: "drive",
},
]
const _RECENT_ITEMS: Record<string, { title: string; meta: string }[]> = {
drive: [
{ title: "Q2 Roadmap.docx", meta: "Edited 1h ago · Jane" },
{ title: "Acme · MSA draft v3.pdf", meta: "Added 2h ago" },
{ title: "Pricing model.xlsx", meta: "Edited 4h ago · Mahesh" },
{ title: "Customer feedback Q1.docx", meta: "Yesterday" },
{ title: "Brand assets / logos", meta: "2 days ago" },
],
notion: [
{ title: "Engineering / Weekly sync", meta: "Updated 12m ago" },
{ title: "Sales / Acme account", meta: "Updated 1h ago" },
{ title: "Brand / Voice & tone", meta: "Yesterday" },
{ title: "Onboarding rewrite spec", meta: "2 days ago" },
],
gmail: [
{ title: "Re: Acme contract review", meta: "Just now · jane@acme.com" },
{ title: "Customer feedback digest", meta: "10m ago" },
{ title: "Re: Pricing decision", meta: "1h ago" },
{ title: "Onboarding ship plan", meta: "3h ago" },
],
}
export default function BrainHomePage() {
return (
<Suspense fallback={null}>
<BrainHomeInner />
</Suspense>
)
}
function BrainHomeInner() {
const searchParams = useSearchParams()
const empty = searchParams?.get("empty") === "1"
return (
<div
className={cn(
"relative min-h-dvh bg-[#05080D] text-[#fafafa]",
dmSansClassName(),
)}
>
<div className="pointer-events-none fixed inset-0 z-0">
<AnimatedGradientBackground
animateFromBottom={false}
topPosition="20%"
/>
<div className="absolute inset-0 bg-[#05080D]/55" aria-hidden />
<div
id="brain-home-grid"
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.22)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_55%,transparent_100%)]"
/>
</div>
<Header onAddMemory={() => {}} onOpenSearch={() => {}} />
<main className="relative z-10 px-6 md:px-10 py-8 pb-[180px]">
<div className="max-w-[1280px] mx-auto space-y-6">
<StatsStrip stats={STATS} empty={empty} />
<div className="grid lg:grid-cols-[1fr_320px] gap-6">
<div className="space-y-8 min-w-0">
<BrainPulseBlock empty={empty} />
<BrainActivityChart empty={empty} />
<ActivityFeedBlock empty={empty} />
</div>
<aside className="lg:sticky lg:top-6 lg:self-start h-fit space-y-4">
<TeamActivityRail empty={empty} />
<SourcesHealthRail empty={empty} />
<RecommendedRail />
</aside>
</div>
</div>
</main>
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-20 h-56 bg-gradient-to-t from-[#05080D] via-[#05080D]/95 to-transparent" />
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-30">
<div className="pointer-events-auto">
<HomeChatComposer onStartChat={() => {}} />
</div>
</div>
</div>
)
}
function StatsStrip({ stats, empty }: { stats: Stat[]; empty?: boolean }) {
const EMPTY_STATS: Stat[] = [
{
label: "Items ingested",
value: "—",
delta: "Connect a source",
deltaTint: "neutral",
},
{
label: "Agent sessions",
value: "—",
delta: "Install an agent",
deltaTint: "neutral",
},
{
label: "Active contributors",
value: "1",
delta: "Just you so far",
deltaTint: "neutral",
},
{
label: "Top space",
value: "—",
delta: "Nothing connected",
deltaTint: "neutral",
},
]
const visible = empty ? EMPTY_STATS : stats
return (
<section
className="rounded-[16px] bg-[#1B1F24] grid grid-cols-2 md:grid-cols-4 divide-y md:divide-y-0 md:divide-x divide-white/[0.04]"
style={modalCardStyle}
>
{visible.map((s) => (
<div key={s.label} className="px-5 py-4">
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
{s.label}
</p>
<p
className={cn(
"text-[22px] font-semibold mt-1.5 tabular-nums leading-none",
empty && s.value === "—" ? "text-[#525D6E]" : "text-[#fafafa]",
dmSans125ClassName(),
)}
>
{s.value}
</p>
{s.delta && (
<p
className={cn(
"text-[11px] font-medium mt-1.5",
s.deltaTint === "up"
? "text-[#4BA0FA]"
: s.deltaTint === "down"
? "text-[#FF8A47]"
: "text-[#737373]",
)}
>
{s.delta}
</p>
)}
</div>
))}
</section>
)
}
function BrainActivityChart({ empty }: { empty?: boolean }) {
if (empty) {
return (
<section
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6 relative overflow-hidden"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px left-0 right-0 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
}}
/>
<p
className={cn(
"text-[16px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Sessions by agent
</p>
<p className="text-[12px] text-[#737373] font-medium mt-0.5">
0 sessions yet once you install Cursor, Claude Code, or any
MCP-capable agent, usage shows up here.
</p>
<div className="mt-6 flex items-end gap-2 opacity-30" aria-hidden>
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((d) => (
<div key={d} className="flex-1 flex flex-col items-center gap-2">
<div
className="w-full rounded-[4px] bg-[#1F2937]"
style={{ height: "4px" }}
/>
<span className="text-[10px] text-[#525D6E] font-medium">
{d}
</span>
</div>
))}
</div>
<button
type="button"
className="mt-5 text-[12px] text-[#4BA0FA] hover:text-[#7eb9fc] transition-colors font-medium inline-flex items-center gap-1.5"
>
Install an agent <ArrowRight className="size-3.5" />
</button>
</section>
)
}
const totalsByAgent = AGENT_SERIES.map((s) => ({
...s,
total: SESSIONS_BY_DAY.reduce((sum, d) => sum + (d.values[s.key] ?? 0), 0),
}))
const grandTotal = totalsByAgent.reduce((sum, s) => sum + s.total, 0)
const dayTotals = SESSIONS_BY_DAY.map((d) =>
AGENT_SERIES.reduce((sum, s) => sum + (d.values[s.key] ?? 0), 0),
)
const max = Math.max(...dayTotals)
return (
<section
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6 relative overflow-hidden"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px left-0 right-0 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
}}
/>
<div className="flex flex-wrap items-end justify-between gap-3 mb-5">
<div>
<p
className={cn(
"text-[16px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Sessions by agent
</p>
<p className="text-[12px] text-[#737373] font-medium mt-0.5">
{grandTotal} agent sessions, last 7 days which tool the team
reached for.
</p>
</div>
<div className="flex flex-wrap items-center gap-3 text-[11px] font-medium">
{totalsByAgent.map((s) => (
<span
key={s.key}
className="inline-flex items-center gap-1.5 text-[#A1A1AA]"
>
<span
aria-hidden
className="size-2 rounded-full"
style={{ background: s.color }}
/>
{s.label}{" "}
<span className="text-[#fafafa] tabular-nums">{s.total}</span>
</span>
))}
</div>
</div>
<div className="flex items-end gap-2">
{SESSIONS_BY_DAY.map((d, dayIdx) => {
const BAR_AREA_PX = 140
const totalForDay = dayTotals[dayIdx] ?? 0
const barHeightPx = Math.max(
6,
Math.round((totalForDay / max) * BAR_AREA_PX),
)
return (
<div
key={d.label}
className="flex-1 flex flex-col items-center gap-2"
>
<div
className="w-full flex items-end"
style={{ height: `${BAR_AREA_PX}px` }}
>
<div
className="w-full rounded-[4px] overflow-hidden flex flex-col"
style={{ height: `${barHeightPx}px` }}
title={`${d.label}: ${totalForDay} sessions`}
>
{AGENT_SERIES.map((series, sIdx) => {
const val = d.values[series.key] ?? 0
if (val === 0) return null
const segHeight =
totalForDay > 0 ? (val / totalForDay) * 100 : 0
return (
<div
key={series.key}
style={{
height: `${segHeight}%`,
background: series.color,
}}
className={cn(
sIdx === 0 && "rounded-t-[4px]",
sIdx === AGENT_SERIES.length - 1 && "rounded-b-[4px]",
)}
title={`${series.label} · ${val}`}
/>
)
})}
</div>
</div>
<span className="text-[10px] text-[#737373] font-medium tabular-nums">
{d.label}
</span>
</div>
)
})}
</div>
</section>
)
}
function _TabButton({
active,
onClick,
icon,
label,
count,
}: {
active: boolean
onClick: () => void
icon: React.ReactNode
label: string
count: number
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex h-8 shrink-0 items-center gap-2 rounded-full px-3 text-[12px] font-medium transition-colors",
active
? "bg-white/[0.10] text-[#fafafa]"
: "text-[#A1A1AA] hover:text-[#fafafa] hover:bg-white/[0.04]",
)}
>
{icon}
<span className="leading-none">{label}</span>
<span
className={cn(
"text-[10px] font-semibold tabular-nums leading-none",
active ? "text-[#A1A1AA]" : "text-[#525D6E]",
)}
>
{count.toLocaleString()}
</span>
</button>
)
}
function SectionHeader({ title, cta }: { title: string; cta?: string }) {
return (
<div className="flex items-end justify-between mb-3 px-1">
<p className="text-[11px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
{title}
</p>
{cta && (
<button
type="button"
className="text-[12px] text-[#737373] hover:text-[#fafafa] transition-colors font-medium"
>
{cta}
</button>
)}
</div>
)
}
function BrainPulseBlock({ empty }: { empty?: boolean }) {
return (
<section className="grid lg:grid-cols-[2fr_3fr] gap-3 items-stretch">
<RecentAnswersCard empty={empty} />
<WeeklyDigestCard empty={empty} />
</section>
)
}
function RecentAnswersCard({ empty }: { empty?: boolean }) {
return (
<div
className="rounded-[14px] bg-[#1B1F24] p-4 flex flex-col"
style={modalCardStyle}
>
<div className="flex items-center justify-between mb-3">
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
Recent answers
</p>
{!empty && (
<button
type="button"
className="text-[10px] text-[#737373] hover:text-[#fafafa] transition-colors font-medium"
>
See all
</button>
)}
</div>
{empty ? (
<div className="flex-1 flex flex-col items-start justify-center py-4 gap-1.5">
<p className="text-[13px] text-[#fafafa] font-medium">
No questions asked yet.
</p>
<p className="text-[11px] text-[#737373] font-medium leading-[1.5]">
Ask the brain something below answers and the team's history will
show up here.
</p>
</div>
) : (
<ul className="space-y-2.5">
{RECENT_ANSWERS.map((a) => (
<li
key={a.question}
className="border-l border-[#2261CA33] pl-2.5 hover:border-[#4BA0FA] transition-colors"
>
<p className="text-[12px] text-[#fafafa] font-semibold leading-snug truncate">
{a.question}
</p>
<p className="text-[11px] text-[#A1A1AA] leading-[1.4] font-medium mt-0.5 line-clamp-1">
{a.answer}
</p>
<p className="text-[10px] text-[#737373] font-medium mt-1">
{a.who} · {a.when}
</p>
</li>
))}
</ul>
)}
</div>
)
}
function WeeklyDigestCard({ empty }: { empty?: boolean }) {
if (empty) {
return (
<div
className="rounded-[14px] bg-[#1B1F24] p-4 md:p-5 flex flex-col relative overflow-hidden"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px left-0 right-0 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
}}
/>
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold mb-3">
This week
</p>
<p
className={cn(
"text-[14px] font-semibold text-[#fafafa] leading-snug",
dmSans125ClassName(),
)}
>
Your brain is just getting started.
</p>
<p className="text-[12px] text-[#737373] font-medium leading-[1.5] mt-2">
Once sources are connected and the team starts asking questions,
you'll see decisions, deadlines, and contributor highlights here.
</p>
<button
type="button"
className="mt-auto pt-4 self-start text-[11px] text-[#4BA0FA] hover:text-[#7eb9fc] transition-colors font-medium inline-flex items-center gap-1.5"
>
Connect a source <ArrowRight className="size-3" />
</button>
</div>
)
}
return (
<div
className="rounded-[14px] bg-[#1B1F24] p-4 md:p-5 flex flex-col relative overflow-hidden"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px left-0 right-0 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
}}
/>
<div className="flex items-end justify-between mb-3">
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
This week
</p>
<p className="text-[10px] uppercase tracking-[0.12em] text-[#525D6E] font-semibold">
{WEEKLY_DIGEST.range}
</p>
</div>
<p
className={cn(
"text-[14px] font-semibold text-[#fafafa] leading-snug",
dmSans125ClassName(),
)}
>
{WEEKLY_DIGEST.headline.split(/(\d+)/).map((piece, i) =>
/^\d+$/.test(piece) ? (
<span key={i} className="text-[#4BA0FA]">
{piece}
</span>
) : (
<span key={i}>{piece}</span>
),
)}
</p>
<ul className="mt-3 space-y-1.5">
{WEEKLY_DIGEST.bullets.map((b) => (
<li key={b.label} className="flex items-start gap-2">
<span
aria-hidden
className={cn("mt-1.5 size-1 rounded-full bg-current", b.tint)}
/>
<span className={cn("text-[12px] font-medium", b.tint)}>
{b.label}
</span>
</li>
))}
</ul>
<button
type="button"
className="mt-auto pt-3 self-start text-[11px] text-[#A1A1AA] hover:text-[#fafafa] transition-colors font-medium inline-flex items-center gap-1.5"
>
Open weekly digest <ArrowRight className="size-3" />
</button>
</div>
)
}
function ActivityFeedBlock({ empty }: { empty?: boolean }) {
if (empty) {
return (
<section>
<SectionHeader title="Activity feed" />
<div
className="rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-4 py-6 flex items-start gap-3"
style={inputBevelStyle}
>
<div className="size-7 rounded-[8px] bg-[#0F1217] flex items-center justify-center shrink-0">
<Sparkles className="size-3.5 text-[#525D6E]" />
</div>
<div className="min-w-0">
<p className="text-[13px] text-[#fafafa] font-medium">
Nothing happening yet.
</p>
<p className="text-[12px] text-[#737373] font-medium mt-1 leading-[1.5]">
As sources sync and the team starts using the brain, every event
will show up here in real time.
</p>
</div>
</div>
</section>
)
}
const sourceIcon = (s: string) => {
if (s === "drive") return <GoogleDrive className="size-3.5" />
if (s === "notion") return <Notion className="size-3.5" />
if (s === "slack")
return <MessageSquare className="size-3.5 text-[#4BA0FA]" />
return <Sparkles className="size-3.5 text-[#8B8B8B]" />
}
return (
<section>
<SectionHeader title="Activity feed" cta="Show more →" />
<div
className="rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] divide-y divide-white/[0.04]"
style={inputBevelStyle}
>
{ACTIVITY.map((a, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<div className="size-7 rounded-[8px] bg-[#0F1217] flex items-center justify-center shrink-0">
{sourceIcon(a.source)}
</div>
<p className="flex-1 text-[13px] text-[#fafafa] font-medium">
{a.text}
</p>
<span className="text-[11px] text-[#737373] font-medium tabular-nums shrink-0">
{a.when}
</span>
</div>
))}
</div>
</section>
)
}
function TeamActivityRail({ empty }: { empty?: boolean }) {
if (empty) {
return (
<section
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6"
style={modalCardStyle}
>
<p
className={cn(
"text-[16px] font-semibold leading-tight",
dmSans125ClassName(),
)}
>
Just you so far
</p>
<p className="text-[11px] text-[#737373] font-medium mt-0.5 leading-[1.5]">
A brain gets sharper as more people contribute. Invite teammates to
see who's adding what.
</p>
<button
type="button"
className="mt-5 w-full inline-flex items-center justify-center gap-1.5 h-9 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] text-[12px] font-medium text-[#fafafa] hover:bg-[#14161A] transition-colors"
>
<Plus className="size-3.5" />
Invite teammates
</button>
</section>
)
}
return (
<section
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6"
style={modalCardStyle}
>
<div className="flex items-center justify-between mb-1">
<p
className={cn(
"text-[16px] font-semibold leading-tight",
dmSans125ClassName(),
)}
>
Team activity
</p>
<span className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
This week
</span>
</div>
<p className="text-[11px] text-[#737373] font-medium">
What your team's been adding to the brain.
</p>
<div className="mt-5 space-y-2.5">
{TEAM_THIS_WEEK.map((member) => (
<div
key={member.email}
className="flex items-center gap-3 rounded-[10px] px-2 py-1.5 hover:bg-white/[0.03] transition-colors"
>
<div
className="size-8 rounded-full flex items-center justify-center text-[11px] font-semibold text-[#0F1217] shrink-0"
style={{ background: member.color }}
>
{member.name[0]}
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] text-[#fafafa] font-medium truncate">
{member.name}
</p>
<p className="text-[11px] text-[#737373] font-medium truncate">
{member.summary}
</p>
</div>
<span className="text-[12px] text-[#A1A1AA] tabular-nums font-semibold shrink-0">
{member.contributions}
</span>
</div>
))}
</div>
<div className="mt-5 pt-4 border-t border-white/[0.04]">
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold mb-2.5">
Recent
</p>
<ul className="space-y-2">
{TEAM_RECENT.map((event, i) => (
<li
key={i}
className="text-[12px] text-[#A1A1AA] leading-[1.5] font-medium"
>
<span className="text-[#fafafa]">{event.who}</span> {event.what}
<span className="text-[#525D6E]"> · {event.when}</span>
</li>
))}
</ul>
</div>
<button
type="button"
className="mt-5 w-full inline-flex items-center justify-center gap-1.5 h-9 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] text-[12px] font-medium text-[#fafafa] hover:bg-[#14161A] transition-colors"
>
<Plus className="size-3.5" />
Invite teammates
</button>
</section>
)
}

File diff suppressed because it is too large Load diff

View file

@ -25,6 +25,7 @@ import { ShortcutsDetail } from "@/components/integrations/shortcuts-detail"
import { RaycastDetail } from "@/components/integrations/raycast-detail"
import { PluginsDetail } from "@/components/integrations/plugins-detail"
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
import { OnboardingConfetti } from "@/components/onboarding-brain/onboarding-confetti"
import { AddDocumentModal } from "@/components/add-document"
import { DocumentModal } from "@/components/document-modal"
import { DocumentsCommandPalette } from "@/components/documents-command-palette"
@ -584,6 +585,7 @@ export default function NewPage() {
return (
<HotkeysProvider>
<OnboardingConfetti />
<div
className={cn(
"relative flex min-h-dvh flex-col bg-[#05080D]",

View file

@ -0,0 +1,466 @@
export type Stat = {
label: string
value: string
delta?: string
deltaTint?: "up" | "down" | "neutral"
}
export const STATS: Stat[] = [
{
label: "Items ingested",
value: "2,272",
delta: "+148 this week",
deltaTint: "up",
},
{
label: "Agent sessions",
value: "184",
delta: "+42 vs last wk",
deltaTint: "up",
},
{
label: "Active contributors",
value: "4",
delta: "of 6 invited",
deltaTint: "neutral",
},
{
label: "Top space",
value: "Gmail",
delta: "1,843 items",
deltaTint: "neutral",
},
]
export type AgentSeries = { key: string; label: string; color: string }
export const AGENT_SERIES: AgentSeries[] = [
{ key: "cursor", label: "Cursor", color: "#4BA0FA" },
{ key: "claudeCode", label: "Claude Code", color: "#FF8A47" },
{ key: "claudeDesktop", label: "Claude Desktop", color: "#B19CFF" },
{ key: "chatgpt", label: "ChatGPT", color: "#10A37F" },
{ key: "other", label: "Other", color: "#525D6E" },
]
export const SESSIONS_BY_DAY: {
label: string
values: Record<string, number>
}[] = [
{
label: "Mon",
values: {
cursor: 8,
claudeCode: 4,
claudeDesktop: 3,
chatgpt: 2,
other: 1,
},
},
{
label: "Tue",
values: {
cursor: 10,
claudeCode: 6,
claudeDesktop: 4,
chatgpt: 3,
other: 1,
},
},
{
label: "Wed",
values: {
cursor: 12,
claudeCode: 9,
claudeDesktop: 5,
chatgpt: 4,
other: 1,
},
},
{
label: "Thu",
values: {
cursor: 9,
claudeCode: 5,
claudeDesktop: 4,
chatgpt: 3,
other: 1,
},
},
{
label: "Fri",
values: {
cursor: 14,
claudeCode: 10,
claudeDesktop: 6,
chatgpt: 4,
other: 2,
},
},
{
label: "Sat",
values: {
cursor: 5,
claudeCode: 3,
claudeDesktop: 2,
chatgpt: 1,
other: 1,
},
},
{
label: "Sun",
values: {
cursor: 17,
claudeCode: 12,
claudeDesktop: 7,
chatgpt: 4,
other: 1,
},
},
]
export const RECENT_ANSWERS = [
{
question: "What did we decide about pricing?",
answer: "Pro stays at $20/mo. No overages, top-up only.",
who: "You",
when: "2h ago",
},
{
question: "What's blocking the Acme deal?",
answer: "MSA review — Jane drafting a net-30 counter.",
who: "You",
when: "4h ago",
},
{
question: "Who owns the onboarding rewrite?",
answer: "Design team. Ship target Friday.",
who: "Sarah Kim",
when: "Yesterday",
},
]
export const WEEKLY_DIGEST = {
range: "Mon Fri",
headline:
"Your brain captured 642 items, surfaced 12 decisions, and answered 184 questions across the team.",
bullets: [
{ label: "5 deadlines coming up next week", tint: "text-[#4BA0FA]" },
{
label: "2 conflicts surfaced between Notion and Slack",
tint: "text-[#FF8A47]",
},
{
label: "Sarah Kim led contributions with 14 docs",
tint: "text-[#A1A1AA]",
},
],
}
export const TEAM_THIS_WEEK = [
{
name: "Sarah Kim",
email: "sarah@acme.com",
contributions: 14,
summary: "14 docs · 3 spaces",
color: "#FF8A47",
},
{
name: "Jane Doe",
email: "jane@acme.com",
contributions: 11,
summary: "11 docs · 2 spaces",
color: "#4BA0FA",
},
{
name: "Mahesh S.",
email: "mahesh@acme.com",
contributions: 9,
summary: "9 docs · 1 space",
color: "#A1A1AA",
},
{
name: "Alex Chen",
email: "alex@acme.com",
contributions: 7,
summary: "7 docs · 2 spaces",
color: "#10A37F",
},
]
export const TEAM_RECENT = [
{ who: "Sarah Kim", what: "added a memo to acme-deal-q2", when: "14m" },
{ who: "Jane Doe", what: "updated the Acme MSA draft", when: "1h" },
{ who: "You", what: 'asked "What did we decide about pricing?"', when: "2h" },
{
who: "Sarah Kim",
what: "connected Notion to Sales space",
when: "Yesterday",
},
]
export type ActivityEvent = {
when: string
text: string
source: "drive" | "notion" | "slack" | "system" | "answer" | "decision"
}
export const ACTIVITY: ActivityEvent[] = [
{ when: "2m", text: "23 new docs ingested from Drive", source: "drive" },
{ when: "14m", text: "Jane added a memo to acme-deal-q2", source: "notion" },
{
when: "1h",
text: "Decision surfaced: pricing locked at $20 Pro",
source: "slack",
},
{
when: "3h",
text: "14 Notion pages updated by Sarah Kim",
source: "notion",
},
{ when: "5h", text: "New entity formed: pricing-v3", source: "system" },
{
when: "Yesterday",
text: "MSA contract uploaded · auto-tagged contracts/MSA",
source: "drive",
},
]
export type TimelineKind =
| "answer"
| "decision"
| "ingest"
| "mention"
| "system"
export type TimelineEvent = {
kind: TimelineKind
when: string
title: string
detail?: string
source?: string
who?: string
}
export const TIMELINE_EVENTS: TimelineEvent[] = [
{
kind: "answer",
when: "2m ago",
title: "What did we decide about pricing?",
detail: "Pro stays at $20/mo. No overages, top-up only.",
source: "You asked",
},
{
kind: "ingest",
when: "14m ago",
title: "23 new docs ingested from Drive",
detail: "Q2 roadmap, Acme MSA draft v3, customer feedback Q1, +20 more.",
source: "Drive",
},
{
kind: "decision",
when: "1h ago",
title: "Decision: pricing locked at $20 Pro",
detail:
"Decided in #leadership thread to keep Pro at $20/mo. No overages — top-up only.",
source: "Slack · #leadership",
},
{
kind: "answer",
when: "4h ago",
title: "What's blocking the Acme deal?",
detail: "MSA review — Jane drafting a net-30 counter.",
source: "You asked",
},
{
kind: "mention",
when: "Today",
title: "onboarding-rewrite mentioned in 3 docs",
detail: "Owner: design team · Ship target: Friday.",
source: "Drive",
who: "Mahesh",
},
{
kind: "system",
when: "5h ago",
title: "New entity formed: pricing-v3",
detail: "Linked across 4 sources · auto-tagged by the brain.",
},
{
kind: "ingest",
when: "Yesterday",
title: "MSA contract uploaded · auto-tagged contracts/MSA",
source: "Drive",
},
{
kind: "answer",
when: "Yesterday",
title: "Who owns the onboarding rewrite?",
detail: "Design team. Ship target Friday.",
source: "Sarah Kim asked",
},
{
kind: "ingest",
when: "Yesterday",
title: "14 Notion pages updated by Sarah Kim",
source: "Notion",
who: "Sarah Kim",
},
]
export type SourceStatus = "healthy" | "syncing" | "warning" | "error"
export type ConnectedSource = {
id: string
name: string
iconKey: "drive" | "notion" | "gmail" | "onedrive" | "github"
status: SourceStatus
delta: string
when: string
statusMessage?: string
}
export const CONNECTED_SOURCES: ConnectedSource[] = [
{
id: "drive",
name: "Google Drive",
iconKey: "drive",
status: "healthy",
delta: "+24 items",
when: "12m ago",
},
{
id: "notion",
name: "Company Notion",
iconKey: "notion",
status: "warning",
delta: "Sync stalled",
when: "4h ago",
statusMessage: "Re-auth · token expired",
},
{
id: "gmail",
name: "Gmail",
iconKey: "gmail",
status: "syncing",
delta: "Ingesting…",
when: "Just now",
},
]
export type RecommendedSource = {
id: string
name: string
iconKey: "slack" | "linear" | "granola" | "github" | "calendar"
reason: string
}
export const RECOMMENDED_SOURCES: RecommendedSource[] = [
{
id: "slack",
name: "Slack",
iconKey: "slack",
reason: "Decisions and threads — most teams connect this.",
},
{
id: "github",
name: "GitHub",
iconKey: "github",
reason: "PRs, issues, READMEs from your repos.",
},
{
id: "linear",
name: "Linear",
iconKey: "linear",
reason: "Track project decisions and blockers.",
},
]
export type LiveSession = {
name: string
avatarColor: string
status: "live" | "idle" | "away"
tool: string
toolColor: string
doing?: string
elapsed?: string
}
export const LIVE_SESSIONS: LiveSession[] = [
{
name: "Sarah Kim",
avatarColor: "#FF8A47",
status: "live",
tool: "Cursor",
toolColor: "#4BA0FA",
doing: "Refactoring auth flow with brain context",
elapsed: "3m elapsed",
},
{
name: "Jane Doe",
avatarColor: "#4BA0FA",
status: "live",
tool: "Claude Code",
toolColor: "#FF8A47",
doing: "MSA review · pulling Acme history",
elapsed: "18m elapsed",
},
{
name: "You",
avatarColor: "#A1A1AA",
status: "idle",
tool: "Cursor",
toolColor: "#4BA0FA",
elapsed: "Idle · 12m",
},
]
export const TODAY_SESSIONS_SUMMARY: {
name: string
avatarColor: string
tool: string
count: number
lastAt: string
}[] = [
{
name: "Alex Chen",
avatarColor: "#10A37F",
tool: "ChatGPT",
count: 4,
lastAt: "2h ago",
},
{
name: "Mahesh S.",
avatarColor: "#A1A1AA",
tool: "Claude Desktop",
count: 7,
lastAt: "3h ago",
},
{
name: "Sarah Kim",
avatarColor: "#FF8A47",
tool: "Cursor",
count: 11,
lastAt: "Now",
},
]
export type ChatMessage = { role: "user" | "brain"; text: string }
export const SAMPLE_CHAT: ChatMessage[] = [
{
role: "user",
text: "What did we decide about pricing this quarter?",
},
{
role: "brain",
text: "Pro is locked at $20/mo. No overages — top-up only. Decided in the #leadership Slack thread on May 28.",
},
{
role: "user",
text: "Who's working on the Acme MSA?",
},
{
role: "brain",
text: "Jane Doe is drafting a net-30 counter to Acme's net-60 ask. Latest draft is in Drive (Acme · MSA draft v3.pdf).",
},
]

View file

@ -0,0 +1,671 @@
"use client"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import { GoogleDrive, Notion } from "@ui/assets/icons"
import {
AlertCircle,
ArrowRight,
Calendar,
CheckCircle2,
FileText,
Github,
Loader2,
MessageSquare,
Plus,
Quote,
Sparkles,
Target,
} from "lucide-react"
import {
ACTIVITY,
AGENT_SERIES,
CONNECTED_SOURCES,
RECENT_ANSWERS,
RECOMMENDED_SOURCES,
SESSIONS_BY_DAY,
STATS,
TEAM_RECENT,
TEAM_THIS_WEEK,
WEEKLY_DIGEST,
type ActivityEvent,
type ConnectedSource,
type RecommendedSource,
type Stat,
} from "./data"
function GmailIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 256 193"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M58.182 192.05V93.14L27.507 65.077 0 49.504v125.091c0 9.658 7.825 17.455 17.455 17.455z"
fill="#4285F4"
/>
<path
d="M197.818 192.05h40.727c9.659 0 17.455-7.826 17.455-17.455V49.505l-31.156 17.837-27.026 25.798z"
fill="#34A853"
/>
<path
d="m58.182 93.14-4.174-38.647 4.174-36.989L128 69.868l69.818-52.364 4.668 33.95-4.668 41.685L128 145.504z"
fill="#EA4335"
/>
<path
d="M197.818 17.504V93.14L256 49.504V26.231c0-21.585-24.64-33.89-41.89-20.945z"
fill="#FBBC04"
/>
<path
d="m0 49.504 26.759 20.07L58.182 93.14V17.504L41.89 5.286C24.61-7.66 0 4.646 0 26.23z"
fill="#C5221F"
/>
</svg>
)
}
export const modalCardStyle = {
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",
}
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)",
}
export function StatsStrip({ stats = STATS }: { stats?: Stat[] }) {
return (
<section
className="rounded-[16px] bg-[#1B1F24] grid grid-cols-2 md:grid-cols-4 divide-y md:divide-y-0 md:divide-x divide-white/[0.04]"
style={modalCardStyle}
>
{stats.map((s) => (
<div key={s.label} className="px-5 py-4">
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
{s.label}
</p>
<p
className={cn(
"text-[22px] font-semibold text-[#fafafa] mt-1.5 tabular-nums leading-none",
dmSans125ClassName(),
)}
>
{s.value}
</p>
{s.delta && (
<p
className={cn(
"text-[11px] font-medium mt-1.5",
s.deltaTint === "up"
? "text-[#4BA0FA]"
: s.deltaTint === "down"
? "text-[#FF8A47]"
: "text-[#737373]",
)}
>
{s.delta}
</p>
)}
</div>
))}
</section>
)
}
export function SectionHeader({ title, cta }: { title: string; cta?: string }) {
return (
<div className="flex items-end justify-between mb-3 px-1">
<p className="text-[11px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
{title}
</p>
{cta && (
<button
type="button"
className="text-[12px] text-[#737373] hover:text-[#fafafa] transition-colors font-medium"
>
{cta}
</button>
)}
</div>
)
}
export function RecentAnswersCard() {
return (
<div
className="rounded-[14px] bg-[#1B1F24] p-4 flex flex-col"
style={modalCardStyle}
>
<div className="flex items-center justify-between mb-3">
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
Recent answers
</p>
<button
type="button"
className="text-[10px] text-[#737373] hover:text-[#fafafa] transition-colors font-medium"
>
See all
</button>
</div>
<ul className="space-y-2.5">
{RECENT_ANSWERS.map((a) => (
<li
key={a.question}
className="border-l border-[#2261CA33] pl-2.5 hover:border-[#4BA0FA] transition-colors"
>
<p className="text-[12px] text-[#fafafa] font-semibold leading-snug truncate">
{a.question}
</p>
<p className="text-[11px] text-[#A1A1AA] leading-[1.4] font-medium mt-0.5 line-clamp-1">
{a.answer}
</p>
<p className="text-[10px] text-[#737373] font-medium mt-1">
{a.who} · {a.when}
</p>
</li>
))}
</ul>
</div>
)
}
export function WeeklyDigestCard() {
return (
<div
className="rounded-[14px] bg-[#1B1F24] p-4 md:p-5 flex flex-col relative overflow-hidden"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px left-0 right-0 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
}}
/>
<div className="flex items-end justify-between mb-3">
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
This week
</p>
<p className="text-[10px] uppercase tracking-[0.12em] text-[#525D6E] font-semibold">
{WEEKLY_DIGEST.range}
</p>
</div>
<p
className={cn(
"text-[14px] font-semibold text-[#fafafa] leading-snug",
dmSans125ClassName(),
)}
>
{WEEKLY_DIGEST.headline.split(/(\d+)/).map((piece, i) =>
/^\d+$/.test(piece) ? (
<span key={i} className="text-[#4BA0FA]">
{piece}
</span>
) : (
<span key={i}>{piece}</span>
),
)}
</p>
<ul className="mt-3 space-y-1.5">
{WEEKLY_DIGEST.bullets.map((b) => (
<li key={b.label} className="flex items-start gap-2">
<span
aria-hidden
className={cn("mt-1.5 size-1 rounded-full bg-current", b.tint)}
/>
<span className={cn("text-[12px] font-medium", b.tint)}>
{b.label}
</span>
</li>
))}
</ul>
<button
type="button"
className="mt-auto pt-3 self-start text-[11px] text-[#A1A1AA] hover:text-[#fafafa] transition-colors font-medium inline-flex items-center gap-1.5"
>
Open weekly digest <ArrowRight className="size-3" />
</button>
</div>
)
}
export function BrainPulseBlock() {
return (
<section className="grid lg:grid-cols-[2fr_3fr] gap-3 items-stretch">
<RecentAnswersCard />
<WeeklyDigestCard />
</section>
)
}
export function BrainActivityChart() {
const totalsByAgent = AGENT_SERIES.map((s) => ({
...s,
total: SESSIONS_BY_DAY.reduce((sum, d) => sum + (d.values[s.key] ?? 0), 0),
}))
const grandTotal = totalsByAgent.reduce((sum, s) => sum + s.total, 0)
const dayTotals = SESSIONS_BY_DAY.map((d) =>
AGENT_SERIES.reduce((sum, s) => sum + (d.values[s.key] ?? 0), 0),
)
const max = Math.max(...dayTotals)
return (
<section
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6 relative overflow-hidden"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px left-0 right-0 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
}}
/>
<div className="flex flex-wrap items-end justify-between gap-3 mb-5">
<div>
<p
className={cn(
"text-[16px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Sessions by agent
</p>
<p className="text-[12px] text-[#737373] font-medium mt-0.5">
{grandTotal} agent sessions, last 7 days which tool the team
reached for.
</p>
</div>
<div className="flex flex-wrap items-center gap-3 text-[11px] font-medium">
{totalsByAgent.map((s) => (
<span
key={s.key}
className="inline-flex items-center gap-1.5 text-[#A1A1AA]"
>
<span
aria-hidden
className="size-2 rounded-full"
style={{ background: s.color }}
/>
{s.label}{" "}
<span className="text-[#fafafa] tabular-nums">{s.total}</span>
</span>
))}
</div>
</div>
<div className="flex items-end gap-2">
{SESSIONS_BY_DAY.map((d, dayIdx) => {
const BAR_AREA_PX = 140
const totalForDay = dayTotals[dayIdx] ?? 0
const barHeightPx = Math.max(
6,
Math.round((totalForDay / max) * BAR_AREA_PX),
)
return (
<div
key={d.label}
className="flex-1 flex flex-col items-center gap-2"
>
<div
className="w-full flex items-end"
style={{ height: `${BAR_AREA_PX}px` }}
>
<div
className="w-full rounded-[4px] overflow-hidden flex flex-col"
style={{ height: `${barHeightPx}px` }}
title={`${d.label}: ${totalForDay} sessions`}
>
{AGENT_SERIES.map((series, sIdx) => {
const val = d.values[series.key] ?? 0
if (val === 0) return null
const segHeight =
totalForDay > 0 ? (val / totalForDay) * 100 : 0
return (
<div
key={series.key}
style={{
height: `${segHeight}%`,
background: series.color,
}}
className={cn(
sIdx === 0 && "rounded-t-[4px]",
sIdx === AGENT_SERIES.length - 1 && "rounded-b-[4px]",
)}
title={`${series.label} · ${val}`}
/>
)
})}
</div>
</div>
<span className="text-[10px] text-[#737373] font-medium tabular-nums">
{d.label}
</span>
</div>
)
})}
</div>
</section>
)
}
function activitySourceIcon(s: ActivityEvent["source"]) {
if (s === "drive") return <GoogleDrive className="size-3.5" />
if (s === "notion") return <Notion className="size-3.5" />
if (s === "slack")
return <MessageSquare className="size-3.5 text-[#4BA0FA]" />
if (s === "answer") return <Sparkles className="size-3.5 text-[#4BA0FA]" />
if (s === "decision")
return <CheckCircle2 className="size-3.5 text-[#4BA0FA]" />
return <Sparkles className="size-3.5 text-[#8B8B8B]" />
}
export function ActivityFeedBlock() {
return (
<section>
<SectionHeader title="Activity feed" cta="Show more →" />
<div
className="rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] divide-y divide-white/[0.04]"
style={inputBevelStyle}
>
{ACTIVITY.map((a, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<div className="size-7 rounded-[8px] bg-[#0F1217] flex items-center justify-center shrink-0">
{activitySourceIcon(a.source)}
</div>
<p className="flex-1 text-[13px] text-[#fafafa] font-medium">
{a.text}
</p>
<span className="text-[11px] text-[#737373] font-medium tabular-nums shrink-0">
{a.when}
</span>
</div>
))}
</div>
</section>
)
}
export function TeamActivityRail() {
return (
<section
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6"
style={modalCardStyle}
>
<div className="flex items-center justify-between mb-1">
<p
className={cn(
"text-[16px] font-semibold leading-tight",
dmSans125ClassName(),
)}
>
Team activity
</p>
<span className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
This week
</span>
</div>
<p className="text-[11px] text-[#737373] font-medium">
What your team's been adding to the brain.
</p>
<div className="mt-5 space-y-2.5">
{TEAM_THIS_WEEK.map((member) => (
<div
key={member.email}
className="flex items-center gap-3 rounded-[10px] px-2 py-1.5 hover:bg-white/[0.03] transition-colors"
>
<div
className="size-8 rounded-full flex items-center justify-center text-[11px] font-semibold text-[#0F1217] shrink-0"
style={{ background: member.color }}
>
{member.name[0]}
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] text-[#fafafa] font-medium truncate">
{member.name}
</p>
<p className="text-[11px] text-[#737373] font-medium truncate">
{member.summary}
</p>
</div>
<span className="text-[12px] text-[#A1A1AA] tabular-nums font-semibold shrink-0">
{member.contributions}
</span>
</div>
))}
</div>
<div className="mt-5 pt-4 border-t border-white/[0.04]">
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold mb-2.5">
Recent
</p>
<ul className="space-y-2">
{TEAM_RECENT.map((event, i) => (
<li
key={i}
className="text-[12px] text-[#A1A1AA] leading-[1.5] font-medium"
>
<span className="text-[#fafafa]">{event.who}</span> {event.what}
<span className="text-[#525D6E]"> · {event.when}</span>
</li>
))}
</ul>
</div>
<button
type="button"
className="mt-5 w-full inline-flex items-center justify-center gap-1.5 h-9 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] text-[12px] font-medium text-[#fafafa] hover:bg-[#14161A] transition-colors"
>
<Plus className="size-3.5" />
Invite teammates
</button>
</section>
)
}
export function BrainHomeBackground() {
return (
<div className="pointer-events-none fixed inset-0 z-0">
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(ellipse 80% 60% at 50% 0%, rgba(75,160,250,0.08) 0%, rgba(34,97,202,0.04) 35%, transparent 70%)",
}}
/>
<div className="absolute inset-0 bg-[#05080D]/55" aria-hidden />
<div
id="brain-home-grid"
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.18)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_55%,transparent_100%)]"
/>
</div>
)
}
export function Quotation({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center gap-1 text-[#737373]">
<Quote className="size-3" />
{children}
</span>
)
}
function connectedSourceIcon(key: ConnectedSource["iconKey"]) {
if (key === "drive") return <GoogleDrive className="size-4" />
if (key === "notion") return <Notion className="size-4" />
if (key === "gmail") return <GmailIcon className="size-4" />
if (key === "github") return <Github className="size-4 text-[#fafafa]" />
return <FileText className="size-4 text-[#8B8B8B]" />
}
function sourceStatusMeta(status: ConnectedSource["status"]) {
if (status === "healthy")
return {
dot: "bg-[#4BA0FA]",
tint: "text-[#4BA0FA]",
icon: <CheckCircle2 className="size-3" />,
}
if (status === "syncing")
return {
dot: "bg-[#A1A1AA]",
tint: "text-[#A1A1AA]",
icon: <Loader2 className="size-3 animate-spin" />,
}
if (status === "warning")
return {
dot: "bg-[#FF8A47]",
tint: "text-[#FF8A47]",
icon: <AlertCircle className="size-3" />,
}
return {
dot: "bg-[#FF5C5C]",
tint: "text-[#FF5C5C]",
icon: <AlertCircle className="size-3" />,
}
}
export function SourcesHealthRail({ empty }: { empty?: boolean }) {
if (empty) {
return (
<section className="rounded-[16px] border border-white/[0.05] bg-white/[0.02] p-4">
<p
className={cn(
"text-[14px] font-semibold leading-tight",
dmSans125ClassName(),
)}
>
Sources health
</p>
<p className="text-[11px] text-[#737373] font-medium mt-0.5 leading-[1.5]">
Nothing connected yet Drive, Notion, Gmail and more are ready when
you are.
</p>
<button
type="button"
className="mt-3 w-full inline-flex items-center justify-center gap-1.5 h-8 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] text-[12px] font-medium text-[#fafafa] hover:bg-[#14161A] transition-colors"
>
<Plus className="size-3.5" />
Connect a source
</button>
</section>
)
}
const issueCount = CONNECTED_SOURCES.filter(
(s) => s.status === "warning" || s.status === "error",
).length
return (
<section className="rounded-[16px] border border-white/[0.05] bg-white/[0.02] p-4">
<div className="flex items-center justify-between mb-1">
<p
className={cn(
"text-[14px] font-semibold leading-tight",
dmSans125ClassName(),
)}
>
Sources health
</p>
{issueCount > 0 ? (
<span className="text-[10px] uppercase tracking-[0.12em] text-[#FF8A47] font-semibold">
{issueCount} need attention
</span>
) : (
<span className="text-[10px] uppercase tracking-[0.12em] text-[#4BA0FA] font-semibold">
All healthy
</span>
)}
</div>
<p className="text-[11px] text-[#737373] font-medium">
Is the brain getting fed?
</p>
<ul className="mt-3 space-y-0.5">
{CONNECTED_SOURCES.map((s) => {
const meta = sourceStatusMeta(s.status)
return (
<li
key={s.id}
className="flex items-center gap-2.5 rounded-[8px] px-1.5 py-2 hover:bg-white/[0.03] transition-colors"
>
<div className="size-6 flex items-center justify-center shrink-0">
{connectedSourceIcon(s.iconKey)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="text-[12px] text-[#fafafa] font-medium truncate">
{s.name}
</p>
<span className="text-[10px] text-[#737373] font-medium tabular-nums shrink-0">
{s.when}
</span>
</div>
<p
className={cn(
"text-[11px] font-medium mt-0.5 inline-flex items-center gap-1",
meta.tint,
)}
>
{meta.icon}
<span className="truncate">{s.statusMessage ?? s.delta}</span>
</p>
</div>
</li>
)
})}
</ul>
</section>
)
}
function recommendedIcon(key: RecommendedSource["iconKey"]) {
if (key === "slack")
return <MessageSquare className="size-4 text-[#4BA0FA]" />
if (key === "github") return <Github className="size-4 text-[#fafafa]" />
if (key === "linear") return <Target className="size-4 text-[#fafafa]" />
if (key === "granola") return <Sparkles className="size-4 text-[#FF8A47]" />
if (key === "calendar") return <Calendar className="size-4 text-[#A1A1AA]" />
return <FileText className="size-4 text-[#8B8B8B]" />
}
export function RecommendedRail() {
return (
<section className="rounded-[16px] border border-white/[0.05] bg-white/[0.02] p-4">
<p
className={cn(
"text-[14px] font-semibold leading-tight",
dmSans125ClassName(),
)}
>
Add more to the brain
</p>
<p className="text-[11px] text-[#737373] font-medium mt-0.5">
Teams like yours connect these next.
</p>
<ul className="mt-3 space-y-0.5">
{RECOMMENDED_SOURCES.map((r) => (
<li
key={r.id}
className="group flex items-start gap-2.5 rounded-[8px] px-1.5 py-2 hover:bg-white/[0.03] transition-colors cursor-pointer"
>
<div className="size-6 flex items-center justify-center shrink-0">
{recommendedIcon(r.iconKey)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="text-[12px] text-[#fafafa] font-medium truncate">
{r.name}
</p>
<ArrowRight className="size-3 text-[#525D6E] group-hover:text-[#fafafa] transition-colors shrink-0" />
</div>
<p className="text-[11px] text-[#737373] font-medium leading-[1.4] mt-0.5 line-clamp-2">
{r.reason}
</p>
</div>
</li>
))}
</ul>
</section>
)
}

View file

@ -0,0 +1,53 @@
"use client"
import { useEffect, useRef } from "react"
import { useQueryState } from "nuqs"
const COLORS = ["#4BA0FA", "#FF8A47", "#B19CFF", "#10A37F", "#fafafa"]
export function OnboardingConfetti() {
const [onboarded, setOnboarded] = useQueryState("onboarded")
const fired = useRef(false)
useEffect(() => {
if (onboarded !== "1" || fired.current) return
fired.current = true
setOnboarded(null)
const reduceMotion = window.matchMedia?.(
"(prefers-reduced-motion: reduce)",
).matches
if (reduceMotion) return
let raf = 0
const run = async () => {
const confetti = (await import("canvas-confetti")).default
const end = Date.now() + 1400
const frame = () => {
confetti({
particleCount: 4,
angle: 60,
spread: 70,
startVelocity: 55,
origin: { x: 0, y: 0.7 },
colors: COLORS,
})
confetti({
particleCount: 4,
angle: 120,
spread: 70,
startVelocity: 55,
origin: { x: 1, y: 0.7 },
colors: COLORS,
})
if (Date.now() < end) raf = requestAnimationFrame(frame)
}
frame()
}
run()
return () => cancelAnimationFrame(raf)
}, [onboarded, setOnboarded])
return null
}

View file

@ -0,0 +1,137 @@
"use client"
import { LogoFull } from "@ui/assets/Logo"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { motion } from "motion/react"
import { BRAIN_STEPS, BRAIN_STEP_LABELS, type BrainStep } from "./types"
interface ShellProps {
step: BrainStep
domain?: string | null
children: React.ReactNode
}
export function BrainShell({ step, children }: ShellProps) {
const visibleSteps: BrainStep[] = BRAIN_STEPS
return (
<div
className={cn(
"relative min-h-dvh bg-[#05080D] text-[#FAFAFA] flex flex-col overflow-hidden",
dmSansClassName(),
)}
>
<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%)",
}}
/>
<header className="relative z-10 grid grid-cols-[1fr_auto_1fr] items-center px-6 md:px-10 py-4 gap-4">
<LogoFull className="h-5 md:h-6 text-[#fafafa] justify-self-start" />
<StepIndicator step={step} visibleSteps={visibleSteps} />
<span aria-hidden />
</header>
<main className="relative z-10 flex-1 flex items-center justify-center px-4 md:px-10 py-6 md:py-10">
<div className="w-full max-w-5xl">{children}</div>
</main>
</div>
)
}
function StepIndicator({
step,
visibleSteps,
}: {
step: BrainStep
visibleSteps: BrainStep[]
}) {
const currentIdx = visibleSteps.indexOf(step)
return (
<div className="flex items-start">
{visibleSteps.map((s, i) => {
const isDone = i < currentIdx
const isCurrent = i === currentIdx
const isLast = i === visibleSteps.length - 1
return (
<div key={s} className="flex items-start">
<div className="flex flex-col items-center gap-1.5 min-w-[58px]">
<StepDot done={isDone} current={isCurrent} />
<span
className={cn(
"text-[11px] font-medium transition-colors leading-none",
isCurrent
? "text-[#fafafa]"
: isDone
? "text-[#737373]"
: "text-[#525D6E]",
)}
>
{BRAIN_STEP_LABELS[s]}
</span>
</div>
{!isLast && (
<div className="flex-1 h-px min-w-[28px] mt-[5px]">
<motion.div
initial={false}
animate={{
background: isDone
? "linear-gradient(to right, #4BA0FA, rgba(75,160,250,0.4))"
: "#2E353D",
}}
transition={{ duration: 0.3 }}
className="h-full w-full rounded-full"
/>
</div>
)}
</div>
)
})}
</div>
)
}
function StepDot({ done, current }: { done: boolean; current: boolean }) {
if (current) {
return (
<div className="relative size-3 flex items-center justify-center">
<motion.span
layoutId="brain-step-ring"
className="absolute inset-0 rounded-full border-2 border-[#4BA0FA]"
/>
<span className="size-1.5 rounded-full bg-[#4BA0FA]" />
</div>
)
}
if (done) {
return (
<div className="size-3 flex items-center justify-center">
<span className="size-2.5 rounded-full bg-[#4BA0FA]" />
</div>
)
}
return (
<div className="size-3 flex items-center justify-center">
<span className="size-2.5 rounded-full border border-[#2E353D]" />
</div>
)
}

View file

@ -0,0 +1,473 @@
"use client"
import { useEffect, useState } from "react"
import { motion } from "motion/react"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import { Textarea } from "@ui/components/textarea"
import {
ArrowRight,
Brain,
Building2,
LayoutGrid,
Loader2,
Plug,
Terminal,
User2,
UserPlus,
Users2,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import type { BrainMode } from "./types"
export interface AboutValues {
name: string
about: string
workspaceName: string
workspaceDomain: string
}
interface Props {
mode: BrainMode
onModeChange: (m: BrainMode) => void
domain: string | null
suggestedWorkspaceName: string
defaultName: string
avatarUrl: string | null
values: AboutValues
onChange: (next: AboutValues) => void
onContinue: () => void
submitting?: boolean
}
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 = {
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 =
"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({
mode,
onModeChange,
domain,
suggestedWorkspaceName,
defaultName,
avatarUrl,
values,
onChange,
onContinue,
submitting,
}: Props) {
// biome-ignore lint/correctness/useExhaustiveDependencies: one-time initialization when defaults become available
useEffect(() => {
const patch: Partial<AboutValues> = {}
if (!values.name && defaultName) patch.name = defaultName
if (!values.workspaceName && suggestedWorkspaceName) {
patch.workspaceName = suggestedWorkspaceName
}
if (!values.workspaceDomain && domain) {
patch.workspaceDomain = domain
}
if (Object.keys(patch).length > 0) onChange({ ...values, ...patch })
}, [defaultName, suggestedWorkspaceName, domain])
const canContinue =
values.name.trim().length > 0 && values.workspaceName.trim().length > 0
return (
<div className="space-y-5">
<div className="grid md:grid-cols-[1.6fr_1fr] gap-4 items-stretch">
<section
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-7"
style={cardSurfaceStyle}
>
<UserAvatar
url={avatarUrl}
name={values.name || defaultName}
className="size-14 mb-5"
/>
<p
className={cn(
"font-semibold text-[#fafafa] text-[20px]",
dmSans125ClassName(),
)}
>
Tell us about you
</p>
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
So your brain sounds like yours, not the docs.
</p>
<div className="mt-6 space-y-5">
<div>
<p className={fieldLabel}>Your name</p>
<Input
value={values.name}
onChange={(e) => onChange({ ...values, name: e.target.value })}
placeholder="e.g. Mahesh"
className={inputClass}
style={inputBevelStyle}
/>
</div>
<div>
<p className={fieldLabel}>
What are you here for?{" "}
<span className="text-[#525D6E] font-medium">(optional)</span>
</p>
<Textarea
value={values.about}
onChange={(e) => onChange({ ...values, about: e.target.value })}
placeholder="A sentence or two — what you do, what you're hoping the brain helps with."
rows={4}
className={cn(
inputClass,
"h-auto resize-none py-3 leading-[1.5]",
)}
style={inputBevelStyle}
/>
</div>
</div>
</section>
<section
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-7 flex flex-col"
style={cardSurfaceStyle}
>
<ModeToggle mode={mode} onChange={onModeChange} />
<div className="mt-6">
{mode === "team" ? (
<TeamWorkspaceCard
domain={values.workspaceDomain || domain || ""}
onDomainChange={(d) =>
onChange({ ...values, workspaceDomain: d })
}
value={values.workspaceName}
onChange={(w) => onChange({ ...values, workspaceName: w })}
suggested={suggestedWorkspaceName}
/>
) : (
<PersonalWorkspaceCard
value={values.workspaceName}
onChange={(w) => onChange({ ...values, workspaceName: w })}
/>
)}
</div>
</section>
</div>
<div className="flex items-center justify-end gap-[22px] px-1">
<Button
variant="insideOut"
onClick={onContinue}
disabled={!canContinue || submitting}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
{submitting ? (
<>
Creating
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
Continue
<ArrowRight className="size-3.5" />
</>
)}
</Button>
</div>
</div>
)
}
function TeamWorkspaceCard({
domain,
onDomainChange,
value,
onChange,
suggested,
}: {
domain: string
onDomainChange: (d: string) => void
value: string
onChange: (v: string) => void
suggested: string
}) {
return (
<>
<div className="flex items-center gap-3">
<div
className="size-12 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden shrink-0"
style={inputBevelStyle}
>
{domain ? (
<DomainLogo domain={domain} />
) : (
<Building2 className="size-5 text-[#737373]" />
)}
</div>
<div className="min-w-0 flex-1">
<input
value={domain}
onChange={(e) => onDomainChange(e.target.value.trim())}
placeholder="your-team.com"
className="w-full bg-transparent text-[18px] text-[#fafafa] font-semibold leading-tight outline-none border-b border-transparent hover:border-[rgba(115,115,115,0.2)] focus:border-[rgba(115,115,115,0.4)] transition-colors px-0 py-0.5"
/>
<div className="text-[12px] text-[#737373] font-medium mt-0.5">
Team workspace
</div>
</div>
</div>
<div className="mt-6">
<p className={fieldLabel}>
Workspace name{" "}
<span className="text-[#525D6E] font-medium">
(rename if you'd like)
</span>
</p>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={suggested || "Acme"}
className={inputClass}
style={inputBevelStyle}
/>
</div>
<PerksList
heading="What this unlocks"
perks={[
{
icon: <UserPlus className="size-4 text-[#8B8B8B]" />,
title: "Invite teammates",
blurb: "Everyone contributes to the same brain.",
},
{
icon: <Terminal className="size-4 text-[#8B8B8B]" />,
title: "Shared coding agent context",
blurb: "Claude, Cursor, MCP — same brain across the team.",
},
{
icon: <LayoutGrid className="size-4 text-[#8B8B8B]" />,
title: "Org-wide spaces",
blurb: "Carve out sales, eng, design with their own access.",
},
]}
/>
<p className="text-[12px] text-[#525D6E] mt-auto pt-5 leading-[1.5] font-medium">
Not your team? Switch above.
</p>
</>
)
}
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`,
`https://icons.duckduckgo.com/ip3/${domain}.ico`,
]
const [idx, setIdx] = useState(0)
if (idx >= sources.length) {
return <Building2 className="size-5 text-[#737373]" />
}
return (
<img
src={sources[idx]}
alt={domain}
className="size-6 object-contain"
onError={() => setIdx((i) => i + 1)}
/>
)
}
function PersonalWorkspaceCard({
value,
onChange,
}: {
value: string
onChange: (v: string) => void
}) {
return (
<>
<div className="flex items-center gap-3">
<div
className="size-12 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center"
style={inputBevelStyle}
>
<User2 className="size-5 text-[#737373]" />
</div>
<div>
<div className="text-[18px] text-[#fafafa] font-semibold">
Just you, for now
</div>
<div className="text-[12px] text-[#737373] font-medium mt-0.5">
Personal workspace
</div>
</div>
</div>
<div className="mt-6">
<p className={fieldLabel}>Workspace nickname</p>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="My brain"
className={inputClass}
style={inputBevelStyle}
/>
</div>
<PerksList
heading="What this unlocks"
perks={[
{
icon: <Brain className="size-4 text-[#8B8B8B]" />,
title: "Your own brain",
blurb: "Notes, docs, bookmarks — all searchable in one place.",
},
{
icon: <Plug className="size-4 text-[#8B8B8B]" />,
title: "Plug into your AI tools",
blurb: "Claude, Cursor, ChatGPT — your context, everywhere.",
},
{
icon: <Users2 className="size-4 text-[#8B8B8B]" />,
title: "Switch to a team anytime",
blurb: "Invite teammates whenever you're ready.",
},
]}
/>
<p className="text-[12px] text-[#525D6E] mt-auto pt-5 leading-[1.5] font-medium">
Working with a team? Switch above.
</p>
</>
)
}
function ModeToggle({
mode,
onChange,
}: {
mode: BrainMode
onChange: (m: BrainMode) => void
}) {
const items: { id: BrainMode; label: string }[] = [
{ id: "personal", label: "Personal" },
{ id: "team", label: "Team" },
]
return (
<div
className="relative grid grid-cols-2 items-center rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.2)] p-1 text-[13px] font-medium w-full"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
{items.map((item) => {
const isActive = mode === item.id
return (
<button
key={item.id}
type="button"
onClick={() => onChange(item.id)}
className={cn(
"relative h-8 rounded-full text-center transition-colors",
isActive
? "text-[#fafafa]"
: "text-[#737373] hover:text-[#fafafa]",
)}
>
{isActive && (
<motion.span
layoutId="brain-mode-pill"
className="absolute inset-0 rounded-full"
style={{
background: "#00173C",
border: "1px solid #2261CA66",
}}
transition={{
type: "spring",
stiffness: 380,
damping: 34,
}}
/>
)}
<span className="relative z-10">{item.label}</span>
</button>
)
})}
</div>
)
}
function UserAvatar({
url,
name,
className,
}: {
url: string | null
name: string
className?: string
}) {
const [errored, setErrored] = useState(false)
const initial = (name?.trim()?.[0] ?? "?").toUpperCase()
const hasImage = url && !errored
return (
<div
className={cn(
"rounded-full overflow-hidden bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center",
className,
)}
style={inputBevelStyle}
>
{hasImage ? (
<img
src={url}
alt={name}
className="size-full object-cover"
onError={() => setErrored(true)}
/>
) : (
<span className="text-[20px] font-semibold text-[#fafafa]">
{initial}
</span>
)}
</div>
)
}
type Perk = { icon: React.ReactNode; title: string; blurb: string }
function PerksList({ heading, perks }: { heading: string; perks: Perk[] }) {
return (
<div className="mt-6">
<p className="text-[10px] uppercase tracking-[0.08em] text-[#737373] font-semibold mb-3 pl-1">
{heading}
</p>
<ul className="space-y-2.5">
{perks.map((p) => (
<li key={p.title} className="flex items-center gap-2.5 pl-1">
<span className="shrink-0">{p.icon}</span>
<span className="text-[13px] text-[#fafafa] font-medium">
{p.title}
</span>
</li>
))}
</ul>
</div>
)
}

View file

@ -0,0 +1,480 @@
"use client"
import { useState, useEffect } from "react"
import Image from "next/image"
import { useQueryState, parseAsString } from "nuqs"
import { Button } from "@ui/components/button"
import { MCPIcon } from "@ui/assets/icons"
import { ArrowRight, Check, Copy, EyeOff, Eye } from "lucide-react"
import { cn } from "@lib/utils"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { toast } from "sonner"
import { MCPSteps } from "@/components/mcp-modal/mcp-detail-view"
import { PLUGIN_CATALOG } from "@/lib/plugin-catalog"
interface Props {
mcpUrl: string
onContinue: () => void
}
const modalCardStyle = {
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 = {
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)",
}
type AgentCategory = "coding" | "productivity"
type Agent = {
key: string
name: string
tagline: string
category: AgentCategory
pluginId?: string
}
const AGENTS: Agent[] = [
{
key: "cursor",
name: "Cursor",
tagline: "Persistent context across coding sessions.",
category: "coding",
},
{
key: "claude-code",
name: "Claude Code",
tagline: "Memory and decisions across CLI sessions.",
category: "coding",
pluginId: "claude_code",
},
{
key: "vscode",
name: "VS Code",
tagline: "Inline context while you write.",
category: "coding",
},
{
key: "cline",
name: "Cline",
tagline: "Agentic dev tasks with your memory.",
category: "coding",
},
{
key: "codex",
name: "Codex",
tagline: "OpenAI Codex with persistent memory.",
category: "coding",
pluginId: "codex",
},
{
key: "gemini-cli",
name: "Gemini CLI",
tagline: "Gemini in your terminal, brain-aware.",
category: "coding",
},
{
key: "claude",
name: "Claude Desktop",
tagline: "Memory across every Claude conversation.",
category: "productivity",
},
{
key: "chatgpt",
name: "ChatGPT",
tagline: "Custom GPT backed by your brain.",
category: "productivity",
},
]
const CATEGORY_ORDER: { id: AgentCategory; label: string }[] = [
{ id: "coding", label: "Coding" },
{ id: "productivity", label: "Productivity" },
]
function agentIcon(agent: Agent) {
if (agent.pluginId) {
const plugin = PLUGIN_CATALOG[agent.pluginId]
if (plugin) return plugin.icon
}
const file = agent.key === "claude-code" ? "claude" : agent.key
return `/mcp-supported-tools/${file}.png`
}
export function StepIngest({ mcpUrl, onContinue }: Props) {
const [activeCategory, setActiveCategory] = useState<AgentCategory>("coding")
const [selectedKey, setSelectedKey] = useState<string>("cursor")
const [, setMcpClient] = useQueryState("mcpClient", parseAsString)
const selectedAgent = AGENTS.find((a) => a.key === selectedKey) ?? AGENTS[0]
useEffect(() => {
if (selectedAgent && !selectedAgent.pluginId) {
setMcpClient(selectedAgent.key)
} else {
setMcpClient(null)
}
}, [selectedAgent, setMcpClient])
const selectAgent = (agent: Agent) => {
setSelectedKey(agent.key)
}
const filtered = AGENTS.filter((a) => a.category === activeCategory)
return (
<div className="space-y-5">
<div className="px-1">
<p
className={cn(
"font-semibold text-[#fafafa] text-[22px]",
dmSans125ClassName(),
)}
>
Use your brain anywhere
</p>
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
Now plug it into the tools you already use to write code, chat, think.
</p>
</div>
<McpHero url={mcpUrl} />
<div className="grid lg:grid-cols-[300px_1fr] gap-4 h-[560px]">
<aside
className="rounded-[16px] bg-[#1B1F24] p-3 flex flex-col gap-3 overflow-hidden h-full"
style={modalCardStyle}
>
<CategoryTabs value={activeCategory} onChange={setActiveCategory} />
<div className="flex-1 min-h-0 overflow-y-auto space-y-1 scrollbar-thin pr-1">
{filtered.map((agent) => (
<AgentRow
key={agent.key}
agent={agent}
active={selectedKey === agent.key}
onClick={() => selectAgent(agent)}
/>
))}
</div>
</aside>
<section
className="rounded-[16px] bg-[#1B1F24] p-5 md:p-6 overflow-hidden flex flex-col h-full"
style={modalCardStyle}
>
{selectedAgent?.pluginId ? (
<PluginSteps pluginId={selectedAgent.pluginId} />
) : (
<MCPSteps variant="embedded" />
)}
</section>
</div>
<div className="flex flex-wrap items-center justify-end gap-[22px] px-1 pt-2">
<button
type="button"
onClick={onContinue}
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors"
>
Skip for now
</button>
<Button
variant="insideOut"
onClick={onContinue}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
Continue
<ArrowRight className="size-3.5" />
</Button>
</div>
</div>
)
}
function McpHero({ url }: { url: string }) {
const [copied, setCopied] = useState(false)
const copy = async () => {
try {
await navigator.clipboard.writeText(url)
setCopied(true)
toast.success("MCP URL copied")
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error("Could not copy")
}
}
return (
<section
className="rounded-[16px] bg-[#1B1F24] px-5 py-3 flex items-center gap-3 relative overflow-hidden"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px left-0 right-0 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
}}
/>
<div
className="size-9 rounded-[10px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center shrink-0"
style={inputBevelStyle}
>
<MCPIcon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[10px] uppercase tracking-[0.08em] text-[#737373] font-semibold">
Universal MCP URL
</p>
<p className="text-[13px] text-[#fafafa] font-mono truncate mt-0.5">
{url}
</p>
</div>
<Button
variant="insideOut"
onClick={copy}
className="rounded-full h-9 px-4 text-[12px] font-medium text-[#fafafa] shrink-0"
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
{copied ? "Copied" : "Copy URL"}
</Button>
</section>
)
}
function PluginSteps({ pluginId }: { pluginId: string }) {
const plugin = PLUGIN_CATALOG[pluginId]
if (!plugin) return null
const steps = plugin.installSteps ?? []
return (
<div className="h-full overflow-y-auto pr-1 scrollbar-thin">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)] flex items-center justify-center shrink-0 overflow-hidden">
<Image
src={plugin.icon}
alt={plugin.name}
width={28}
height={28}
unoptimized
className="size-6 object-contain"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-[16px] font-semibold text-[#fafafa] leading-tight">
Set up {plugin.name}
</p>
<p className="text-[12px] text-[#A1A1AA] font-medium mt-0.5">
{plugin.tagline}
</p>
</div>
{plugin.docsUrl && (
<a
href={plugin.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[12px] text-[#A1A1AA] hover:text-[#fafafa] transition-colors font-medium shrink-0"
>
Docs
</a>
)}
</div>
<div className="space-y-3">
{steps.map((step, i) => (
<PluginStep key={step.title} idx={i + 1} step={step} />
))}
</div>
<div className="mt-4 rounded-[10px] border border-[#4BA0FA]/20 bg-[#4BA0FA]/[0.04] p-3 flex items-start gap-2">
<div className="text-[11px] text-[#A1A1AA] leading-[1.5] font-medium">
Your <span className="text-[#fafafa]">API key</span> is minted in
Settings Integrations Plugins. Mint it once and paste into the
step above.
</div>
</div>
</div>
)
}
function PluginStep({
idx,
step,
}: {
idx: number
step: import("@/lib/plugin-catalog").InstallStep
}) {
const [revealed, setRevealed] = useState(false)
const [copied, setCopied] = useState(false)
const copy = async () => {
if (!step.code) return
try {
await navigator.clipboard.writeText(step.code)
setCopied(true)
toast.success("Copied")
setTimeout(() => setCopied(false), 1500)
} catch {
toast.error("Could not copy")
}
}
return (
<div className="flex gap-3">
<div className="flex flex-col items-center shrink-0 pt-1">
<div className="size-5 rounded-full bg-[#4BA0FA]/15 text-[#4BA0FA] flex items-center justify-center text-[10px] font-semibold">
{idx}
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-semibold text-[#fafafa]">
{step.title}
{step.optional && (
<span className="ml-2 text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
Optional
</span>
)}
</p>
{step.description && (
<p className="text-[12px] text-[#A1A1AA] mt-1 leading-[1.5] font-medium">
{step.description}
</p>
)}
{step.code && (
<div
className="mt-2 rounded-[10px] bg-[#0F1217] border border-[rgba(82,89,102,0.2)] p-3 flex items-start gap-2"
style={inputBevelStyle}
>
<pre
className={cn(
"flex-1 min-w-0 text-[11px] text-[#fafafa] font-mono overflow-x-auto whitespace-pre",
step.secret && !revealed && "blur-[3px] select-none",
)}
>
{step.code}
</pre>
<div className="flex items-center gap-1 shrink-0">
{step.secret && (
<button
type="button"
onClick={() => setRevealed((v) => !v)}
className="size-7 rounded-md text-[#737373] hover:text-[#fafafa] flex items-center justify-center transition-colors"
aria-label={revealed ? "Hide" : "Reveal"}
>
{revealed ? (
<EyeOff className="size-3.5" />
) : (
<Eye className="size-3.5" />
)}
</button>
)}
<button
type="button"
onClick={copy}
className="size-7 rounded-md text-[#737373] hover:text-[#fafafa] flex items-center justify-center transition-colors"
aria-label="Copy"
>
{copied ? (
<Check className="size-3.5 text-[#4BA0FA]" />
) : (
<Copy className="size-3.5" />
)}
</button>
</div>
</div>
)}
</div>
</div>
)
}
function CategoryTabs({
value,
onChange,
}: {
value: AgentCategory
onChange: (c: AgentCategory) => void
}) {
const counts: Record<AgentCategory, number> = {
coding: 0,
productivity: 0,
}
for (const a of AGENTS) counts[a.category] += 1
return (
<div className="scrollbar-none flex items-center gap-0.5 overflow-x-auto rounded-full bg-[#0D121A] p-0.5 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)] w-full">
{CATEGORY_ORDER.map((cat) => {
const isActive = value === cat.id
return (
<button
key={cat.id}
type="button"
onClick={() => onChange(cat.id)}
className={cn(
dmSansClassName(),
"flex flex-1 h-7 shrink-0 items-center justify-center gap-1.5 rounded-full px-3 text-[12px] font-medium leading-none transition-colors",
isActive
? "bg-white/[0.10] text-[#FAFAFA]"
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
)}
>
<span className="leading-none">{cat.label}</span>
<span
className={cn(
"text-[10px] font-semibold tabular-nums leading-none",
isActive ? "text-[#A1A1AA]" : "text-[#525D6E]",
)}
>
{counts[cat.id]}
</span>
</button>
)
})}
</div>
)
}
function AgentRow({
agent,
active,
onClick,
}: {
agent: Agent
active: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
dmSansClassName(),
"w-full text-left flex items-center gap-2.5 rounded-[10px] px-2.5 py-2 transition-colors",
active
? "bg-white/[0.08] text-[#fafafa]"
: "text-[#A1A1AA] hover:bg-white/[0.04] hover:text-[#fafafa]",
)}
>
<div className="size-8 rounded-[8px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)] flex items-center justify-center shrink-0 overflow-hidden">
<Image
src={agentIcon(agent)}
alt={agent.name}
width={24}
height={24}
unoptimized
className="size-5 object-contain"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium truncate">{agent.name}</p>
<p className="text-[11px] text-[#737373] truncate font-medium">
{agent.tagline}
</p>
</div>
</button>
)
}

View file

@ -0,0 +1,587 @@
"use client"
import { useState } from "react"
import { Button } from "@ui/components/button"
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@ui/components/drawer"
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
import { Logo } from "@ui/assets/Logo"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/select"
import {
AlertTriangle,
ArrowRight,
Check,
Database,
FolderOpen,
Github,
Globe,
Mic,
Plus,
} from "lucide-react"
import {
AppleShortcutsIcon,
ChromeIcon,
RaycastIcon,
} from "@/components/integration-icons"
function XBookmarksIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M18.24 2.25h3.31l-7.23 8.26 8.5 11.24H16.17l-4.71-6.23-5.4 6.23H2.74l7.73-8.84L1.25 2.25H8.08l4.25 5.62 5.91-5.62Zm-1.16 17.52h1.83L7.08 4.13H5.12z" />
</svg>
)
}
function GmailIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 256 193"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M58.182 192.05V93.14L27.507 65.077 0 49.504v125.091c0 9.658 7.825 17.455 17.455 17.455z"
fill="#4285F4"
/>
<path
d="M197.818 192.05h40.727c9.659 0 17.455-7.826 17.455-17.455V49.505l-31.156 17.837-27.026 25.798z"
fill="#34A853"
/>
<path
d="m58.182 93.14-4.174-38.647 4.174-36.989L128 69.868l69.818-52.364 4.668 33.95-4.668 41.685L128 145.504z"
fill="#EA4335"
/>
<path
d="M197.818 17.504V93.14L256 49.504V26.231c0-21.585-24.64-33.89-41.89-20.945z"
fill="#FBBC04"
/>
<path
d="m0 49.504 26.759 20.07L58.182 93.14V17.504L41.89 5.286C24.61-7.66 0 4.646 0 26.23z"
fill="#C5221F"
/>
</svg>
)
}
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import { $fetch } from "@lib/api"
import { toast } from "sonner"
type SourceId = "drive" | "notion" | "gmail" | "github" | "onedrive"
type SourceState = "idle" | "connecting" | "connected" | "waitlist"
type DriveScope = "selective" | "full"
export interface SourcesValues {
connected: Partial<Record<SourceId, SourceState>>
driveScope: DriveScope
}
interface Props {
containerTag: string
workspaceName: string
values: SourcesValues
onChange: (next: SourcesValues) => void
onContinue: () => void
}
const modalCardStyle = {
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 = {
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)",
}
export function StepSources({
containerTag,
workspaceName,
values,
onChange,
onContinue,
}: Props) {
const [moreOpen, setMoreOpen] = useState(false)
const setState = (id: SourceId, state: SourceState) => {
onChange({ ...values, connected: { ...values.connected, [id]: state } })
}
const connectRealProvider = async (
provider: "google-drive" | "notion" | "onedrive",
id: SourceId,
) => {
setState(id, "connecting")
try {
const metadata: Record<string, string> = {}
if (provider === "google-drive") {
metadata.scope = values.driveScope
}
const res = await $fetch("@post/connections/:provider", {
params: { provider },
body: {
redirectUrl: window.location.href,
containerTags: [containerTag],
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
},
})
const data = "data" in res ? res.data : null
if (data && "authLink" in data && data.authLink) {
window.location.href = data.authLink
return
}
throw new Error("No auth link returned")
} catch (err) {
setState(id, "idle")
toast.error(
err instanceof Error ? err.message : "Could not start connection",
)
}
}
const connectedCount = Object.values(values.connected).filter(
(s) => s === "connected" || s === "waitlist",
).length
return (
<div>
<div className="flex flex-wrap items-end justify-between gap-3 mb-6 px-1">
<div>
<p
className={cn(
"font-semibold text-[#fafafa] text-[22px]",
dmSans125ClassName(),
)}
>
Connect your team's signals
</p>
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
Start with the sources that carry the most context. Add more
anytime.
</p>
</div>
<RoutingChip workspaceName={workspaceName} />
</div>
<div className="grid md:grid-cols-2 gap-3">
<SourceCard
title="Google Drive"
blurb="Docs, sheets, slides — the working memory of your team."
icon={<GoogleDrive className="size-7" />}
state={values.connected.drive ?? "idle"}
ctaLabel="Connect"
perks={[
"Docs, sheets, slides — all parsed",
"Stays in sync as files change",
"You pick what to share at sign-in",
]}
onConnect={() => connectRealProvider("google-drive", "drive")}
headerNote={
values.driveScope === "full" ? (
<p className="mt-1.5 flex items-center gap-1.5 text-[11px] text-[#FF8A47] font-medium">
<AlertTriangle className="size-3 shrink-0" />
Full Drive can exhaust your monthly usage.
</p>
) : null
}
footerLeft={
<DriveScopePicker
value={values.driveScope}
onChange={(s) => onChange({ ...values, driveScope: s })}
/>
}
footerRight={<SpaceChip name="My Drive" />}
/>
<SourceCard
title="Notion"
blurb="Pages, databases, the team's running wiki."
icon={<Notion className="size-7" />}
state={values.connected.notion ?? "idle"}
ctaLabel="Connect"
perks={[
"Pages and database rows",
"Stays in sync when you edit",
"Pick which workspaces ingest",
]}
onConnect={() => connectRealProvider("notion", "notion")}
footerRight={<SpaceChip name="Company Notion" />}
/>
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 px-1">
<button
type="button"
onClick={() => setMoreOpen(true)}
className="text-[#737373] font-medium text-[14px] hover:text-[#fafafa] inline-flex items-center gap-1.5 transition-colors"
>
<Plus className="size-3.5" />
More integrations
<span className="text-[#525D6E]">
(Gmail, GitHub, OneDrive, Granola)
</span>
</button>
<div className="flex items-center gap-[22px]">
<button
type="button"
onClick={onContinue}
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors"
>
Skip for now
</button>
<Button
variant="insideOut"
onClick={onContinue}
disabled={connectedCount === 0}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
Continue
{connectedCount > 0 && (
<span className="text-[#4BA0FA] ml-1">({connectedCount})</span>
)}
<ArrowRight className="size-3.5" />
</Button>
</div>
</div>
<MoreDrawer
open={moreOpen}
onClose={() => setMoreOpen(false)}
containerTag={containerTag}
connectRealProvider={connectRealProvider}
/>
</div>
)
}
function RoutingChip({ workspaceName }: { workspaceName: string }) {
return (
<div
className="inline-flex items-center gap-2 px-3 h-9 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.2)] text-[12px]"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
title="All sources route to your brain. You can carve out spaces after setup."
>
<Logo className="size-3.5 text-[#8B8B8B]" />
<span className="text-[#737373] font-medium">Routing to</span>
<span className="text-[#fafafa] font-semibold">
{workspaceName || "your brain"}
</span>
</div>
)
}
function SourceCard({
title,
blurb,
icon,
state,
ctaLabel,
perks,
soft,
headerNote,
footerLeft,
footerRight,
onConnect,
}: {
title: string
blurb: string
icon: React.ReactNode
state: SourceState
ctaLabel: string
perks: string[]
soft?: boolean
headerNote?: React.ReactNode
footerLeft?: React.ReactNode
footerRight?: React.ReactNode
onConnect: () => void
}) {
const isDone = state === "connected" || state === "waitlist"
return (
<div
className={cn(
"rounded-[16px] p-5 transition-colors bg-[#1B1F24] flex flex-col",
isDone && "ring-1 ring-[#2261CA33]",
)}
style={modalCardStyle}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div
className="size-12 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center shrink-0"
style={inputBevelStyle}
>
{icon}
</div>
<div className="min-w-0">
<p className="text-[15px] font-semibold text-[#fafafa]">{title}</p>
<p className="text-[12px] text-[#737373] mt-0.5 leading-[1.4] font-medium">
{blurb}
</p>
{headerNote}
</div>
</div>
{isDone ? (
<span className="inline-flex items-center gap-1 text-[11px] text-[#4BA0FA] font-semibold uppercase tracking-[0.08em] shrink-0 mt-1">
<Check className="size-3" />
{state === "waitlist" ? "Requested" : "Connected"}
</span>
) : (
<Button
variant="insideOut"
onClick={onConnect}
disabled={state === "connecting"}
className="shrink-0 rounded-full h-9 px-4 text-[13px] font-medium text-[#fafafa]"
>
{state === "connecting" ? "Opening…" : ctaLabel}
</Button>
)}
</div>
<ul className="mt-4 space-y-1.5">
{perks.map((p) => (
<li
key={p}
className="flex items-start gap-2.5 text-[12px] text-[#737373] font-medium leading-[1.5]"
>
<span
aria-hidden
className="size-1 rounded-full bg-[#525D6E] shrink-0 mt-[7px]"
/>
<span>{p}</span>
</li>
))}
</ul>
{soft && !isDone && (
<p className="text-[11px] text-[#525D6E] mt-3 leading-[1.5] font-medium">
OAuth lands shortly request access and we'll auto-enable it.
</p>
)}
{(footerLeft || footerRight) && (
<div className="mt-auto pt-4 flex items-end justify-between gap-3">
<div>{footerLeft}</div>
<div className="pb-1.5">{footerRight}</div>
</div>
)}
</div>
)
}
function SpaceChip({ name }: { name: string }) {
return (
<div
className="inline-flex items-center gap-1.5 text-[11px] font-medium text-[#737373]"
title={`This source will save into the "${name}" space.`}
>
<span className="text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
Saves to
</span>
<FolderOpen className="size-3 text-[#737373]" />
<span className="text-[#fafafa]">{name}</span>
</div>
)
}
function DriveScopePicker({
value,
onChange,
}: {
value: DriveScope
onChange: (s: DriveScope) => void
}) {
return (
<Select value={value} onValueChange={(v) => onChange(v as DriveScope)}>
<SelectTrigger className="h-7 px-3 rounded-full bg-transparent border border-[rgba(115,115,115,0.18)] text-[#737373] text-[11px] font-medium gap-1 w-auto shadow-none focus:ring-0 hover:text-[#fafafa] hover:border-[rgba(115,115,115,0.3)] transition-colors [&>svg]:size-3 [&>svg]:opacity-80">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#14161A] border-[rgba(82,89,102,0.2)] rounded-[12px] min-w-[180px]">
<SelectItem
value="selective"
className="text-[#fafafa] focus:bg-[#1B1F24] text-[13px]"
>
Files & folders
</SelectItem>
<SelectItem
value="full"
className="text-[#fafafa] focus:bg-[#1B1F24] text-[13px]"
>
Full Drive
</SelectItem>
</SelectContent>
</Select>
)
}
function MoreDrawer({
open,
onClose,
containerTag,
connectRealProvider,
}: {
open: boolean
onClose: () => void
containerTag: string
connectRealProvider: (
provider: "google-drive" | "notion" | "onedrive",
id: SourceId,
) => void
}) {
return (
<Drawer open={open} onOpenChange={(o) => !o && onClose()}>
<DrawerContent
className="bg-[#1B1F24] border-none"
style={modalCardStyle}
>
<DrawerHeader className="px-8 pt-6 pb-4 max-w-5xl mx-auto w-full">
<DrawerTitle className="text-[#fafafa] text-[20px] font-semibold">
More integrations
</DrawerTitle>
<p className="text-[#737373] text-[13px] font-medium mt-1">
Add any of these alongside your spotlight sources. Everything routes
to <span className="text-[#fafafa]">{containerTag}</span>.
</p>
</DrawerHeader>
<div className="px-8 pb-8 max-w-5xl mx-auto w-full grid sm:grid-cols-2 gap-2.5">
<MoreItem
title="Gmail"
blurb="Inbox threads, decisions, customer conversations."
icon={<GmailIcon className="size-6" />}
action="Request access"
soft
/>
<MoreItem
title="GitHub"
blurb="PRs, issues, READMEs — every code decision searchable."
icon={<Github className="size-6 text-[#fafafa]" />}
action="Request access"
soft
/>
<MoreItem
title="OneDrive"
blurb="Office docs from OneDrive."
icon={<OneDrive className="size-6" />}
action="Connect"
onAction={() => connectRealProvider("onedrive", "onedrive")}
/>
<MoreItem
title="Granola"
blurb="Meeting notes into searchable decisions."
icon={<Mic className="size-6 text-[#FF8A47]" />}
action="Coming soon"
soft
/>
<MoreItem
title="Web crawler"
blurb="Crawl any site into your brain."
icon={<Globe className="size-6 text-[#8B8B8B]" />}
action="Connect"
soft
/>
<MoreItem
title="S3"
blurb="Files from an S3 bucket."
icon={<Database className="size-6 text-[#8B8B8B]" />}
action="Connect"
soft
/>
<MoreItem
title="Chrome extension"
blurb="Save pages and clip from the browser."
icon={<ChromeIcon className="size-7" />}
action="Install"
soft
/>
<MoreItem
title="Apple Shortcuts"
blurb="One-tap capture from iPhone."
icon={<AppleShortcutsIcon />}
action="Install"
soft
/>
<MoreItem
title="Raycast"
blurb="Quick add from your Mac."
icon={<RaycastIcon className="size-7" />}
action="Install"
soft
/>
<MoreItem
title="X bookmarks"
blurb="One-shot import of saved tweets."
icon={<XBookmarksIcon className="size-5 text-[#fafafa]" />}
action="Import"
soft
/>
</div>
</DrawerContent>
</Drawer>
)
}
function MoreItem({
title,
blurb,
icon,
action,
soft,
onAction,
}: {
title: string
blurb: string
icon: React.ReactNode
action: string
soft?: boolean
onAction?: () => void
}) {
return (
<div
className="flex items-center gap-3 rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-4 py-3"
style={inputBevelStyle}
>
<div
className="size-10 rounded-[10px] bg-[#0F1217] border border-[rgba(82,89,102,0.2)] flex items-center justify-center shrink-0"
style={inputBevelStyle}
>
{icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-[14px] text-[#fafafa] font-semibold truncate">
{title}
</p>
<p className="text-[12px] text-[#737373] font-medium truncate">
{blurb}
</p>
</div>
<Button
variant="insideOut"
onClick={onAction}
disabled={soft && !onAction}
className={cn(
"shrink-0 rounded-full h-8 px-3 text-[12px] font-medium text-[#fafafa]",
soft && !onAction && "opacity-50",
)}
>
{action}
</Button>
</div>
)
}

View file

@ -0,0 +1,331 @@
"use client"
import { useMemo, useState } from "react"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/select"
import { ArrowRight, Loader2, Mail, Plus, Trash2, Users } from "lucide-react"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
const modalCardStyle = {
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 = {
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 inputClass =
"bg-[#0F1217] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#525D6E] h-12 shadow-none focus-visible:ring-0 focus-visible:border-[rgba(115,115,115,0.3)] transition-colors"
export interface TeamValues {
invites: { email: string; role: "admin" | "member" }[]
visibility: "team-private" | "org-shared"
suggestChanges: boolean
}
interface Props {
mode: "personal" | "team"
isScale: boolean
inviteDomain: string | null
values: TeamValues
onChange: (next: TeamValues) => void
onContinue: () => void
onSkip?: () => void
onUpgrade: () => void
submitting?: boolean
}
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi
export function StepTeam({
mode,
inviteDomain,
values,
onChange,
onContinue,
onSkip,
submitting,
}: Props) {
const [draft, setDraft] = useState("")
const domainOrFallback = (inviteDomain || "acme.com").trim().toLowerCase()
const addInvites = (text: string) => {
const found = text.match(EMAIL_RE) ?? []
if (found.length === 0) return
const existing = new Set(values.invites.map((i) => i.email.toLowerCase()))
const next: { email: string; role: "admin" | "member" }[] = []
for (const raw of found) {
const email = raw.trim().toLowerCase()
if (!email || existing.has(email)) continue
existing.add(email)
next.push({ email, role: "member" })
}
if (next.length === 0) {
setDraft("")
return
}
onChange({ ...values, invites: [...values.invites, ...next] })
setDraft("")
}
const removeInvite = (email: string) => {
onChange({
...values,
invites: values.invites.filter((i) => i.email !== email),
})
}
const setRole = (email: string, role: "admin" | "member") => {
onChange({
...values,
invites: values.invites.map((i) =>
i.email === email ? { ...i, role } : i,
),
})
}
const domainBreakdown = useMemo(() => {
const counts = new Map<string, number>()
for (const inv of values.invites) {
const at = inv.email.lastIndexOf("@")
if (at < 0) continue
const domain = inv.email.slice(at + 1).toLowerCase()
counts.set(domain, (counts.get(domain) ?? 0) + 1)
}
return [...counts.entries()].sort((a, b) => b[1] - a[1])
}, [values.invites])
if (mode === "personal") {
return (
<div
className="max-w-xl mx-auto rounded-[22px] bg-[#1B1F24] p-10 text-center"
style={modalCardStyle}
>
<div
className="size-12 rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center mx-auto"
style={inputBevelStyle}
>
<Users className="size-5 text-[#737373]" />
</div>
<p
className={cn(
"text-[20px] font-semibold text-[#fafafa] mt-5",
dmSans125ClassName(),
)}
>
Going solo for now
</p>
<p className="text-[14px] text-[#737373] mt-2 leading-[1.5] font-medium">
You're in Personal mode, so there's no team step. Switch to Team in
the top bar anytime to invite others.
</p>
<Button
variant="insideOut"
onClick={onContinue}
className="mt-6 rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
Continue
<ArrowRight className="size-3.5" />
</Button>
</div>
)
}
const count = values.invites.length
return (
<div className="max-w-2xl mx-auto space-y-5">
<section
className="rounded-[22px] bg-[#1B1F24] p-7 md:p-8"
style={modalCardStyle}
>
<div className="flex items-start gap-4">
<div
className="size-12 rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center shrink-0"
style={inputBevelStyle}
>
<Users className="size-5 text-[#fafafa]" />
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
"text-[20px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Invite your team
</p>
<p className="text-[14px] text-[#737373] mt-1 leading-[1.5] font-medium">
A brain gets sharper as more people contribute. You can also do
this later.
</p>
</div>
</div>
<form
onSubmit={(e) => {
e.preventDefault()
addInvites(draft)
}}
className="mt-6 flex gap-2"
>
<div className="relative flex-1">
<Mail className="size-4 absolute left-3.5 top-1/2 -translate-y-1/2 text-[#737373]" />
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onPaste={(e) => {
const pasted = e.clipboardData.getData("text")
if (pasted && EMAIL_RE.test(pasted)) {
e.preventDefault()
addInvites(pasted)
}
}}
placeholder={`alex@${domainOrFallback}, sam@${domainOrFallback}, …`}
className={cn(inputClass, "pl-10")}
style={inputBevelStyle}
/>
</div>
<Button
type="submit"
variant="insideOut"
disabled={!draft.trim()}
className="rounded-full h-12 px-4 text-[13px] font-medium text-[#fafafa]"
>
<Plus className="size-4" />
Add
</Button>
</form>
<p className="text-[11px] text-[#525D6E] font-medium mt-2 pl-2">
Paste multiple emails at once we'll split them for you.
</p>
{count === 0 ? (
<div className="mt-6 rounded-[14px] border border-dashed border-[rgba(82,89,102,0.3)] bg-[#14161A]/40 px-5 py-8 text-center">
<p className="text-[13px] text-[#737373] font-medium">
No invites yet.
</p>
<p className="text-[12px] text-[#525D6E] font-medium mt-1">
Try{" "}
<span className="text-[#A1A1AA]">alex@{domainOrFallback}</span>,{" "}
<span className="text-[#A1A1AA]">sam@{domainOrFallback}</span>,
etc.
</p>
</div>
) : (
<>
<div className="mt-6 flex items-center justify-between gap-3 px-1">
<p className="text-[10px] uppercase tracking-[0.08em] text-[#737373] font-semibold">
{count} invite{count === 1 ? "" : "s"}
</p>
{domainBreakdown.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-[#737373] font-medium">
{domainBreakdown.slice(0, 3).map(([d, n]) => (
<span
key={d}
className="px-2 py-0.5 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)]"
>
{d} · {n}
</span>
))}
</div>
)}
</div>
<div className="mt-3 space-y-1.5 max-h-[280px] overflow-y-auto scrollbar-thin pr-1">
{values.invites.map((inv) => (
<div
key={inv.email}
className="flex items-center gap-3 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-3 py-2"
style={inputBevelStyle}
>
<div className="size-7 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] flex items-center justify-center shrink-0">
<span className="text-[11px] font-semibold text-[#A1A1AA] uppercase">
{(inv.email[0] ?? "?").toUpperCase()}
</span>
</div>
<span className="text-[13px] text-[#fafafa] flex-1 font-medium truncate">
{inv.email}
</span>
<Select
value={inv.role}
onValueChange={(r) =>
setRole(inv.email, r as "admin" | "member")
}
>
<SelectTrigger className="w-24 h-7 bg-transparent border border-[rgba(82,89,102,0.2)] rounded-full text-[#A1A1AA] text-[11px] font-medium px-3 shadow-none focus:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#14161A] border-[rgba(82,89,102,0.2)] rounded-[12px]">
<SelectItem
value="member"
className="text-[#fafafa] focus:bg-[#1B1F24] text-[13px]"
>
Member
</SelectItem>
<SelectItem
value="admin"
className="text-[#fafafa] focus:bg-[#1B1F24] text-[13px]"
>
Admin
</SelectItem>
</SelectContent>
</Select>
<button
type="button"
onClick={() => removeInvite(inv.email)}
className="text-[#737373] hover:text-[#fafafa] p-1 transition-colors"
aria-label={`Remove ${inv.email}`}
>
<Trash2 className="size-4" />
</button>
</div>
))}
</div>
</>
)}
</section>
<div className="flex items-center justify-end gap-[22px]">
<button
type="button"
onClick={onSkip ?? onContinue}
disabled={submitting}
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors disabled:opacity-50"
>
Skip for now
</button>
<Button
variant="insideOut"
onClick={onContinue}
disabled={submitting}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
{submitting ? (
<>
Sending
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
{count > 0
? `Send ${count} invite${count === 1 ? "" : "s"}`
: "Continue"}
<ArrowRight className="size-3.5" />
</>
)}
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,131 @@
export type BrainMode = "personal" | "team"
export type BrainStep = "about" | "sources" | "ingest" | "team"
export const BRAIN_STEPS: BrainStep[] = ["about", "sources", "ingest", "team"]
export const BRAIN_STEP_LABELS: Record<BrainStep, string> = {
about: "About",
sources: "Tools",
ingest: "Flows",
team: "Team",
}
const FREE_EMAIL_DOMAINS = new Set([
"gmail.com",
"googlemail.com",
"yahoo.com",
"yahoo.co.uk",
"yahoo.co.in",
"outlook.com",
"hotmail.com",
"live.com",
"icloud.com",
"me.com",
"mac.com",
"aol.com",
"protonmail.com",
"proton.me",
"pm.me",
"fastmail.com",
"zoho.com",
"yandex.com",
"yandex.ru",
"mail.com",
"qq.com",
"163.com",
"126.com",
"naver.com",
"duck.com",
])
export function detectModeFromEmail(
email: string | undefined | null,
): BrainMode {
if (!email) return "personal"
const at = email.lastIndexOf("@")
if (at < 0) return "personal"
const domain = email
.slice(at + 1)
.toLowerCase()
.trim()
if (!domain) return "personal"
if (FREE_EMAIL_DOMAINS.has(domain)) return "personal"
return "team"
}
export function workspaceNameFromEmail(
email: string | undefined | null,
): string {
if (!email) return ""
const at = email.lastIndexOf("@")
if (at < 0) return ""
const domain = email.slice(at + 1).toLowerCase()
const root = domain.split(".")[0] ?? ""
if (!root) return ""
return root.charAt(0).toUpperCase() + root.slice(1)
}
export function workspaceDomainFromEmail(
email: string | undefined | null,
): string | null {
if (!email) return null
const at = email.lastIndexOf("@")
if (at < 0) return null
const domain = email
.slice(at + 1)
.toLowerCase()
.trim()
return domain || null
}
export type BrainMetadata = {
brainOnboardingVersion?: "v1"
brainOnboardingComplete?: boolean
brainMode?: BrainMode
brainWorkspaceName?: string
brainWorkspaceDomain?: string | null
brainAbout?: string
brainContainerTag?: string
brainSources?: {
drive?: { status: "connected" | "pending" | "skipped" }
gmail?: { status: "requested" | "connected" | "skipped"; range?: string }
notion?: { status: "connected" | "pending" | "skipped" }
granola?: { status: "waitlist" | "skipped" }
}
brainInvites?: { email: string; role: "admin" | "member" }[]
brainPermissions?: {
visibility: "team-private" | "org-shared"
suggestChanges: boolean
}
}
export function generateOrgSlug(name: string): string {
const base =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "org"
return `${base}-${Math.floor(100000 + Math.random() * 900000)}`
}
export function generateUsername(name: string): string {
const base =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/(^_|_$)/g, "") || "user"
return `${base}${Math.floor(100000 + Math.random() * 900000)}`
}
export function containerTagFromWorkspace(
name: string,
mode: BrainMode,
): string {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
if (!slug) return mode === "team" ? "team-brain" : "personal-brain"
return mode === "team" ? `${slug}-brain` : `${slug}-personal`
}

View file

@ -2,7 +2,10 @@
"name": "@repo/web",
"version": "0.1.0",
"private": true,
"portless": { "name": "app.dev.supermemory", "script": "dev:app" },
"portless": {
"name": "app.dev.supermemory",
"script": "dev:app"
},
"scripts": {
"dev": "portless",
"dev:app": "next dev --port ${PORT:-3000}",
@ -73,6 +76,7 @@
"agents": "^0.4.0",
"ai": "^6.0.168",
"autumn-js": "1.2.12",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
@ -117,6 +121,7 @@
"@sentry/cli": "^2.52.0",
"@tailwindcss/postcss": "^4.1.11",
"@total-typescript/tsconfig": "^1.0.4",
"@types/canvas-confetti": "^1.9.0",
"@types/is-hotkey": "^0.1.10",
"@types/node": "^24.0.4",
"@types/react": "^19.2.9",

View file

@ -186,6 +186,7 @@
"agents": "^0.4.0",
"ai": "^6.0.168",
"autumn-js": "1.2.12",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
@ -230,6 +231,7 @@
"@sentry/cli": "^2.52.0",
"@tailwindcss/postcss": "^4.1.11",
"@total-typescript/tsconfig": "^1.0.4",
"@types/canvas-confetti": "^1.9.0",
"@types/is-hotkey": "^0.1.10",
"@types/node": "^24.0.4",
"@types/react": "^19.2.9",
@ -1981,6 +1983,8 @@
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/chrome": ["@types/chrome@0.1.37", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-IJE4ceuDO7lrEuua7Pow47zwNcI8E6qqkowRP7aFPaZ0lrjxh6y836OPqqkIZeTX64FTogbw+4RNH0+QrweCTQ=="],
@ -2479,6 +2483,8 @@
"canvas-color-tracker": ["canvas-color-tracker@1.3.2", "", { "dependencies": { "tinycolor2": "^1.6.0" } }, "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg=="],
"canvas-confetti": ["canvas-confetti@1.9.4", "", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],