mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
feat(web): brain connections board + MCP connect from web (#1181)
Connections board on brain home: featured app tiles with OAuth/static connect against brain/mcp-connections, agent preview prompts, connector icon set, and reworked company-brain connections in settings. --- **Session Details** - Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/e37671c7-3893-48d6-ad66-73200826c04d) - Requested by: Unknown - Address comments on this PR. Add `(aside)` to your comment to have me ignore it.
This commit is contained in:
parent
42f3aec885
commit
d4c451c3ce
6 changed files with 667 additions and 463 deletions
119
apps/web/components/brain-connector-icons.tsx
Normal file
119
apps/web/components/brain-connector-icons.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { cn } from "@lib/utils"
|
||||
import { Granola, Notion } from "@ui/assets/icons"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
|
||||
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 SentryMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<title>Sentry</title>
|
||||
<path d="M13.91 2.505c-.873-1.448-2.972-1.448-3.844 0L6.904 7.92a15.478 15.478 0 0 1 8.53 12.811h-2.221A13.301 13.301 0 0 0 5.784 9.814l-2.926 5.06a7.65 7.65 0 0 1 4.435 5.848H2.194a.365.365 0 0 1-.298-.534l1.413-2.402a5.16 5.16 0 0 0-1.614-.913L.296 19.275a2.182 2.182 0 0 0 .812 2.999 2.24 2.24 0 0 0 1.086.288h6.983a9.322 9.322 0 0 0-3.845-8.318l1.11-1.922a11.47 11.47 0 0 1 4.95 10.24h5.915a17.242 17.242 0 0 0-7.885-15.28l2.244-3.845a.37.37 0 0 1 .504-.13c.255.14 9.75 16.708 9.928 16.9a.365.365 0 0 1-.327.543h-2.287c.029.612.029 1.223 0 1.831h2.297a2.206 2.206 0 0 0 1.922-3.31z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function PostHogMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<title>PostHog</title>
|
||||
<path d="M9.854 14.5 5 9.647.854 5.5A.5.5 0 0 0 0 5.854V8.44a.5.5 0 0 0 .146.353L5 13.647l.147.146L9.854 18.5l.146.147v-.049c.065.03.134.049.207.049h2.586a.5.5 0 0 0 .353-.854L9.854 14.5zm0-5-4-4a.487.487 0 0 0-.409-.144.515.515 0 0 0-.356.21.493.493 0 0 0-.089.288V8.44a.5.5 0 0 0 .147.353l9 9a.5.5 0 0 0 .853-.354v-2.585a.5.5 0 0 0-.146-.354l-5-5zm1-4a.5.5 0 0 0-.854.354V8.44a.5.5 0 0 0 .147.353l4 4a.5.5 0 0 0 .853-.354V9.854a.5.5 0 0 0-.146-.354l-4-4zm12.647 11.515a3.863 3.863 0 0 1-2.232-1.1l-4.708-4.707a.5.5 0 0 0-.854.354v6.585a.5.5 0 0 0 .5.5H23.5a.5.5 0 0 0 .5-.5v-.6c0-.276-.225-.497-.499-.532zm-5.394.032a.8.8 0 1 1 0-1.6.8.8 0 0 1 0 1.6zM.854 15.5a.5.5 0 0 0-.854.354v2.293a.5.5 0 0 0 .5.5h2.293c.222 0 .39-.135.462-.309a.493.493 0 0 0-.109-.545L.854 15.501zM5 14.647.854 10.5a.5.5 0 0 0-.854.353v2.586a.5.5 0 0 0 .146.353L4.854 18.5l.146.147h2.793a.5.5 0 0 0 .353-.854L5 14.647z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function LetterMark({ label, color }: { label: string; color: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex size-full items-center justify-center rounded-[8px] text-[15px] font-bold text-white",
|
||||
)}
|
||||
style={{ background: color }}
|
||||
>
|
||||
{label.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Real brand logos where we have them, a branded lettermark otherwise.
|
||||
export function brainConnectorIcon(
|
||||
slug: string,
|
||||
name: string,
|
||||
className = "size-[18px]",
|
||||
): React.ReactNode {
|
||||
switch (slug) {
|
||||
case "github":
|
||||
return <GithubMark className={cn(className, "text-[#FAFAFA]")} />
|
||||
case "linear":
|
||||
return <LinearMark className={cn(className, "text-[#5E6AD2]")} />
|
||||
case "slack":
|
||||
return <SlackMark className={className} />
|
||||
case "notion":
|
||||
return <Notion className={className} />
|
||||
case "granola":
|
||||
return <Granola className={className} />
|
||||
case "sentry":
|
||||
return <SentryMark className={cn(className, "text-[#FAFAFA]")} />
|
||||
case "posthog":
|
||||
return <PostHogMark className={cn(className, "text-[#F1A82C]")} />
|
||||
default:
|
||||
return <LetterMark label={name} color="#1E293B" />
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,21 @@
|
|||
"use client"
|
||||
|
||||
import { $fetch } from "@lib/api"
|
||||
import { cn } from "@lib/utils"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
|
||||
import { ExternalLink, Loader2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { ArrowRight, Loader2 } from "lucide-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useSettingsModal } from "@/components/settings/settings-modal"
|
||||
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
|
||||
|
||||
// Apps surfaced on the dashboard; the rest live behind "More" in settings.
|
||||
const FEATURED_SLUGS = ["linear", "granola", "sentry"] as const
|
||||
// Example prompts on the right card — can include apps not shown on the left.
|
||||
const PREVIEW_PROMPT_SLUGS = ["linear", "granola", "github", "sentry"] as const
|
||||
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const MCP_BASE = `${BACKEND}/brain/mcp-connections`
|
||||
|
||||
const cardStyle = {
|
||||
boxShadow:
|
||||
|
|
@ -22,64 +26,128 @@ const tileStyle = {
|
|||
"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 }
|
||||
type AuthType = "oauth" | "static" | "none"
|
||||
type CatalogEntry = {
|
||||
slug: string
|
||||
name: string
|
||||
category: string
|
||||
authType: AuthType
|
||||
tokenHint?: string
|
||||
}
|
||||
type ConnRow = {
|
||||
serverSlug: string
|
||||
status: "active" | "pending" | "error"
|
||||
userId: string | null
|
||||
}
|
||||
|
||||
// Example asks that connecting each app unlocks for the Slack agent.
|
||||
const AGENT_PROMPTS: Record<string, string> = {
|
||||
linear: "What's blocking the sprint?",
|
||||
github: "Summarize the open PRs on auth",
|
||||
sentry: "Any new errors since the deploy?",
|
||||
notion: "Find our launch checklist",
|
||||
posthog: "How's activation trending this week?",
|
||||
plain: "What are customers asking about?",
|
||||
granola: "Recap yesterday's standup",
|
||||
}
|
||||
|
||||
function titleCase(s: string) {
|
||||
return s.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function ConnectionsBoard() {
|
||||
const [brainRows, setBrainRows] = useState<ConnRow[] | null>(null)
|
||||
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
|
||||
const [rows, setRows] = useState<ConnRow[]>([])
|
||||
const [slack, setSlack] = useState<{
|
||||
connected: boolean
|
||||
teamName: string | null
|
||||
} | null>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
|
||||
const loadBrain = useCallback(async () => {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [c, s] = await Promise.all([
|
||||
fetch(`${BACKEND}/brain/connections`, { credentials: "include" }),
|
||||
const [cat, conn, s] = await Promise.all([
|
||||
fetch(`${MCP_BASE}/catalog`, { credentials: "include" }),
|
||||
fetch(`${MCP_BASE}/`, { credentials: "include" }),
|
||||
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
|
||||
])
|
||||
if (c.ok)
|
||||
setBrainRows(((await c.json()) as { toolkits: ConnRow[] }).toolkits)
|
||||
if (s.ok) setSlack(await s.json())
|
||||
} catch {}
|
||||
// Parse each response independently so one bad payload can't strand the others.
|
||||
try {
|
||||
if (cat.ok) {
|
||||
const data: { catalog?: CatalogEntry[] } = await cat.json()
|
||||
setCatalog(data.catalog ?? [])
|
||||
} else setCatalog([])
|
||||
} catch {
|
||||
setCatalog([])
|
||||
}
|
||||
try {
|
||||
if (conn.ok) {
|
||||
const data: { connections?: ConnRow[] } = await conn.json()
|
||||
setRows(data.connections ?? [])
|
||||
} else setRows([])
|
||||
} catch {
|
||||
setRows([])
|
||||
}
|
||||
try {
|
||||
if (s.ok) setSlack(await s.json())
|
||||
} catch {}
|
||||
} catch {
|
||||
setCatalog([])
|
||||
setRows([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadBrain()
|
||||
const onFocus = () => void loadBrain()
|
||||
void load()
|
||||
const onFocus = () => void load()
|
||||
window.addEventListener("focus", onFocus)
|
||||
return () => window.removeEventListener("focus", onFocus)
|
||||
}, [loadBrain])
|
||||
}, [load])
|
||||
|
||||
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 isConnected = (slug: string) =>
|
||||
rows.some((r) => r.serverSlug === slug && r.status === "active")
|
||||
|
||||
const connectorConnected = (provider: string) =>
|
||||
Boolean(connectors?.some((c) => c.provider === provider))
|
||||
|
||||
const connectBrain = async (toolkit: string) => {
|
||||
setBusy(`brain:${toolkit}`)
|
||||
const connect = async (entry: CatalogEntry) => {
|
||||
setBusy(entry.slug)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BACKEND}/brain/connections/${toolkit}/link?scope=user`,
|
||||
{ method: "POST", credentials: "include" },
|
||||
)
|
||||
if (entry.authType === "static") {
|
||||
const token = window.prompt(
|
||||
`Paste a token for ${entry.name}.${entry.tokenHint ? `\n${entry.tokenHint}` : ""}`,
|
||||
)
|
||||
if (!token) return
|
||||
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect-static`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ token, shared: false }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast.error("Couldn't connect.")
|
||||
return
|
||||
}
|
||||
toast.success(`${entry.name} connected.`)
|
||||
await load()
|
||||
return
|
||||
}
|
||||
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
shared: false,
|
||||
redirectUrl: window.location.href,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast.error("Couldn't start the connection.")
|
||||
return
|
||||
}
|
||||
const data = (await res.json()) as { url?: string }
|
||||
if (data.url) window.open(data.url, "_blank", "noopener")
|
||||
else toast.error("Couldn't start the connection.")
|
||||
const data: { authUrl?: string; ok?: boolean } = await res.json()
|
||||
if (data.authUrl) window.open(data.authUrl, "_blank", "noopener")
|
||||
else if (data.ok) {
|
||||
toast.success(`${entry.name} connected.`)
|
||||
await load()
|
||||
} else toast.error("Couldn't start the connection.")
|
||||
} catch {
|
||||
toast.error("Couldn't start the connection.")
|
||||
} finally {
|
||||
|
|
@ -87,99 +155,259 @@ export function ConnectionsBoard() {
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
const { openSettings } = useSettingsModal()
|
||||
const apps = catalog ?? []
|
||||
const loading = catalog === null
|
||||
const featured = FEATURED_SLUGS.map((slug) =>
|
||||
apps.find((a) => a.slug === slug),
|
||||
).filter((a): a is CatalogEntry => Boolean(a))
|
||||
const previewApps = PREVIEW_PROMPT_SLUGS.map((slug) =>
|
||||
apps.find((a) => a.slug === slug),
|
||||
).filter((a): a is CatalogEntry => Boolean(a))
|
||||
const remainingCount = Math.max(apps.length - featured.length, 0)
|
||||
const connectedCount = apps.filter((a) => isConnected(a.slug)).length
|
||||
|
||||
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."
|
||||
<div className="grid items-start gap-4 lg:grid-cols-5">
|
||||
<section
|
||||
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5 lg:col-span-3"
|
||||
style={cardStyle}
|
||||
>
|
||||
<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]"
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[15px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
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={<OneDrive className="size-5" />}
|
||||
name="OneDrive"
|
||||
subtitle="Files from Microsoft 365."
|
||||
connected={connectorConnected("onedrive")}
|
||||
busy={busy === "conn:onedrive"}
|
||||
onConnect={() => connectConnector("onedrive")}
|
||||
/>
|
||||
</Group>
|
||||
Connect your tools
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
Give your Slack agent live access to the apps your team already
|
||||
uses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TileSkeleton key={i} showDivider={i < 2} />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{featured.map((entry, i) => (
|
||||
<AppTile
|
||||
key={entry.slug}
|
||||
icon={brainConnectorIcon(entry.slug, entry.name, "size-5")}
|
||||
name={entry.name}
|
||||
subtitle={titleCase(entry.category)}
|
||||
connected={isConnected(entry.slug)}
|
||||
busy={busy === entry.slug}
|
||||
onConnect={() => connect(entry)}
|
||||
showDivider={i < featured.length - 1 || remainingCount > 0}
|
||||
/>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<MoreTile
|
||||
count={remainingCount}
|
||||
onClick={() => openSettings("company-brain")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AgentPreview
|
||||
apps={previewApps}
|
||||
isConnected={isConnected}
|
||||
connectedCount={connectedCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentPreview({
|
||||
apps,
|
||||
isConnected,
|
||||
connectedCount,
|
||||
}: {
|
||||
apps: CatalogEntry[]
|
||||
isConnected: (slug: string) => boolean
|
||||
connectedCount: number
|
||||
}) {
|
||||
const prompts = apps
|
||||
.filter((a) => AGENT_PROMPTS[a.slug])
|
||||
.slice(0, 6)
|
||||
.map((a) => ({
|
||||
slug: a.slug,
|
||||
name: a.name,
|
||||
prompt: AGENT_PROMPTS[a.slug],
|
||||
connected: isConnected(a.slug),
|
||||
}))
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5 lg:col-span-2"
|
||||
style={cardStyle}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SlackMark className="size-4" />
|
||||
<p
|
||||
className={cn(
|
||||
"text-[15px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Ask in Slack
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[12px] font-medium leading-[1.5] text-[#737373]">
|
||||
{connectedCount > 0
|
||||
? "Things your agent can answer now:"
|
||||
: "Connect a tool and your agent can answer:"}
|
||||
</p>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
|
||||
{prompts.map((p, i) => (
|
||||
<div
|
||||
key={p.slug}
|
||||
className={cn(
|
||||
"grid min-h-11 grid-cols-[28px_1fr] items-center gap-x-3 px-3 py-2.5 transition-opacity",
|
||||
i < prompts.length - 1 && "border-b border-[#1B1F24]",
|
||||
p.connected ? "opacity-100" : "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex size-7 items-center justify-center overflow-hidden rounded-[8px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
|
||||
style={tileStyle}
|
||||
>
|
||||
{brainConnectorIcon(p.slug, p.name, "size-4")}
|
||||
</div>
|
||||
<p className="text-[12.5px] font-medium leading-snug text-[#d4d4d8]">
|
||||
"{p.prompt}"
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function AppTile({
|
||||
icon,
|
||||
name,
|
||||
subtitle,
|
||||
connected,
|
||||
busy,
|
||||
onConnect,
|
||||
showDivider = false,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
name: string
|
||||
subtitle: string
|
||||
connected: boolean
|
||||
busy: boolean
|
||||
onConnect: () => void
|
||||
showDivider?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[52px] items-center gap-3 px-3 py-2.5",
|
||||
showDivider && "border-b border-[#1B1F24]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex size-9 shrink-0 items-center justify-center overflow-hidden 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-[13px] font-semibold leading-none text-[#fafafa]">
|
||||
{name}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[11px] font-medium leading-none text-[#737373]">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{connected ? (
|
||||
<span className="flex w-[88px] shrink-0 items-center justify-end 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 w-[88px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 py-1.5 text-[12px] 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 MoreTile({ count, onClick }: { count: number; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex min-h-[52px] w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-[#171A1F]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-[10px] border border-dashed border-[#2A3140] bg-[#080B0F] text-[11px] font-semibold text-[#737373]"
|
||||
style={tileStyle}
|
||||
>
|
||||
+{count}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-semibold leading-none text-[#fafafa]">
|
||||
{count} more {count === 1 ? "app" : "apps"}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[11px] font-medium leading-none text-[#737373]">
|
||||
Notion, PostHog, Plain and more
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-[88px] shrink-0 justify-end">
|
||||
<ArrowRight className="size-4 text-[#52525B]" />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[52px] items-center gap-3 px-3 py-2.5",
|
||||
showDivider && "border-b border-[#1B1F24]",
|
||||
)}
|
||||
>
|
||||
<div className="size-9 animate-pulse rounded-[10px] bg-[#1c1f24]" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 w-20 animate-pulse rounded bg-[#1c1f24]" />
|
||||
<div className="h-2.5 w-28 animate-pulse rounded bg-[#1c1f24]" />
|
||||
</div>
|
||||
<div className="h-7 w-[88px] animate-pulse rounded-full bg-[#1c1f24]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SlackBanner() {
|
||||
return (
|
||||
<section
|
||||
|
|
@ -230,164 +458,3 @@ function SlackBanner() {
|
|||
</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 min-w-0 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,34 @@ import { useCallback, useEffect, useState } from "react"
|
|||
import { toast } from "sonner"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
||||
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
|
||||
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 }
|
||||
const MCP_BASE = `${BACKEND}/brain/mcp-connections`
|
||||
|
||||
type AuthType = "oauth" | "static" | "none"
|
||||
type CatalogEntry = {
|
||||
slug: string
|
||||
name: string
|
||||
category: string
|
||||
authType: AuthType
|
||||
tokenHint?: string
|
||||
}
|
||||
type ConnRow = {
|
||||
serverSlug: string
|
||||
authType: AuthType
|
||||
status: "active" | "pending" | "error"
|
||||
userId: string | null
|
||||
}
|
||||
type SlackStatus = { connected: boolean; teamName: string | null }
|
||||
type Scope = "org" | "user"
|
||||
|
||||
function titleCase(s: string) {
|
||||
return s.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function SecondaryButton({
|
||||
children,
|
||||
|
|
@ -37,79 +58,6 @@ function SecondaryButton({
|
|||
)
|
||||
}
|
||||
|
||||
function SlackMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -130,8 +78,10 @@ function StatusDot({ connected }: { connected: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
function AppCard({
|
||||
toolkit,
|
||||
function AppRow({
|
||||
name,
|
||||
subtitle,
|
||||
icon,
|
||||
connected,
|
||||
canConnect,
|
||||
canDisconnect,
|
||||
|
|
@ -140,7 +90,9 @@ function AppCard({
|
|||
onConnect,
|
||||
onDisconnect,
|
||||
}: {
|
||||
toolkit: string
|
||||
name: string
|
||||
subtitle: string
|
||||
icon: React.ReactNode
|
||||
connected: boolean
|
||||
canConnect: boolean
|
||||
canDisconnect: boolean
|
||||
|
|
@ -149,25 +101,20 @@ function AppCard({
|
|||
onConnect: () => void
|
||||
onDisconnect: () => 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 items-center justify-between gap-4 rounded-xl bg-[#14161A] px-4 py-3 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<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 className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-[9px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[15px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
"font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{meta.label}
|
||||
{name}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
|
|
@ -175,7 +122,7 @@ function AppCard({
|
|||
"truncate text-[12px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{meta.subtitle}
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -205,47 +152,46 @@ function AppCard({
|
|||
)
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
function ScopeToggle({
|
||||
scope,
|
||||
onChange,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
children: React.ReactNode
|
||||
scope: Scope
|
||||
onChange: (s: Scope) => void
|
||||
}) {
|
||||
const items: { id: Scope; label: string }[] = [
|
||||
{ id: "org", label: "Organization" },
|
||||
{ id: "user", label: "Personal" },
|
||||
]
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="px-1">
|
||||
<p
|
||||
<div className="inline-flex rounded-full border border-[#1E293B] bg-[#0D121A] p-1">
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
onClick={() => onChange(it.id)}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[15px] tracking-[-0.15px] text-[#FAFAFA]",
|
||||
"rounded-full px-4 h-8 text-[13px] font-medium transition-colors",
|
||||
scope === it.id
|
||||
? "bg-[#1E293B] text-[#FAFAFA]"
|
||||
: "text-[#737373] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-0.5 text-[13px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
{children}
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardSkeleton() {
|
||||
function RowSkeleton() {
|
||||
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="flex items-center gap-3 rounded-xl bg-[#14161A] px-4 py-3 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
|
||||
<div className="size-9 animate-pulse rounded-[9px] 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 className="h-3 w-24 animate-pulse rounded bg-[#1c1f24]" />
|
||||
<div className="h-2.5 w-36 animate-pulse rounded bg-[#1c1f24]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -253,9 +199,11 @@ function CardSkeleton() {
|
|||
|
||||
export default function CompanyBrainConnections() {
|
||||
const isCompanyBrain = useHasCompanyBrain()
|
||||
const [rows, setRows] = useState<ConnRow[] | null>(null)
|
||||
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
|
||||
const [rows, setRows] = useState<ConnRow[]>([])
|
||||
const [slackStatus, setSlackStatus] = useState<SlackStatus | null>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [scope, setScope] = useState<Scope>("user")
|
||||
|
||||
const roleQuery = useQuery({
|
||||
queryKey: ["company-brain-connections", "role"],
|
||||
|
|
@ -268,16 +216,23 @@ export default function CompanyBrainConnections() {
|
|||
const isAdmin = role === "owner" || role === "admin"
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [connRes, slackRes] = await Promise.all([
|
||||
fetch(`${BACKEND}/brain/connections`, { credentials: "include" }),
|
||||
const [catRes, connRes, slackRes] = await Promise.all([
|
||||
fetch(`${MCP_BASE}/catalog`, { credentials: "include" }),
|
||||
fetch(`${MCP_BASE}/`, { credentials: "include" }),
|
||||
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
|
||||
])
|
||||
if (catRes.ok) {
|
||||
const data = (await catRes.json()) as { catalog?: CatalogEntry[] }
|
||||
setCatalog(Array.isArray(data.catalog) ? data.catalog : [])
|
||||
} else {
|
||||
setCatalog([])
|
||||
toast.error("Couldn't load the app catalog.")
|
||||
}
|
||||
if (connRes.ok) {
|
||||
const data = (await connRes.json()) as { toolkits?: ConnRow[] }
|
||||
setRows(Array.isArray(data.toolkits) ? data.toolkits : [])
|
||||
const data = (await connRes.json()) as { connections?: ConnRow[] }
|
||||
setRows(Array.isArray(data.connections) ? data.connections : [])
|
||||
} else {
|
||||
setRows([])
|
||||
toast.error("Couldn't load connections.")
|
||||
}
|
||||
if (slackRes.ok) {
|
||||
setSlackStatus((await slackRes.json()) as SlackStatus)
|
||||
|
|
@ -294,13 +249,49 @@ export default function CompanyBrainConnections() {
|
|||
return () => window.removeEventListener("focus", onFocus)
|
||||
}, [isCompanyBrain, load])
|
||||
|
||||
const connect = async (toolkit: string, scope: "user" | "org") => {
|
||||
setBusy(`${toolkit}:${scope}`)
|
||||
// A row with userId === null is the org-shared connection; any other row
|
||||
// returned to the caller is their own personal one.
|
||||
const isConnected = (slug: string, shared: boolean) =>
|
||||
rows.some(
|
||||
(r) =>
|
||||
r.serverSlug === slug &&
|
||||
r.status === "active" &&
|
||||
(shared ? r.userId === null : r.userId !== null),
|
||||
)
|
||||
|
||||
const connect = async (entry: CatalogEntry, shared: boolean) => {
|
||||
const key = `${entry.slug}:${shared ? "org" : "user"}`
|
||||
setBusy(key)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BACKEND}/brain/connections/${toolkit}/link?scope=${scope}`,
|
||||
{ method: "POST", credentials: "include" },
|
||||
)
|
||||
if (entry.authType === "static") {
|
||||
const token = window.prompt(
|
||||
`Paste a token for ${entry.name}.${entry.tokenHint ? `\n${entry.tokenHint}` : ""}`,
|
||||
)
|
||||
if (!token) return
|
||||
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect-static`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ token, shared }),
|
||||
})
|
||||
if (res.status === 403) {
|
||||
toast.error("Only admins can connect the shared org account.")
|
||||
return
|
||||
}
|
||||
if (!res.ok) {
|
||||
toast.error("Couldn't connect.")
|
||||
return
|
||||
}
|
||||
toast.success(`${entry.name} connected.`)
|
||||
await load()
|
||||
return
|
||||
}
|
||||
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ shared, redirectUrl: window.location.href }),
|
||||
})
|
||||
if (res.status === 403) {
|
||||
toast.error("Only admins can connect the shared org account.")
|
||||
return
|
||||
|
|
@ -309,9 +300,19 @@ export default function CompanyBrainConnections() {
|
|||
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.")
|
||||
const data = (await res.json()) as {
|
||||
authUrl?: string
|
||||
ok?: boolean
|
||||
error?: string
|
||||
}
|
||||
if (data.authUrl) {
|
||||
window.open(data.authUrl, "_blank", "noopener")
|
||||
} else if (data.ok) {
|
||||
toast.success(`${entry.name} connected.`)
|
||||
await load()
|
||||
} else {
|
||||
toast.error(data.error ?? "Couldn't start the connection.")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Couldn't start the connection.")
|
||||
} finally {
|
||||
|
|
@ -319,18 +320,18 @@ export default function CompanyBrainConnections() {
|
|||
}
|
||||
}
|
||||
|
||||
const disconnect = async (toolkit: string, scope: "user" | "org") => {
|
||||
const label = TOOLKITS[toolkit]?.label ?? toolkit
|
||||
const disconnect = async (entry: CatalogEntry, shared: boolean) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Disconnect ${label} from ${scope === "org" ? "the shared org account" : "your personal account"}?`,
|
||||
`Disconnect ${entry.name} from ${shared ? "the shared org account" : "your personal account"}?`,
|
||||
)
|
||||
)
|
||||
return
|
||||
setBusy(`${toolkit}:${scope}`)
|
||||
const key = `${entry.slug}:${shared ? "org" : "user"}`
|
||||
setBusy(key)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BACKEND}/brain/connections/${toolkit}?scope=${scope}`,
|
||||
`${MCP_BASE}/${entry.slug}?shared=${shared ? "true" : "false"}`,
|
||||
{ method: "DELETE", credentials: "include" },
|
||||
)
|
||||
if (res.status === 403) {
|
||||
|
|
@ -341,7 +342,7 @@ export default function CompanyBrainConnections() {
|
|||
toast.error("Couldn't disconnect.")
|
||||
return
|
||||
}
|
||||
toast.success(`${label} disconnected.`)
|
||||
toast.success(`${entry.name} disconnected.`)
|
||||
await load()
|
||||
} catch {
|
||||
toast.error("Couldn't disconnect.")
|
||||
|
|
@ -363,16 +364,22 @@ export default function CompanyBrainConnections() {
|
|||
)
|
||||
}
|
||||
|
||||
const loading = rows === null
|
||||
const loading = catalog === null
|
||||
const apps = catalog ?? []
|
||||
const shared = scope === "org"
|
||||
const description = shared
|
||||
? "Connected by admins. Used for reads when you haven't connected your own."
|
||||
: "Your personal accounts, used for your actions and your reads."
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<ScopeToggle scope={scope} onChange={setScope} />
|
||||
{slackStatus?.connected && slackStatus.teamName ? (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mr-auto text-[13px] font-medium text-[#737373]",
|
||||
"ml-auto text-[13px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Slack · {slackStatus.teamName}
|
||||
|
|
@ -385,50 +392,41 @@ export default function CompanyBrainConnections() {
|
|||
</SecondaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
<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}
|
||||
canDisconnect={isAdmin}
|
||||
lockedHint="Admin only"
|
||||
busy={busy === `${row.toolkit}:org`}
|
||||
onConnect={() => connect(row.toolkit, "org")}
|
||||
onDisconnect={() => disconnect(row.toolkit, "org")}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Your connections"
|
||||
description="Your personal accounts — used for your actions and your reads."
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"px-1 text-[13px] font-medium text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{loading ? (
|
||||
<CardSkeleton />
|
||||
<>
|
||||
<RowSkeleton />
|
||||
<RowSkeleton />
|
||||
<RowSkeleton />
|
||||
</>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<AppCard
|
||||
key={`user-${row.toolkit}`}
|
||||
toolkit={row.toolkit}
|
||||
connected={row.user}
|
||||
canConnect
|
||||
canDisconnect
|
||||
busy={busy === `${row.toolkit}:user`}
|
||||
onConnect={() => connect(row.toolkit, "user")}
|
||||
onDisconnect={() => disconnect(row.toolkit, "user")}
|
||||
apps.map((entry) => (
|
||||
<AppRow
|
||||
key={`${scope}-${entry.slug}`}
|
||||
name={entry.name}
|
||||
subtitle={titleCase(entry.category)}
|
||||
icon={brainConnectorIcon(entry.slug, entry.name)}
|
||||
connected={isConnected(entry.slug, shared)}
|
||||
canConnect={shared ? isAdmin : true}
|
||||
canDisconnect={shared ? isAdmin : true}
|
||||
lockedHint={shared ? "Admin only" : undefined}
|
||||
busy={busy === `${entry.slug}:${scope}`}
|
||||
onConnect={() => connect(entry, shared)}
|
||||
onDisconnect={() => disconnect(entry, shared)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||
{
|
||||
id: "company-brain",
|
||||
label: "Company Brain",
|
||||
description: "GitHub & Linear — org and personal",
|
||||
description: "Connect apps to your brain — org and personal",
|
||||
icon: <Building2 className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12,7 +12,14 @@ import {
|
|||
} from "@ui/components/dropdown-menu"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { LogOut, Settings, RotateCcw, HelpCircle, LifeBuoy } from "lucide-react"
|
||||
import {
|
||||
LogOut,
|
||||
Settings,
|
||||
RotateCcw,
|
||||
HelpCircle,
|
||||
LifeBuoy,
|
||||
Building2,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
|
|
@ -152,6 +159,13 @@ export function UserProfileMenu({
|
|||
<Settings className="size-4 text-[#737373]" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => openSettings("company-brain")}
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
>
|
||||
<Building2 className="size-4 text-[#737373]" />
|
||||
Company Brain
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleTryOnboarding}
|
||||
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
|
||||
|
|
|
|||
|
|
@ -222,7 +222,13 @@ export const analytics = {
|
|||
|
||||
// settings / spaces / docs analytics
|
||||
settingsTabChanged: (props: {
|
||||
tab: "account" | "billing" | "integrations" | "connections" | "support"
|
||||
tab:
|
||||
| "account"
|
||||
| "billing"
|
||||
| "integrations"
|
||||
| "connections"
|
||||
| "company-brain"
|
||||
| "support"
|
||||
}) => safeCapture("settings_tab_changed", props),
|
||||
|
||||
spaceCreated: () => safeCapture("space_created"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue