diff --git a/apps/web/components/brain-connector-icons.tsx b/apps/web/components/brain-connector-icons.tsx new file mode 100644 index 00000000..b957a9e2 --- /dev/null +++ b/apps/web/components/brain-connector-icons.tsx @@ -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 ( + + ) +} + +function GithubMark({ className }: { className?: string }) { + return ( + + GitHub + + + ) +} + +function LinearMark({ className }: { className?: string }) { + return ( + + Linear + + + ) +} + +function SentryMark({ className }: { className?: string }) { + return ( + + Sentry + + + ) +} + +function PostHogMark({ className }: { className?: string }) { + return ( + + PostHog + + + ) +} + +function LetterMark({ label, color }: { label: string; color: string }) { + return ( + + {label.charAt(0).toUpperCase()} + + ) +} + +// 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 + case "linear": + return + case "slack": + return + case "notion": + return + case "granola": + return + case "sentry": + return + case "posthog": + return + default: + return + } +} diff --git a/apps/web/components/brain-home/connections-board.tsx b/apps/web/components/brain-home/connections-board.tsx index ad00c555..7f39f183 100644 --- a/apps/web/components/brain-home/connections-board.tsx +++ b/apps/web/components/brain-home/connections-board.tsx @@ -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 = { + 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(null) + const [catalog, setCatalog] = useState(null) + const [rows, setRows] = useState([]) const [slack, setSlack] = useState<{ connected: boolean teamName: string | null } | null>(null) const [busy, setBusy] = useState(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 (
{slack && !slack.connected && } -
- +
- } - name="GitHub" - subtitle="Repos, pull requests and issues." - connected={brainConnected("github")} - busy={busy === "brain:github"} - onConnect={() => connectBrain("github")} - /> - } - name="Linear" - subtitle="Issues, projects and cycles." - connected={brainConnected("linear")} - busy={busy === "brain:linear"} - onConnect={() => connectBrain("linear")} - /> - - - +

- All connectors - - - } - > - } - name="Google Drive" - subtitle="Docs, sheets and slides." - connected={connectorConnected("google-drive")} - busy={busy === "conn:google-drive"} - onConnect={() => connectConnector("google-drive")} - /> - } - name="Notion" - subtitle="Pages, databases and blocks." - connected={connectorConnected("notion")} - busy={busy === "conn:notion"} - onConnect={() => connectConnector("notion")} - /> - } - name="OneDrive" - subtitle="Files from Microsoft 365." - connected={connectorConnected("onedrive")} - busy={busy === "conn:onedrive"} - onConnect={() => connectConnector("onedrive")} - /> - + Connect your tools +

+

+ Give your Slack agent live access to the apps your team already + uses. +

+
+ +
+ {loading ? ( + Array.from({ length: 3 }).map((_, i) => ( + + )) + ) : ( + <> + {featured.map((entry, i) => ( + connect(entry)} + showDivider={i < featured.length - 1 || remainingCount > 0} + /> + ))} + {remainingCount > 0 && ( + openSettings("company-brain")} + /> + )} + + )} +
+ + +
) } +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 ( +
+
+ +

+ Ask in Slack +

+
+

+ {connectedCount > 0 + ? "Things your agent can answer now:" + : "Connect a tool and your agent can answer:"} +

+ +
+ {prompts.map((p, i) => ( +
+
+ {brainConnectorIcon(p.slug, p.name, "size-4")} +
+

+ "{p.prompt}" +

+
+ ))} +
+
+ ) +} + +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 ( +
+
+ {icon} +
+
+

+ {name} +

+

+ {subtitle} +

+
+ {connected ? ( + + + Connected + + ) : ( + + )} +
+ ) +} + +function MoreTile({ count, onClick }: { count: number; onClick: () => void }) { + return ( + + ) +} + +function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) { + return ( +
+
+
+
+
+
+
+
+ ) +} + function SlackBanner() { return (
) } - -function Group({ - title, - subtitle, - accent, - cta, - children, -}: { - title: string - subtitle: string - accent?: boolean - cta?: React.ReactNode - children: React.ReactNode -}) { - return ( -
- {accent && ( -
- )} -
-
-

- {title} -

-

- {subtitle} -

-
- {cta} -
- {children} -
- ) -} - -function AppCard({ - icon, - name, - subtitle, - connected, - busy, - onConnect, -}: { - icon: React.ReactNode - name: string - subtitle: string - connected: boolean - busy: boolean - onConnect: () => void -}) { - return ( -
-
- {icon} -
-
-

- {name} -

-

- {subtitle} -

-
- {connected ? ( - - - Connected - - ) : ( - - )} -
- ) -} - -function GithubMark({ className }: { className?: string }) { - return ( - - GitHub - - - ) -} - -function LinearMark({ className }: { className?: string }) { - return ( - - Linear - - - ) -} - -function SlackMark({ className }: { className?: string }) { - return ( - - ) -} diff --git a/apps/web/components/settings/company-brain-connections.tsx b/apps/web/components/settings/company-brain-connections.tsx index cdb9cd45..7d15dec8 100644 --- a/apps/web/components/settings/company-brain-connections.tsx +++ b/apps/web/components/settings/company-brain-connections.tsx @@ -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 ( - - ) -} - -function GithubMark({ className }: { className?: string }) { - return ( - - GitHub - - - ) -} - -function LinearMark({ className }: { className?: string }) { - return ( - - Linear - - - ) -} - -const TOOLKITS: Record< - string, - { label: string; subtitle: string; icon: React.ReactNode } -> = { - github: { - label: "GitHub", - subtitle: "Repos, pull requests and issues", - icon: , - }, - linear: { - label: "Linear", - subtitle: "Issues, projects and cycles", - icon: , - }, -} - function StatusDot({ connected }: { connected: boolean }) { return ( void onDisconnect: () => void }) { - const meta = TOOLKITS[toolkit] ?? { - label: toolkit, - subtitle: "", - icon: null, - } return ( -
+
-
- {meta.icon} +
+ {icon}

- {meta.label} + {name}

- {meta.subtitle} + {subtitle}

@@ -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 ( -
-
-

+ {items.map((it) => ( +

- {children} + {it.label} + + ))}
) } -function CardSkeleton() { +function RowSkeleton() { return ( -
-
+
+
-
-
+
+
) @@ -253,9 +199,11 @@ function CardSkeleton() { export default function CompanyBrainConnections() { const isCompanyBrain = useHasCompanyBrain() - const [rows, setRows] = useState(null) + const [catalog, setCatalog] = useState(null) + const [rows, setRows] = useState([]) const [slackStatus, setSlackStatus] = useState(null) const [busy, setBusy] = useState(null) + const [scope, setScope] = useState("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 ( -
-
+
+
+ {slackStatus?.connected && slackStatus.teamName ? (

Slack · {slackStatus.teamName} @@ -385,50 +392,41 @@ export default function CompanyBrainConnections() { ) : null}

-
- {loading ? ( - - ) : ( - rows.map((row) => ( - connect(row.toolkit, "org")} - onDisconnect={() => disconnect(row.toolkit, "org")} - /> - )) - )} -
-
+ {description} +

+ +
{loading ? ( - + <> + + + + ) : ( - rows.map((row) => ( - connect(row.toolkit, "user")} - onDisconnect={() => disconnect(row.toolkit, "user")} + apps.map((entry) => ( + connect(entry, shared)} + onDisconnect={() => disconnect(entry, shared)} /> )) )} -
+
) } diff --git a/apps/web/components/settings/settings-content.tsx b/apps/web/components/settings/settings-content.tsx index 429274b6..1f32b738 100644 --- a/apps/web/components/settings/settings-content.tsx +++ b/apps/web/components/settings/settings-content.tsx @@ -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: , }, { diff --git a/apps/web/components/user-profile-menu.tsx b/apps/web/components/user-profile-menu.tsx index 77593fb8..13b98155 100644 --- a/apps/web/components/user-profile-menu.tsx +++ b/apps/web/components/user-profile-menu.tsx @@ -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 + 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" + > + + Company Brain + safeCapture("settings_tab_changed", props), spaceCreated: () => safeCapture("space_created"),