mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat(web): add Slack connect card to home + space selector polish (#1137)
Adds the 'Add Supermemory to your Slack' card (status + install) on the home dashboard, and removes the border on the space-selector trigger.
This commit is contained in:
parent
a6f7f346e2
commit
f28e974609
13 changed files with 1816 additions and 417 deletions
|
|
@ -16,6 +16,7 @@ import {
|
|||
type SourcesValues,
|
||||
} from "@/components/onboarding-brain/step-sources"
|
||||
import { StepIngest } from "@/components/onboarding-brain/step-ingest"
|
||||
import { useFeatureFlagEnabled } from "posthog-js/react"
|
||||
import {
|
||||
StepTeam,
|
||||
type TeamValues,
|
||||
|
|
@ -68,6 +69,8 @@ export default function BrainOnboardingPage() {
|
|||
[user?.email],
|
||||
)
|
||||
|
||||
// Team (Company Brain) onboarding is gated behind a private-beta flag.
|
||||
const allowTeam = useFeatureFlagEnabled("company-brain-beta") ?? false
|
||||
const [mode, setMode] = useState<BrainMode>(detectedMode)
|
||||
const [about, setAbout] = useState<AboutValues>({
|
||||
name: user?.name ?? "",
|
||||
|
|
@ -168,6 +171,13 @@ export default function BrainOnboardingPage() {
|
|||
return plan === "scale" || plan === "scale_yearly"
|
||||
}, [org])
|
||||
|
||||
// Personal onboarding has no team step — drop it from the flow + stepper.
|
||||
const steps = useMemo<BrainStep[]>(
|
||||
() =>
|
||||
mode === "team" ? BRAIN_STEPS : BRAIN_STEPS.filter((s) => s !== "team"),
|
||||
[mode],
|
||||
)
|
||||
|
||||
const finish = useCallback(async () => {
|
||||
analytics.onboardingCompleted({
|
||||
mode,
|
||||
|
|
@ -189,15 +199,23 @@ export default function BrainOnboardingPage() {
|
|||
}, [router, mode, sources, team, forceCreate])
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
const idx = BRAIN_STEPS.indexOf(step)
|
||||
const idx = steps.indexOf(step)
|
||||
analytics.onboardingStepCompleted({ step, index: idx })
|
||||
const next = BRAIN_STEPS[idx + 1]
|
||||
const next = steps[idx + 1]
|
||||
if (!next) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
setStepAndUrl(next)
|
||||
}, [step, setStepAndUrl, finish])
|
||||
}, [step, steps, setStepAndUrl, finish])
|
||||
|
||||
// If the current step isn't valid for the mode (e.g. switched to personal),
|
||||
// fall back to the last valid step.
|
||||
useEffect(() => {
|
||||
if (!steps.includes(step)) {
|
||||
setStepAndUrl(steps[steps.length - 1] ?? "about")
|
||||
}
|
||||
}, [steps, step, setStepAndUrl])
|
||||
|
||||
const [creatingOrg, setCreatingOrg] = useState(false)
|
||||
const creatingOrgRef = useRef(false)
|
||||
|
|
@ -206,13 +224,14 @@ export default function BrainOnboardingPage() {
|
|||
if (!forceCreate && organizations && organizations.length > 0) return
|
||||
const name = (about.workspaceName || suggestedWorkspaceName).trim()
|
||||
const slug = generateOrgSlug(name)
|
||||
const effectiveMode = allowTeam ? mode : "personal"
|
||||
const metadata: BrainMetadata & { signupSource: string } = {
|
||||
signupSource: "consumer",
|
||||
brainOnboardingVersion: "v1",
|
||||
brainMode: mode,
|
||||
brainMode: effectiveMode,
|
||||
brainWorkspaceName: name,
|
||||
brainWorkspaceDomain:
|
||||
mode === "team" ? about.workspaceDomain || domain : null,
|
||||
effectiveMode === "team" ? about.workspaceDomain || domain : null,
|
||||
brainContainerTag: containerTag,
|
||||
...(about.about.trim() ? { brainAbout: about.about.trim() } : {}),
|
||||
}
|
||||
|
|
@ -247,6 +266,7 @@ export default function BrainOnboardingPage() {
|
|||
about,
|
||||
suggestedWorkspaceName,
|
||||
mode,
|
||||
allowTeam,
|
||||
domain,
|
||||
containerTag,
|
||||
setActiveOrg,
|
||||
|
|
@ -332,6 +352,7 @@ export default function BrainOnboardingPage() {
|
|||
return (
|
||||
<BrainShell
|
||||
step={step}
|
||||
steps={steps}
|
||||
domain={mode === "team" ? about.workspaceDomain || domain : null}
|
||||
>
|
||||
{step === "about" && (
|
||||
|
|
@ -341,6 +362,7 @@ export default function BrainOnboardingPage() {
|
|||
analytics.onboardingModeSelected({ mode: m })
|
||||
setMode(m)
|
||||
}}
|
||||
allowTeam={allowTeam}
|
||||
domain={domain}
|
||||
suggestedWorkspaceName={suggestedWorkspaceName}
|
||||
defaultName={user?.name ?? ""}
|
||||
|
|
@ -361,7 +383,13 @@ export default function BrainOnboardingPage() {
|
|||
onContinue={goNext}
|
||||
/>
|
||||
)}
|
||||
{step === "ingest" && <StepIngest mcpUrl={mcpUrl} onContinue={goNext} />}
|
||||
{step === "ingest" && (
|
||||
<StepIngest
|
||||
mode={allowTeam ? mode : "personal"}
|
||||
mcpUrl={mcpUrl}
|
||||
onContinue={goNext}
|
||||
/>
|
||||
)}
|
||||
{step === "team" && (
|
||||
<StepTeam
|
||||
mode={mode}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import { MobileBottomNav } from "@/components/bottom-nav"
|
|||
import { ChatSidebar, HomeChatComposer } from "@/components/chat"
|
||||
import type { ChatAttachmentDraft } from "@/components/chat/attachments"
|
||||
import { DashboardView } from "@/components/dashboard-view"
|
||||
import { BrainHomeView } from "@/components/brain-home/brain-home-view"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { MemoriesGrid } from "@/components/memories-grid"
|
||||
import { GraphLayoutView } from "@/components/graph-layout-view"
|
||||
import { IntegrationsView, DetailWrapper } from "@/components/integrations-view"
|
||||
|
|
@ -130,6 +132,27 @@ export default function NewPage() {
|
|||
: undefined
|
||||
|
||||
const { viewMode, setViewMode } = useViewMode()
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
|
||||
// Slack OAuth redirects back here with ?slack=connected — toast then clean up.
|
||||
useEffect(() => {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
if (sp.get("slack") !== "connected") return
|
||||
const team = sp.get("team")
|
||||
toast.success(
|
||||
team
|
||||
? `Supermemory added to ${team} on Slack`
|
||||
: "Supermemory added to your Slack",
|
||||
)
|
||||
sp.delete("slack")
|
||||
sp.delete("team")
|
||||
const qs = sp.toString()
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.pathname + (qs ? `?${qs}` : ""),
|
||||
)
|
||||
}, [])
|
||||
const queryClient = useQueryClient()
|
||||
const [highlightsForceAt, setHighlightsForceAt] = useState(0)
|
||||
|
||||
|
|
@ -754,6 +777,10 @@ export default function NewPage() {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
) : isCompanyBrain ? (
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto p-4 pt-2! pb-[180px] md:p-6">
|
||||
<BrainHomeView />
|
||||
</div>
|
||||
) : (
|
||||
<DashboardView
|
||||
spaceLabel={dashboardSpaceLabel}
|
||||
|
|
|
|||
357
apps/web/components/brain-home/brain-home-view.tsx
Normal file
357
apps/web/components/brain-home/brain-home-view.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
"use client"
|
||||
|
||||
import { $fetch } from "@lib/api"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { ArrowRight, Check, FileText, Loader2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { ConnectionsBoard } from "./connections-board"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
const cardStyle = {
|
||||
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",
|
||||
}
|
||||
|
||||
type RecentDoc = {
|
||||
id?: string
|
||||
title?: string | null
|
||||
createdAt?: string | Date | null
|
||||
}
|
||||
|
||||
function useBrainOverview() {
|
||||
const { user, org } = useAuth()
|
||||
const enabled = !!user && !!org?.id
|
||||
|
||||
const docs = useQuery({
|
||||
queryKey: ["brain-recents", org?.id],
|
||||
queryFn: async () => {
|
||||
const res = await $fetch("@post/documents/documents", {
|
||||
body: {
|
||||
page: 1,
|
||||
limit: 6,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
containerTags: [],
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
if (res.error) throw new Error(res.error?.message)
|
||||
return res.data as unknown as {
|
||||
documents?: RecentDoc[]
|
||||
pagination?: { totalItems?: number }
|
||||
}
|
||||
},
|
||||
staleTime: 60_000,
|
||||
enabled,
|
||||
})
|
||||
|
||||
const connectors = useQuery({
|
||||
queryKey: ["brain-home", "connectors"],
|
||||
queryFn: async () => {
|
||||
const res = await $fetch("@post/connections/list", {
|
||||
body: { containerTags: [] },
|
||||
})
|
||||
if (res.error) return [] as Array<{ provider?: string }>
|
||||
return (res.data ?? []) as Array<{ provider?: string }>
|
||||
},
|
||||
staleTime: 30_000,
|
||||
enabled,
|
||||
})
|
||||
|
||||
const brain = useQuery({
|
||||
queryKey: ["brain-connections"],
|
||||
queryFn: async () => {
|
||||
const [c, s] = await Promise.all([
|
||||
fetch(`${BACKEND}/brain/connections`, { credentials: "include" }),
|
||||
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
|
||||
])
|
||||
const toolkits = c.ok
|
||||
? ((await c.json()) as { toolkits: { org: boolean; user: boolean }[] })
|
||||
.toolkits
|
||||
: []
|
||||
const slack = s.ok
|
||||
? ((await s.json()) as { connected: boolean }).connected
|
||||
: false
|
||||
return {
|
||||
activeCount: toolkits.filter((t) => t.org || t.user).length,
|
||||
slack,
|
||||
}
|
||||
},
|
||||
staleTime: 30_000,
|
||||
enabled,
|
||||
})
|
||||
|
||||
const mcp = useQuery({
|
||||
queryKey: ["mcp-status"],
|
||||
queryFn: async () => {
|
||||
const res = await $fetch("@get/mcp/has-login")
|
||||
if (res.error) return false
|
||||
return Boolean((res.data as { previousLogin?: boolean })?.previousLogin)
|
||||
},
|
||||
staleTime: 60_000,
|
||||
enabled,
|
||||
})
|
||||
|
||||
const memoriesCount = docs.data?.pagination?.totalItems ?? 0
|
||||
const connectedCount =
|
||||
(brain.data?.activeCount ?? 0) +
|
||||
(brain.data?.slack ? 1 : 0) +
|
||||
(connectors.data?.length ?? 0)
|
||||
|
||||
return {
|
||||
loading: docs.isPending,
|
||||
recentDocs: docs.data?.documents ?? [],
|
||||
memoriesCount,
|
||||
connectedCount,
|
||||
hasSource: connectedCount > 0,
|
||||
hasAgent: mcp.data ?? false,
|
||||
hasMemory: memoriesCount > 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function BrainHomeView() {
|
||||
const o = useBrainOverview()
|
||||
const stepsDone = [o.hasSource, o.hasAgent, o.hasMemory].filter(
|
||||
Boolean,
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1080px] space-y-6">
|
||||
<StatsRow
|
||||
memories={o.memoriesCount}
|
||||
connected={o.connectedCount}
|
||||
setupDone={stepsDone}
|
||||
/>
|
||||
<ConnectionsBoard />
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_340px]">
|
||||
<RecentMemories docs={o.recentDocs} loading={o.loading} />
|
||||
<GettingStarted
|
||||
hasSource={o.hasSource}
|
||||
hasAgent={o.hasAgent}
|
||||
hasMemory={o.hasMemory}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsRow({
|
||||
memories,
|
||||
connected,
|
||||
setupDone,
|
||||
}: {
|
||||
memories: number
|
||||
connected: number
|
||||
setupDone: number
|
||||
}) {
|
||||
const tiles = [
|
||||
{ label: "Memories", value: memories.toLocaleString() },
|
||||
{ label: "Connected sources", value: String(connected) },
|
||||
{ label: "Setup", value: `${setupDone}/3` },
|
||||
]
|
||||
return (
|
||||
<section
|
||||
className="grid grid-cols-3 divide-x divide-white/[0.04] rounded-[16px] bg-[#1B1F24]"
|
||||
style={cardStyle}
|
||||
>
|
||||
{tiles.map((t) => (
|
||||
<div key={t.label} className="px-5 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#737373]">
|
||||
{t.label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-[22px] font-semibold leading-none tabular-nums text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{t.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentMemories({
|
||||
docs,
|
||||
loading,
|
||||
}: {
|
||||
docs: RecentDoc[]
|
||||
loading: boolean
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-[18px] bg-[#1B1F24] p-5" style={cardStyle}>
|
||||
<p
|
||||
className={cn(
|
||||
"mb-3 text-[15px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Recent memories
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-6 text-[13px] font-medium text-[#737373]">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
) : docs.length === 0 ? (
|
||||
<div className="flex items-center gap-3 rounded-[12px] bg-[#14161A] px-4 py-5">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-[10px] bg-[#0F1217] text-[#525D6E]">
|
||||
<FileText className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-medium text-[#fafafa]">
|
||||
No memories yet
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium leading-[1.5] text-[#737373]">
|
||||
Connect a source or ask your brain below — what you save shows up
|
||||
here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-white/[0.04]">
|
||||
{docs.map((doc, i) => (
|
||||
<li
|
||||
key={doc.id ?? i}
|
||||
className="flex items-center gap-3 px-1 py-2.5"
|
||||
>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-[8px] bg-[#0F1217] text-[#737373]">
|
||||
<FileText className="size-3.5" />
|
||||
</div>
|
||||
<p className="min-w-0 flex-1 truncate text-[13px] font-medium text-[#fafafa]">
|
||||
{doc.title?.trim() || "Untitled memory"}
|
||||
</p>
|
||||
<span className="shrink-0 text-[11px] font-medium text-[#737373]">
|
||||
{formatWhen(doc.createdAt)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function GettingStarted({
|
||||
hasSource,
|
||||
hasAgent,
|
||||
hasMemory,
|
||||
}: {
|
||||
hasSource: boolean
|
||||
hasAgent: boolean
|
||||
hasMemory: boolean
|
||||
}) {
|
||||
const steps = [
|
||||
{
|
||||
done: hasSource,
|
||||
title: "Connect a source",
|
||||
hint: "GitHub, Linear, Drive or Slack.",
|
||||
href: "/settings/integrations",
|
||||
},
|
||||
{
|
||||
done: hasAgent,
|
||||
title: "Install a coding agent",
|
||||
hint: "Claude Code, Codex or Cursor.",
|
||||
href: "/settings/integrations",
|
||||
},
|
||||
{
|
||||
done: hasMemory,
|
||||
title: "Add your first memory",
|
||||
hint: "Save a doc, or ask your brain below.",
|
||||
},
|
||||
]
|
||||
return (
|
||||
<section
|
||||
className="relative h-fit overflow-hidden rounded-[18px] bg-[#1B1F24] p-5"
|
||||
style={cardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px right-8 left-8 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.45), transparent)",
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[15px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Getting started
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
A few steps to make your brain useful.
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 space-y-2.5">
|
||||
{steps.map((step) => (
|
||||
<li key={step.title} className="flex items-start gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border",
|
||||
step.done
|
||||
? "border-[#4BA0FA] bg-[#4BA0FA]"
|
||||
: "border-[rgba(82,89,102,0.4)]",
|
||||
)}
|
||||
>
|
||||
{step.done && <Check className="size-3 text-white" />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[13px] font-medium",
|
||||
step.done
|
||||
? "text-[#737373] line-through"
|
||||
: "text-[#fafafa]",
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
{!step.done && step.href && (
|
||||
<Link
|
||||
href={step.href}
|
||||
className="inline-flex shrink-0 items-center gap-0.5 text-[12px] font-medium text-[#4BA0FA] transition-opacity hover:opacity-80"
|
||||
>
|
||||
Set up
|
||||
<ArrowRight className="size-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{!step.done && (
|
||||
<p className="mt-0.5 text-[12px] font-medium leading-[1.4] text-[#737373]">
|
||||
{step.hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function formatWhen(value?: string | Date | null): string {
|
||||
if (!value) return ""
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return ""
|
||||
const min = Math.round((Date.now() - d.getTime()) / 60000)
|
||||
if (min < 1) return "just now"
|
||||
if (min < 60) return `${min}m`
|
||||
const hr = Math.round(min / 60)
|
||||
if (hr < 24) return `${hr}h`
|
||||
const day = Math.round(hr / 24)
|
||||
if (day < 7) return `${day}d`
|
||||
return d.toLocaleDateString()
|
||||
}
|
||||
393
apps/web/components/brain-home/connections-board.tsx
Normal file
393
apps/web/components/brain-home/connections-board.tsx
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
"use client"
|
||||
|
||||
import { $fetch } from "@lib/api"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { GoogleDrive, Notion } from "@ui/assets/icons"
|
||||
import { Cloud, ExternalLink, Loader2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
const cardStyle = {
|
||||
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 tileStyle = {
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
||||
}
|
||||
|
||||
type ConnRow = { toolkit: string; org: boolean; user: boolean }
|
||||
|
||||
export function ConnectionsBoard() {
|
||||
const [brainRows, setBrainRows] = useState<ConnRow[] | null>(null)
|
||||
const [slack, setSlack] = useState<{
|
||||
connected: boolean
|
||||
teamName: string | null
|
||||
} | null>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
|
||||
const loadBrain = useCallback(async () => {
|
||||
try {
|
||||
const [c, s] = await Promise.all([
|
||||
fetch(`${BACKEND}/brain/connections`, { credentials: "include" }),
|
||||
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
|
||||
])
|
||||
if (c.ok)
|
||||
setBrainRows(((await c.json()) as { toolkits: ConnRow[] }).toolkits)
|
||||
if (s.ok) setSlack(await s.json())
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadBrain()
|
||||
const onFocus = () => void loadBrain()
|
||||
window.addEventListener("focus", onFocus)
|
||||
return () => window.removeEventListener("focus", onFocus)
|
||||
}, [loadBrain])
|
||||
|
||||
const { data: connectors } = useQuery({
|
||||
queryKey: ["brain-home", "connectors"],
|
||||
queryFn: async () => {
|
||||
const res = await $fetch("@post/connections/list", {
|
||||
body: { containerTags: [] },
|
||||
})
|
||||
if (res.error) return [] as Array<{ provider?: string }>
|
||||
return (res.data ?? []) as Array<{ provider?: string }>
|
||||
},
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const connectorConnected = (provider: string) =>
|
||||
Boolean(connectors?.some((c) => c.provider === provider))
|
||||
|
||||
const connectBrain = async (toolkit: string) => {
|
||||
setBusy(`brain:${toolkit}`)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BACKEND}/brain/connections/${toolkit}/link?scope=user`,
|
||||
{ method: "POST", credentials: "include" },
|
||||
)
|
||||
if (!res.ok) {
|
||||
toast.error("Couldn't start the connection.")
|
||||
return
|
||||
}
|
||||
const data = (await res.json()) as { url?: string }
|
||||
if (data.url) window.open(data.url, "_blank", "noopener")
|
||||
else toast.error("Couldn't start the connection.")
|
||||
} catch {
|
||||
toast.error("Couldn't start the connection.")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const connectConnector = async (
|
||||
provider: "google-drive" | "notion" | "onedrive",
|
||||
) => {
|
||||
setBusy(`conn:${provider}`)
|
||||
try {
|
||||
const res = await $fetch("@post/connections/:provider", {
|
||||
params: { provider },
|
||||
body: { redirectUrl: window.location.href, containerTags: [] },
|
||||
})
|
||||
const data = res.data as { authLink?: string } | undefined
|
||||
if (data?.authLink) window.location.href = data.authLink
|
||||
else toast.error("Couldn't start the connection.")
|
||||
} catch {
|
||||
toast.error("Couldn't start the connection.")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const brainConnected = (toolkit: string) =>
|
||||
Boolean(brainRows?.find((r) => r.toolkit === toolkit)?.org) ||
|
||||
Boolean(brainRows?.find((r) => r.toolkit === toolkit)?.user)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{slack && !slack.connected && <SlackBanner />}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Group
|
||||
title="Tool integrations"
|
||||
subtitle="Apps your agents can act on."
|
||||
>
|
||||
<AppCard
|
||||
icon={<GithubMark className="size-5 text-[#fafafa]" />}
|
||||
name="GitHub"
|
||||
subtitle="Repos, pull requests and issues."
|
||||
connected={brainConnected("github")}
|
||||
busy={busy === "brain:github"}
|
||||
onConnect={() => connectBrain("github")}
|
||||
/>
|
||||
<AppCard
|
||||
icon={<LinearMark className="size-5 text-[#5E6AD2]" />}
|
||||
name="Linear"
|
||||
subtitle="Issues, projects and cycles."
|
||||
connected={brainConnected("linear")}
|
||||
busy={busy === "brain:linear"}
|
||||
onConnect={() => connectBrain("linear")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group
|
||||
title="Connectors"
|
||||
subtitle="Sync documents into your brain."
|
||||
cta={
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="inline-flex items-center gap-1 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
>
|
||||
All connectors
|
||||
<ExternalLink className="size-3" aria-hidden />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<AppCard
|
||||
icon={<GoogleDrive className="size-5" />}
|
||||
name="Google Drive"
|
||||
subtitle="Docs, sheets and slides."
|
||||
connected={connectorConnected("google-drive")}
|
||||
busy={busy === "conn:google-drive"}
|
||||
onConnect={() => connectConnector("google-drive")}
|
||||
/>
|
||||
<AppCard
|
||||
icon={<Notion className="size-5" />}
|
||||
name="Notion"
|
||||
subtitle="Pages, databases and blocks."
|
||||
connected={connectorConnected("notion")}
|
||||
busy={busy === "conn:notion"}
|
||||
onConnect={() => connectConnector("notion")}
|
||||
/>
|
||||
<AppCard
|
||||
icon={<Cloud className="size-5 text-[#0F6CBD]" />}
|
||||
name="OneDrive"
|
||||
subtitle="Files from Microsoft 365."
|
||||
connected={connectorConnected("onedrive")}
|
||||
busy={busy === "conn:onedrive"}
|
||||
onConnect={() => connectConnector("onedrive")}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SlackBanner() {
|
||||
return (
|
||||
<section
|
||||
className="relative overflow-hidden rounded-[18px] bg-[#1B1F24] p-5"
|
||||
style={cardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px right-8 left-8 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.45), transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div
|
||||
className="flex size-12 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
|
||||
style={tileStyle}
|
||||
>
|
||||
<SlackMark className="size-7" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[16px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Company Brain in Slack
|
||||
</p>
|
||||
<p className="mt-0.5 text-[13px] font-medium leading-[1.5] text-[#737373]">
|
||||
Install Supermemory so your team can{" "}
|
||||
<span className="text-[#A1A1AA]">@supermemory</span> in any
|
||||
channel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
className="inline-flex shrink-0 items-center gap-2 self-start rounded-lg bg-white px-4 py-2.5 text-[14px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02] sm:self-auto"
|
||||
>
|
||||
<SlackMark className="size-[18px]" />
|
||||
Add to Slack
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Group({
|
||||
title,
|
||||
subtitle,
|
||||
accent,
|
||||
cta,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
subtitle: string
|
||||
accent?: boolean
|
||||
cta?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="relative flex flex-col gap-2.5 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5"
|
||||
style={cardStyle}
|
||||
>
|
||||
{accent && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px right-8 left-8 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.45), transparent)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="mb-1 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[15px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{cta}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AppCard({
|
||||
icon,
|
||||
name,
|
||||
subtitle,
|
||||
connected,
|
||||
busy,
|
||||
onConnect,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
name: string
|
||||
subtitle: string
|
||||
connected: boolean
|
||||
busy: boolean
|
||||
onConnect: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-[12px] bg-[#14161A] p-3">
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-[10px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
|
||||
style={tileStyle}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[14px] font-semibold leading-tight text-[#fafafa]">
|
||||
{name}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[12px] font-medium text-[#737373]">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{connected ? (
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-[12px] font-medium text-[#fafafa]">
|
||||
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
|
||||
Connected
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnect}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex shrink-0 items-center gap-1.5 rounded-full bg-[#0D121A] px-3.5 py-2 text-[13px] font-medium text-[#fafafa] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Connect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GithubMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function LinearMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<title>Linear</title>
|
||||
<path d="M3.084 12.866a8.916 8.916 0 0 0 8.05 8.05.27.27 0 0 0 .222-.46l-7.812-7.812a.27.27 0 0 0-.46.222Zm-.044-1.955a.27.27 0 0 0 .078.21l9.76 9.76c.06.06.142.087.21.078a8.87 8.87 0 0 0 1.273-.218.27.27 0 0 0 .127-.453L3.712 9.51a.27.27 0 0 0-.453.127 8.87 8.87 0 0 0-.218 1.273Zm.69-2.706a.27.27 0 0 0 .06.29l11.715 11.716a.27.27 0 0 0 .29.06 8.96 8.96 0 0 0 .837-.384.27.27 0 0 0 .066-.439L4.553 7.302a.27.27 0 0 0-.44.066 8.96 8.96 0 0 0-.383.837Zm1.11-1.798a.27.27 0 0 1-.017-.366A8.948 8.948 0 0 1 18.07 18.69a.27.27 0 0 1-.366-.017L4.94 6.407Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SlackMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
|
||||
<title>Slack</title>
|
||||
<path
|
||||
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
<path
|
||||
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import {
|
|||
import { StaticGraphPreview } from "@/components/memory-graph/graph-card"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
||||
import { ChromeIcon, RaycastIcon } from "@/components/integration-icons"
|
||||
import { SlackConnectCard } from "@/components/slack-connect-card"
|
||||
import { GoogleDrive, Notion, MCPIcon } from "@ui/assets/icons"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import type { IntegrationParamValue } from "@/lib/search-params"
|
||||
|
|
@ -1331,6 +1332,7 @@ export function DashboardView({
|
|||
)}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4 md:space-y-5">
|
||||
<SlackConnectCard />
|
||||
{headerNotice ? <div className="space-y-2">{headerNotice}</div> : null}
|
||||
|
||||
{/* Header */}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@ import { BRAIN_STEPS, BRAIN_STEP_LABELS, type BrainStep } from "./types"
|
|||
interface ShellProps {
|
||||
step: BrainStep
|
||||
domain?: string | null
|
||||
steps?: BrainStep[]
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function BrainShell({ step, children }: ShellProps) {
|
||||
const visibleSteps: BrainStep[] = BRAIN_STEPS
|
||||
export function BrainShell({ step, steps, children }: ShellProps) {
|
||||
const visibleSteps: BrainStep[] = steps ?? BRAIN_STEPS
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
Building2,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
Mail,
|
||||
Plug,
|
||||
Terminal,
|
||||
User2,
|
||||
|
|
@ -31,6 +32,7 @@ export interface AboutValues {
|
|||
interface Props {
|
||||
mode: BrainMode
|
||||
onModeChange: (m: BrainMode) => void
|
||||
allowTeam: boolean
|
||||
domain: string | null
|
||||
suggestedWorkspaceName: string
|
||||
defaultName: string
|
||||
|
|
@ -58,6 +60,7 @@ const inputClass =
|
|||
export function StepAbout({
|
||||
mode,
|
||||
onModeChange,
|
||||
allowTeam,
|
||||
domain,
|
||||
suggestedWorkspaceName,
|
||||
defaultName,
|
||||
|
|
@ -80,8 +83,11 @@ export function StepAbout({
|
|||
if (Object.keys(patch).length > 0) onChange({ ...values, ...patch })
|
||||
}, [defaultName, suggestedWorkspaceName, domain])
|
||||
|
||||
const teamGated = mode === "team" && !allowTeam
|
||||
const canContinue =
|
||||
values.name.trim().length > 0 && values.workspaceName.trim().length > 0
|
||||
!teamGated &&
|
||||
values.name.trim().length > 0 &&
|
||||
values.workspaceName.trim().length > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
|
|
@ -146,7 +152,9 @@ export function StepAbout({
|
|||
<ModeToggle mode={mode} onChange={onModeChange} />
|
||||
|
||||
<div className="mt-6">
|
||||
{mode === "team" ? (
|
||||
{teamGated ? (
|
||||
<TeamBetaGate onUsePersonal={() => onModeChange("personal")} />
|
||||
) : mode === "team" ? (
|
||||
<TeamWorkspaceCard
|
||||
domain={values.workspaceDomain || domain || ""}
|
||||
onDomainChange={(d) =>
|
||||
|
|
@ -358,6 +366,57 @@ function PersonalWorkspaceCard({
|
|||
)
|
||||
}
|
||||
|
||||
function TeamBetaGate({ onUsePersonal }: { onUsePersonal: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-[14px] bg-[#1B1F24] p-5 md:p-6"
|
||||
style={cardSurfaceStyle}
|
||||
>
|
||||
<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] font-semibold uppercase tracking-[0.12em] text-[#4BA0FA]">
|
||||
Private beta
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-[15px] font-semibold leading-snug text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Team workspaces are invite-only
|
||||
</p>
|
||||
<p className="mt-1.5 text-[13px] leading-relaxed text-[#737373]">
|
||||
We're onboarding teams to Company Brain one at a time. Email us for
|
||||
access — or start with a personal workspace and invite your team later.
|
||||
</p>
|
||||
<div className="mt-4 flex w-full flex-col items-center gap-2.5">
|
||||
<a
|
||||
href="mailto:support@supermemory.com?subject=Company%20Brain%20beta%20access"
|
||||
className="group inline-flex items-center gap-2 rounded-[10px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] px-3.5 py-2 text-[13px] font-medium text-[#fafafa] transition-colors hover:border-[rgba(75,160,250,0.4)]"
|
||||
>
|
||||
<Mail className="size-3.5 text-[#4BA0FA]" />
|
||||
support@supermemory.com
|
||||
<ArrowRight className="size-3.5 text-[#525D6E] transition-colors group-hover:text-[#fafafa]" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUsePersonal}
|
||||
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
>
|
||||
Start with a personal workspace
|
||||
<ArrowRight className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModeToggle({
|
||||
mode,
|
||||
onChange,
|
||||
|
|
|
|||
|
|
@ -1,21 +1,89 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import type { ReactNode } from "react"
|
||||
import Image from "next/image"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { MCPIcon } from "@ui/assets/icons"
|
||||
import { ArrowRight, Check, Copy, EyeOff, Eye } from "lucide-react"
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Plug,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { toast } from "sonner"
|
||||
import { MCPSteps } from "@/components/mcp-modal/mcp-detail-view"
|
||||
import { PLUGIN_CATALOG } from "@/lib/plugin-catalog"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import type { BrainMode } from "./types"
|
||||
|
||||
interface Props {
|
||||
mcpUrl: string
|
||||
onContinue: () => void
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
const TEST_PROMPT = "What do we know about [topic]?"
|
||||
|
||||
type FlowToolId = "slack" | "mcp" | "codex" | "claude-code"
|
||||
type FlowToolKind = "slack" | "mcp" | "plugin"
|
||||
|
||||
type FlowTool = {
|
||||
id: FlowToolId
|
||||
label: string
|
||||
blurb: string
|
||||
kind: FlowToolKind
|
||||
pluginId?: string
|
||||
recommended?: boolean
|
||||
}
|
||||
|
||||
const TOOL_OPTIONS: Record<BrainMode, [FlowTool, ...FlowTool[]]> = {
|
||||
team: [
|
||||
{
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
blurb: "Ask questions in-channel.",
|
||||
kind: "slack",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "claude-code",
|
||||
label: "Claude Code",
|
||||
blurb: "Shared context in your terminal.",
|
||||
kind: "plugin",
|
||||
pluginId: "claude_code",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
blurb: "OpenAI's coding agent.",
|
||||
kind: "plugin",
|
||||
pluginId: "codex",
|
||||
},
|
||||
],
|
||||
personal: [
|
||||
{
|
||||
id: "mcp",
|
||||
label: "MCP",
|
||||
blurb: "Use the universal URL in any client.",
|
||||
kind: "mcp",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
blurb: "OpenAI's coding agent.",
|
||||
kind: "plugin",
|
||||
pluginId: "codex",
|
||||
},
|
||||
{
|
||||
id: "claude-code",
|
||||
label: "Claude Code",
|
||||
blurb: "Context in your terminal.",
|
||||
kind: "plugin",
|
||||
pluginId: "claude_code",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const modalCardStyle = {
|
||||
|
|
@ -28,102 +96,24 @@ const inputBevelStyle = {
|
|||
"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
|
||||
interface Props {
|
||||
mode: BrainMode
|
||||
mcpUrl: string
|
||||
onContinue: () => void
|
||||
}
|
||||
|
||||
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]
|
||||
export function StepIngest({ mode, mcpUrl, onContinue }: Props) {
|
||||
const tools = TOOL_OPTIONS[mode]
|
||||
const [selected, setSelected] = useState<FlowToolId>(tools[0].id)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAgent && !selectedAgent.pluginId) {
|
||||
setMcpClient(selectedAgent.key)
|
||||
} else {
|
||||
setMcpClient(null)
|
||||
}
|
||||
}, [selectedAgent, setMcpClient])
|
||||
setSelected(tools[0].id)
|
||||
}, [tools])
|
||||
|
||||
const selectAgent = (agent: Agent) => {
|
||||
analytics.onboardingAgentSelected({ agent: agent.key })
|
||||
setSelectedKey(agent.key)
|
||||
}
|
||||
const activeTool = useMemo(
|
||||
() => tools.find((t) => t.id === selected) ?? tools[0],
|
||||
[tools, selected],
|
||||
)
|
||||
|
||||
const handleContinue = () => {
|
||||
analytics.onboardingIngestCompleted()
|
||||
|
|
@ -135,79 +125,411 @@ export function StepIngest({ mcpUrl, onContinue }: Props) {
|
|||
onContinue()
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="mx-auto w-full max-w-[900px] pb-10">
|
||||
<section className="relative py-4">
|
||||
<div className="mb-6 px-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[22px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Use your brain where you work
|
||||
</p>
|
||||
<p className="mt-1.5 text-[15px] font-medium leading-[1.4] text-[#737373]">
|
||||
{mode === "team"
|
||||
? "Pick where your team asks questions, then set it up."
|
||||
: "Pick the tool you open every day — about 60 seconds."}
|
||||
</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 className="grid items-start gap-4 lg:grid-cols-[250px_minmax(0,1fr)]">
|
||||
{/* Left rail: pick a tool */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{tools.map((tool) => (
|
||||
<FlowToolRow
|
||||
key={tool.id}
|
||||
tool={tool}
|
||||
active={selected === tool.id}
|
||||
onSelect={() => {
|
||||
analytics.onboardingAgentSelected({ agent: tool.id })
|
||||
setSelected(tool.id)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="mt-1 inline-flex items-center gap-1.5 px-2 py-1.5 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
>
|
||||
More tools
|
||||
<span className="text-[#525D6E]">(full catalog)</span>
|
||||
<ExternalLink className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</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>
|
||||
{/* Right pane: setup detail */}
|
||||
<div
|
||||
className="relative flex min-h-[300px] flex-col overflow-hidden rounded-[22px] bg-[#1B1F24] p-6 md:p-7"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px right-10 left-10 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.4), transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<ToolIcon id={activeTool.id} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[18px] font-semibold text-[#fafafa]">
|
||||
Set up {activeTool.label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
{activeTool.blurb}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTool.kind === "slack" ? (
|
||||
<SlackSetupPanel />
|
||||
) : activeTool.kind === "mcp" ? (
|
||||
<McpGenericSetup mcpUrl={mcpUrl} />
|
||||
) : activeTool.pluginId ? (
|
||||
<PluginSetup
|
||||
key={activeTool.pluginId}
|
||||
pluginId={activeTool.pluginId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-[22px] border-t border-white/[0.06] pt-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="text-[14px] font-medium text-[#737373] transition-colors hover:text-[#999]"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={handleContinue}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolIcon({ id, className }: { id: FlowToolId; className?: string }) {
|
||||
if (id === "slack") return <SlackMark className={className ?? "size-5"} />
|
||||
if (id === "mcp")
|
||||
return <Plug className={cn("text-[#4BA0FA]", className ?? "size-5")} />
|
||||
const file = id === "claude-code" ? "claude" : id
|
||||
return (
|
||||
<Image
|
||||
src={`/mcp-supported-tools/${file}.png`}
|
||||
alt=""
|
||||
width={28}
|
||||
height={28}
|
||||
unoptimized
|
||||
className={cn("object-contain", className ?? "size-5")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FlowToolRow({
|
||||
tool,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
tool: FlowTool
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"group relative flex w-full items-center gap-3 overflow-hidden rounded-[14px] p-3 text-left transition-all duration-150",
|
||||
active
|
||||
? "bg-[#10151D] ring-2 ring-[#4BA0FA]/45"
|
||||
: "bg-[#1B1F24] ring-1 ring-white/[0.05] hover:ring-white/[0.12]",
|
||||
)}
|
||||
style={modalCardStyle}
|
||||
>
|
||||
{active && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px right-5 left-5 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.55), transparent)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-[10px] border bg-[#14161A] transition-colors",
|
||||
active ? "border-[#2261CA]/45" : "border-[rgba(82,89,102,0.2)]",
|
||||
)}
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<ToolIcon id={tool.id} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-[22px] px-1 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={handleContinue}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[14px] font-semibold leading-tight text-[#fafafa]">
|
||||
{tool.label}
|
||||
</p>
|
||||
{tool.recommended && (
|
||||
<span className="shrink-0 rounded-full bg-[#4BA0FA]/12 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[#4BA0FA]">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[12px] font-medium text-[#737373]">
|
||||
{tool.blurb}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"flex size-[18px] shrink-0 items-center justify-center rounded-full border transition-colors",
|
||||
active
|
||||
? "border-[#4BA0FA] bg-[#4BA0FA]"
|
||||
: "border-[rgba(82,89,102,0.4)] group-hover:border-[rgba(115,115,115,0.5)]",
|
||||
)}
|
||||
>
|
||||
{active && <Check className="size-3 text-white" />}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function StepRow({
|
||||
index,
|
||||
title,
|
||||
done,
|
||||
children,
|
||||
}: {
|
||||
index: number
|
||||
title: ReactNode
|
||||
done?: boolean
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"mt-0.5 flex size-[22px] shrink-0 items-center justify-center rounded-full text-[12px] font-semibold transition-colors",
|
||||
done
|
||||
? "bg-[#4BA0FA] text-white"
|
||||
: "border border-[rgba(82,89,102,0.3)] bg-[#14161A] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="size-3" /> : index}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<div className="text-[13px] font-medium leading-[1.5] text-[#fafafa]">
|
||||
{title}
|
||||
</div>
|
||||
{children ? <div className="mt-2.5">{children}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function McpHero({ url }: { url: string }) {
|
||||
// Coding-agent plugins (Codex, Claude Code) auto-login via OAuth, so the
|
||||
// "Save your API key" step is dropped — we render the remaining install steps.
|
||||
function PluginSetup({ pluginId }: { pluginId: string }) {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
const steps = (plugin?.installSteps ?? []).filter(
|
||||
(s) => !s.secret && !s.code?.includes("sm_..."),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{steps.map((step, i) => (
|
||||
<StepRow key={step.title} index={i + 1} title={step.title}>
|
||||
{step.description ? (
|
||||
<p className="mb-2 text-[12px] font-medium leading-[1.5] text-[#737373]">
|
||||
{step.description}
|
||||
</p>
|
||||
) : null}
|
||||
{step.code ? <CopyCodeBlock code={step.code} /> : null}
|
||||
</StepRow>
|
||||
))}
|
||||
<StepRow index={steps.length + 1} title="Ask your brain to test it">
|
||||
<CopyCodeBlock code={TEST_PROMPT} />
|
||||
</StepRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function McpGenericSetup({ mcpUrl }: { mcpUrl: string }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<StepRow index={1} title="Copy your universal MCP URL">
|
||||
<McpUrlRow url={mcpUrl} />
|
||||
</StepRow>
|
||||
<StepRow index={2} title="Paste it into any MCP client">
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
>
|
||||
Per-client setup guides
|
||||
<ExternalLink className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</StepRow>
|
||||
<StepRow index={3} title="Ask your brain to test it">
|
||||
<CopyCodeBlock code={TEST_PROMPT} />
|
||||
</StepRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SlackSetupPanel() {
|
||||
const [status, setStatus] = useState<{
|
||||
connected: boolean
|
||||
teamName: string | null
|
||||
} | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND}/brain/slack/status`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (active && res.ok) {
|
||||
setStatus(
|
||||
(await res.json()) as {
|
||||
connected: boolean
|
||||
teamName: string | null
|
||||
},
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const connected = status?.connected ?? false
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<StepRow
|
||||
index={1}
|
||||
done={connected}
|
||||
title={
|
||||
connected
|
||||
? `Connected to ${status?.teamName ?? "your workspace"}`
|
||||
: "Add Supermemory to your Slack workspace"
|
||||
}
|
||||
>
|
||||
{!connected &&
|
||||
(loading ? (
|
||||
<span className="inline-flex items-center gap-2 text-[12px] font-medium text-[#737373]">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking…
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="insideOut"
|
||||
asChild
|
||||
className="h-9 gap-2 rounded-full px-4 text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
<a href={`${BACKEND}/brain/slack/oauth/install`}>
|
||||
<SlackMark className="size-4" />
|
||||
Add to Slack
|
||||
</a>
|
||||
</Button>
|
||||
))}
|
||||
</StepRow>
|
||||
<StepRow
|
||||
index={2}
|
||||
title={
|
||||
<>
|
||||
Mention <span className="text-[#4BA0FA]">@supermemory</span> in{" "}
|
||||
<span className="font-mono text-[12px]">#general</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<StepRow index={3} title="Ask your brain to test it">
|
||||
<CopyCodeBlock code="@supermemory what do we know about [topic]?" />
|
||||
</StepRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyCodeBlock({ code }: { code: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
setCopied(true)
|
||||
toast.success("Copied")
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
toast.error("Could not copy")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-start gap-2 rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#0F1217] p-3"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<pre className="min-w-0 flex-1 overflow-x-auto whitespace-pre font-mono text-[11px] text-[#fafafa]">
|
||||
{code}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
aria-label="Copy"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function McpUrlRow({ url }: { url: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
|
|
@ -220,36 +542,22 @@ function McpHero({ url }: { url: string }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-[16px] bg-[#1B1F24] px-5 py-3 flex items-center gap-3 relative overflow-hidden"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<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"
|
||||
className="min-w-0 flex-1 rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#0F1217] px-4 py-3"
|
||||
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">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[#525D6E]">
|
||||
Universal MCP URL
|
||||
</p>
|
||||
<p className="text-[13px] text-[#fafafa] font-mono truncate mt-0.5">
|
||||
<p className="mt-0.5 truncate font-mono text-[13px] text-[#fafafa]">
|
||||
{url}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={copy}
|
||||
className="rounded-full h-9 px-4 text-[12px] font-medium text-[#fafafa] shrink-0"
|
||||
className="h-9 shrink-0 rounded-full px-4 text-[12px] font-medium text-[#fafafa]"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5" />
|
||||
|
|
@ -258,235 +566,46 @@ function McpHero({ url }: { url: string }) {
|
|||
)}
|
||||
{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")
|
||||
}
|
||||
}
|
||||
function SlackMark({ className }: { className?: string }) {
|
||||
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>
|
||||
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
|
||||
<title>Slack</title>
|
||||
<path
|
||||
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
<path
|
||||
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
297
apps/web/components/settings/company-brain-connections.tsx
Normal file
297
apps/web/components/settings/company-brain-connections.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
"use client"
|
||||
|
||||
import { authClient } from "@lib/auth"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Check, Loader2, Lock } from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { PillButton } from "../integrations/install-steps"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
type ConnRow = { toolkit: string; org: boolean; user: boolean }
|
||||
|
||||
function GithubMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function LinearMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<title>Linear</title>
|
||||
<path d="M3.084 12.866a8.916 8.916 0 0 0 8.05 8.05.27.27 0 0 0 .222-.46l-7.812-7.812a.27.27 0 0 0-.46.222Zm-.044-1.955a.27.27 0 0 0 .078.21l9.76 9.76c.06.06.142.087.21.078a8.87 8.87 0 0 0 1.273-.218.27.27 0 0 0 .127-.453L3.712 9.51a.27.27 0 0 0-.453.127 8.87 8.87 0 0 0-.218 1.273Zm.69-2.706a.27.27 0 0 0 .06.29l11.715 11.716a.27.27 0 0 0 .29.06 8.96 8.96 0 0 0 .837-.384.27.27 0 0 0 .066-.439L4.553 7.302a.27.27 0 0 0-.44.066 8.96 8.96 0 0 0-.383.837Zm1.11-1.798a.27.27 0 0 1-.017-.366A8.948 8.948 0 0 1 18.07 18.69a.27.27 0 0 1-.366-.017L4.94 6.407Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const TOOLKITS: Record<
|
||||
string,
|
||||
{ label: string; subtitle: string; icon: React.ReactNode }
|
||||
> = {
|
||||
github: {
|
||||
label: "GitHub",
|
||||
subtitle: "Repos, pull requests and issues",
|
||||
icon: <GithubMark className="size-5 text-[#FAFAFA]" />,
|
||||
},
|
||||
linear: {
|
||||
label: "Linear",
|
||||
subtitle: "Issues, projects and cycles",
|
||||
icon: <LinearMark className="size-5 text-[#5E6AD2]" />,
|
||||
},
|
||||
}
|
||||
|
||||
function StatusDot({ connected }: { connected: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex items-center gap-1.5 text-[13px] font-medium",
|
||||
connected ? "text-[#FAFAFA]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-[7px] shrink-0 rounded-full",
|
||||
connected ? "bg-[#00AC3F]" : "bg-[#3A4150]",
|
||||
)}
|
||||
/>
|
||||
{connected ? "Connected" : "Not connected"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function AppCard({
|
||||
toolkit,
|
||||
connected,
|
||||
canConnect,
|
||||
lockedHint,
|
||||
busy,
|
||||
onConnect,
|
||||
}: {
|
||||
toolkit: string
|
||||
connected: boolean
|
||||
canConnect: boolean
|
||||
lockedHint?: string
|
||||
busy: boolean
|
||||
onConnect: () => void
|
||||
}) {
|
||||
const meta = TOOLKITS[toolkit] ?? {
|
||||
label: toolkit,
|
||||
subtitle: "",
|
||||
icon: null,
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-[14px] bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] sm:p-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||
{meta.icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[15px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{meta.label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[12px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{meta.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<StatusDot connected={connected} />
|
||||
{!connected &&
|
||||
(canConnect ? (
|
||||
<PillButton onClick={onConnect} disabled={busy}>
|
||||
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Connect
|
||||
</PillButton>
|
||||
) : lockedHint ? (
|
||||
<span className="flex items-center gap-1 text-[12px] font-medium text-[#737373]">
|
||||
<Lock className="size-3" />
|
||||
{lockedHint}
|
||||
</span>
|
||||
) : null)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="px-1">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[15px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-0.5 text-[13px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-[14px] bg-[#14161A] p-5 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="size-10 animate-pulse rounded-[10px] bg-[#1c1f24]" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 w-24 animate-pulse rounded bg-[#1c1f24]" />
|
||||
<div className="h-3 w-40 animate-pulse rounded bg-[#1c1f24]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CompanyBrainConnections() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const [rows, setRows] = useState<ConnRow[] | null>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
|
||||
const roleQuery = useQuery({
|
||||
queryKey: ["company-brain-connections", "role"],
|
||||
queryFn: async () =>
|
||||
(await authClient.organization.getActiveMember()).data?.role ?? null,
|
||||
staleTime: 60_000,
|
||||
enabled: isCompanyBrain,
|
||||
})
|
||||
const role = (roleQuery.data ?? "").toLowerCase()
|
||||
const isAdmin = role === "owner" || role === "admin"
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const res = await fetch(`${BACKEND}/brain/connections`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (res.ok)
|
||||
setRows(((await res.json()) as { toolkits: ConnRow[] }).toolkits)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCompanyBrain) return
|
||||
void load()
|
||||
const onFocus = () => void load()
|
||||
window.addEventListener("focus", onFocus)
|
||||
return () => window.removeEventListener("focus", onFocus)
|
||||
}, [isCompanyBrain, load])
|
||||
|
||||
const connect = async (toolkit: string, scope: "user" | "org") => {
|
||||
setBusy(`${toolkit}:${scope}`)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BACKEND}/brain/connections/${toolkit}/link?scope=${scope}`,
|
||||
{ method: "POST", credentials: "include" },
|
||||
)
|
||||
if (res.status === 403) {
|
||||
toast.error("Only admins can connect the shared org account.")
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
toast.error("Couldn't start the connection.")
|
||||
return
|
||||
}
|
||||
const data = (await res.json()) as { url?: string; error?: string }
|
||||
if (data.url) window.open(data.url, "_blank", "noopener")
|
||||
else toast.error(data.error ?? "Couldn't start the connection.")
|
||||
} catch {
|
||||
toast.error("Couldn't start the connection.")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCompanyBrain) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Company Brain isn't enabled for this organization.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const loading = rows === null
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<Section
|
||||
title="Organization (shared)"
|
||||
description="Connected by admins. Used for reads when you haven't connected your own."
|
||||
>
|
||||
{loading ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<AppCard
|
||||
key={`org-${row.toolkit}`}
|
||||
toolkit={row.toolkit}
|
||||
connected={row.org}
|
||||
canConnect={isAdmin}
|
||||
lockedHint="Admin only"
|
||||
busy={busy === `${row.toolkit}:org`}
|
||||
onConnect={() => connect(row.toolkit, "org")}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Your connections"
|
||||
description="Your personal accounts — used for your actions and your reads."
|
||||
>
|
||||
{loading ? (
|
||||
<CardSkeleton />
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<AppCard
|
||||
key={`user-${row.toolkit}`}
|
||||
toolkit={row.toolkit}
|
||||
connected={row.user}
|
||||
canConnect
|
||||
busy={busy === `${row.toolkit}:user`}
|
||||
onConnect={() => connect(row.toolkit, "user")}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import Account from "@/components/settings/account"
|
|||
import Billing from "@/components/settings/billing"
|
||||
import Integrations from "@/components/settings/integrations"
|
||||
import ConnectionsMCP from "@/components/settings/connections-mcp"
|
||||
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
|
||||
import Support from "@/components/settings/support"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
|
@ -44,6 +45,7 @@ export const TABS = [
|
|||
"billing",
|
||||
"integrations",
|
||||
"connections",
|
||||
"company-brain",
|
||||
"support",
|
||||
] as const
|
||||
export type SettingsTab = (typeof TABS)[number]
|
||||
|
|
@ -80,6 +82,12 @@ const NAV_ITEMS: NavItem[] = [
|
|||
description: "Drive, Notion, OneDrive, MCP",
|
||||
icon: <Zap className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "company-brain",
|
||||
label: "Company Brain",
|
||||
description: "GitHub & Linear — org and personal",
|
||||
icon: <Building2 className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
label: "Support & Help",
|
||||
|
|
@ -421,6 +429,7 @@ export function SettingsContent({
|
|||
{activeTab === "billing" && <Billing />}
|
||||
{activeTab === "integrations" && <Integrations />}
|
||||
{activeTab === "connections" && <ConnectionsMCP />}
|
||||
{activeTab === "company-brain" && <CompanyBrainConnections />}
|
||||
{activeTab === "support" && <Support />}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
|
|
|
|||
106
apps/web/components/slack-connect-card.tsx
Normal file
106
apps/web/components/slack-connect-card.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
type SlackStatus = { connected: boolean; teamName: string | null }
|
||||
|
||||
function SlackMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
|
||||
<title>Slack</title>
|
||||
<path
|
||||
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
<path
|
||||
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SlackConnectCard() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const [status, setStatus] = useState<SlackStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCompanyBrain) return
|
||||
let active = true
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND}/brain/slack/status`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (active && res.ok) setStatus((await res.json()) as SlackStatus)
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [isCompanyBrain])
|
||||
|
||||
if (!isCompanyBrain || loading) return null
|
||||
|
||||
const connected = status?.connected
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-[14px] bg-[#191D24] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] sm:px-5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-fg-primary">
|
||||
Add Supermemory to your Slack
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[12px] text-fg-muted">
|
||||
{connected
|
||||
? `Connected to ${status?.teamName ?? "your workspace"}.`
|
||||
: "Answer from your company brain and act on connected apps — right inside Slack."}
|
||||
</p>
|
||||
</div>
|
||||
{connected ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-surface-skeleton px-3 py-1.5 text-[12px] font-medium text-fg-muted ring-1 ring-surface-border">
|
||||
<span className="size-1.5 rounded-full bg-[#2EB67D]" />
|
||||
Connected
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={`${BACKEND}/brain/slack/oauth/install`}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-white px-3.5 py-2 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
<SlackMark className="size-4" />
|
||||
Add to Slack
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -61,8 +61,8 @@ export interface SpaceSelectorProps {
|
|||
|
||||
const triggerVariants = {
|
||||
default:
|
||||
"h-10 min-h-10 shrink-0 rounded-full border border-[#161F2C] bg-muted px-3 gap-2 " +
|
||||
"hover:bg-white/5 hover:border-[#2261CA33] " +
|
||||
"h-10 min-h-10 shrink-0 rounded-full bg-muted px-3 gap-2 " +
|
||||
"hover:bg-white/5 " +
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2261CA33]/35",
|
||||
insideOut:
|
||||
"h-10 min-h-10 gap-2 px-3 rounded-full bg-[#0D121A] shadow-inside-out hover:bg-[#121820]",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import posthog from "posthog-js"
|
||||
import { PostHogProvider as PHProvider } from "posthog-js/react"
|
||||
import { Suspense, useEffect } from "react"
|
||||
import { useSession } from "./auth"
|
||||
|
||||
|
|
@ -65,12 +66,12 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
|||
}, [session?.user])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PHProvider client={posthog}>
|
||||
<Suspense fallback={null}>
|
||||
{process.env.NODE_ENV === "production" && <PostHogPageTracking />}
|
||||
</Suspense>
|
||||
{children}
|
||||
</>
|
||||
</PHProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue