mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
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:
parent
7c2bd19cd8
commit
170133cf83
14 changed files with 4587 additions and 1326 deletions
992
apps/web/app/(app)/brain-home/page.tsx
Normal file
992
apps/web/app/(app)/brain-home/page.tsx
Normal 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
|
|
@ -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]",
|
||||
|
|
|
|||
466
apps/web/components/brain-home/data.ts
Normal file
466
apps/web/components/brain-home/data.ts
Normal 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).",
|
||||
},
|
||||
]
|
||||
671
apps/web/components/brain-home/widgets.tsx
Normal file
671
apps/web/components/brain-home/widgets.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
apps/web/components/onboarding-brain/onboarding-confetti.tsx
Normal file
53
apps/web/components/onboarding-brain/onboarding-confetti.tsx
Normal 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
|
||||
}
|
||||
137
apps/web/components/onboarding-brain/shell.tsx
Normal file
137
apps/web/components/onboarding-brain/shell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
473
apps/web/components/onboarding-brain/step-about.tsx
Normal file
473
apps/web/components/onboarding-brain/step-about.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
480
apps/web/components/onboarding-brain/step-ingest.tsx
Normal file
480
apps/web/components/onboarding-brain/step-ingest.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
587
apps/web/components/onboarding-brain/step-sources.tsx
Normal file
587
apps/web/components/onboarding-brain/step-sources.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
331
apps/web/components/onboarding-brain/step-team.tsx
Normal file
331
apps/web/components/onboarding-brain/step-team.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
apps/web/components/onboarding-brain/types.ts
Normal file
131
apps/web/components/onboarding-brain/types.ts
Normal 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`
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
6
bun.lock
6
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue