diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index 311791bf..a8e0499e 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -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(detectedMode) const [about, setAbout] = useState({ 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( + () => + 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 ( {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" && } + {step === "ingest" && ( + + )} {step === "team" && ( { + 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() { }} /> + ) : isCompanyBrain ? ( +
+ +
) : ( { + 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 ( +
+ + +
+ + +
+
+ ) +} + +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 ( +
+ {tiles.map((t) => ( +
+

+ {t.label} +

+

+ {t.value} +

+
+ ))} +
+ ) +} + +function RecentMemories({ + docs, + loading, +}: { + docs: RecentDoc[] + loading: boolean +}) { + return ( +
+

+ Recent memories +

+ + {loading ? ( +
+ + Loading… +
+ ) : docs.length === 0 ? ( +
+
+ +
+
+

+ No memories yet +

+

+ Connect a source or ask your brain below — what you save shows up + here. +

+
+
+ ) : ( +
    + {docs.map((doc, i) => ( +
  • +
    + +
    +

    + {doc.title?.trim() || "Untitled memory"} +

    + + {formatWhen(doc.createdAt)} + +
  • + ))} +
+ )} +
+ ) +} + +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 ( +
+
+

+ Getting started +

+

+ A few steps to make your brain useful. +

+ +
    + {steps.map((step) => ( +
  • + + {step.done && } + +
    +
    +

    + {step.title} +

    + {!step.done && step.href && ( + + Set up + + + )} +
    + {!step.done && ( +

    + {step.hint} +

    + )} +
    +
  • + ))} +
+
+ ) +} + +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() +} diff --git a/apps/web/components/brain-home/connections-board.tsx b/apps/web/components/brain-home/connections-board.tsx new file mode 100644 index 00000000..963fe17d --- /dev/null +++ b/apps/web/components/brain-home/connections-board.tsx @@ -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(null) + const [slack, setSlack] = useState<{ + connected: boolean + teamName: string | null + } | null>(null) + const [busy, setBusy] = useState(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 ( +
+ {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")} + /> + +
+
+ ) +} + +function SlackBanner() { + return ( +
+
+
+
+
+ +
+
+

+ Company Brain in Slack +

+

+ Install Supermemory so your team can{" "} + @supermemory in any + channel. +

+
+
+ + + + Add to Slack + +
+
+ ) +} + +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/dashboard-view.tsx b/apps/web/components/dashboard-view.tsx index 6edbc1ca..b24e93fa 100644 --- a/apps/web/components/dashboard-view.tsx +++ b/apps/web/components/dashboard-view.tsx @@ -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({ )} >
+ {headerNotice ?
{headerNotice}
: null} {/* Header */} diff --git a/apps/web/components/onboarding-brain/shell.tsx b/apps/web/components/onboarding-brain/shell.tsx index c05b6a9a..f2eb9f8a 100644 --- a/apps/web/components/onboarding-brain/shell.tsx +++ b/apps/web/components/onboarding-brain/shell.tsx @@ -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 (
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 (
@@ -146,7 +152,9 @@ export function StepAbout({
- {mode === "team" ? ( + {teamGated ? ( + onModeChange("personal")} /> + ) : mode === "team" ? ( @@ -358,6 +366,57 @@ function PersonalWorkspaceCard({ ) } +function TeamBetaGate({ onUsePersonal }: { onUsePersonal: () => void }) { + return ( +
+
+

+ Private beta +

+

+ Team workspaces are invite-only +

+

+ 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. +

+
+ + + support@supermemory.com + + + +
+
+ ) +} + function ModeToggle({ mode, onChange, diff --git a/apps/web/components/onboarding-brain/step-ingest.tsx b/apps/web/components/onboarding-brain/step-ingest.tsx index 3f795157..b786a141 100644 --- a/apps/web/components/onboarding-brain/step-ingest.tsx +++ b/apps/web/components/onboarding-brain/step-ingest.tsx @@ -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 = { + 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("coding") - const [selectedKey, setSelectedKey] = useState("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(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 ( -
-
-

- Use your brain anywhere -

-

- Now plug it into the tools you already use to write code, chat, think. -

-
+
+
+
+

+ Use your brain where you work +

+

+ {mode === "team" + ? "Pick where your team asks questions, then set it up." + : "Pick the tool you open every day — about 60 seconds."} +

+
- - -
- -
- {selectedAgent?.pluginId ? ( - - ) : ( - - )} -
+ {/* Right pane: setup detail */} +
+
+ +
+
+
+ +
+
+

+ Set up {activeTool.label} +

+

+ {activeTool.blurb} +

+
+
+ + {activeTool.kind === "slack" ? ( + + ) : activeTool.kind === "mcp" ? ( + + ) : activeTool.pluginId ? ( + + ) : null} +
+ +
+ + +
+
+
+
+
+ ) +} + +function ToolIcon({ id, className }: { id: FlowToolId; className?: string }) { + if (id === "slack") return + if (id === "mcp") + return + const file = id === "claude-code" ? "claude" : id + return ( + + ) +} + +function FlowToolRow({ + tool, + active, + onSelect, +}: { + tool: FlowTool + active: boolean + onSelect: () => void +}) { + return ( + - +
+
+

+ {tool.label} +

+ {tool.recommended && ( + + Recommended + + )} +
+

+ {tool.blurb} +

+
+ + + {active && } + + + ) +} + +function StepRow({ + index, + title, + done, + children, +}: { + index: number + title: ReactNode + done?: boolean + children?: ReactNode +}) { + return ( +
+ + {done ? : index} + +
+
+ {title} +
+ {children ?
{children}
: null}
) } -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 ( +
+ {steps.map((step, i) => ( + + {step.description ? ( +

+ {step.description} +

+ ) : null} + {step.code ? : null} +
+ ))} + + + +
+ ) +} + +function McpGenericSetup({ mcpUrl }: { mcpUrl: string }) { + return ( +
+ + + + + + Per-client setup guides + + + + + + +
+ ) +} + +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 ( +
+ + {!connected && + (loading ? ( + + + Checking… + + ) : ( + + ))} + + + Mention @supermemory in{" "} + #general + + } + /> + + + +
+ ) +} + +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 ( +
+
+				{code}
+			
+ +
+ ) +} + +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 ( -
+
-
- -
-
-

+

Universal MCP URL

-

+

{url}

-
- ) -} - -function PluginSteps({ pluginId }: { pluginId: string }) { - const plugin = PLUGIN_CATALOG[pluginId] - if (!plugin) return null - const steps = plugin.installSteps ?? [] - return ( -
-
-
- {plugin.name} -
-
-

- Set up {plugin.name} -

-

- {plugin.tagline} -

-
- {plugin.docsUrl && ( - - Docs ↗ - - )} -
- -
- {steps.map((step, i) => ( - - ))} -
- -
-
- Your API key is minted in - Settings → Integrations → Plugins. Mint it once and paste into the - step above. -
-
) } -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 ( -
-
-
- {idx} -
-
-
-

- {step.title} - {step.optional && ( - - Optional - - )} -

- {step.description && ( -

- {step.description} -

- )} - {step.code && ( -
-
-							{step.code}
-						
-
- {step.secret && ( - - )} - -
-
- )} -
-
- ) -} - -function CategoryTabs({ - value, - onChange, -}: { - value: AgentCategory - onChange: (c: AgentCategory) => void -}) { - const counts: Record = { - coding: 0, - productivity: 0, - } - for (const a of AGENTS) counts[a.category] += 1 - return ( -
- {CATEGORY_ORDER.map((cat) => { - const isActive = value === cat.id - return ( - - ) - })} -
- ) -} - -function AgentRow({ - agent, - active, - onClick, -}: { - agent: Agent - active: boolean - onClick: () => void -}) { - return ( - + ) } diff --git a/apps/web/components/settings/company-brain-connections.tsx b/apps/web/components/settings/company-brain-connections.tsx new file mode 100644 index 00000000..b9045e8b --- /dev/null +++ b/apps/web/components/settings/company-brain-connections.tsx @@ -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 ( + + 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 ( + + + {connected ? "Connected" : "Not connected"} + + ) +} + +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 ( +
+
+
+ {meta.icon} +
+
+

+ {meta.label} +

+

+ {meta.subtitle} +

+
+
+
+ + {!connected && + (canConnect ? ( + + {busy && } + Connect + + ) : lockedHint ? ( + + + {lockedHint} + + ) : null)} +
+
+ ) +} + +function Section({ + title, + description, + children, +}: { + title: string + description: string + children: React.ReactNode +}) { + return ( +
+
+

+ {title} +

+

+ {description} +

+
+ {children} +
+ ) +} + +function CardSkeleton() { + return ( +
+
+
+
+
+
+
+ ) +} + +export default function CompanyBrainConnections() { + const isCompanyBrain = useHasCompanyBrain() + const [rows, setRows] = useState(null) + const [busy, setBusy] = useState(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 ( +

+ Company Brain isn't enabled for this organization. +

+ ) + } + + const loading = rows === null + + return ( +
+
+ {loading ? ( + + ) : ( + rows.map((row) => ( + connect(row.toolkit, "org")} + /> + )) + )} +
+ +
+ {loading ? ( + + ) : ( + rows.map((row) => ( + connect(row.toolkit, "user")} + /> + )) + )} +
+
+ ) +} diff --git a/apps/web/components/settings/settings-content.tsx b/apps/web/components/settings/settings-content.tsx index a2ee63a4..578a88bd 100644 --- a/apps/web/components/settings/settings-content.tsx +++ b/apps/web/components/settings/settings-content.tsx @@ -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: , }, + { + id: "company-brain", + label: "Company Brain", + description: "GitHub & Linear — org and personal", + icon: , + }, { id: "support", label: "Support & Help", @@ -421,6 +429,7 @@ export function SettingsContent({ {activeTab === "billing" && } {activeTab === "integrations" && } {activeTab === "connections" && } + {activeTab === "company-brain" && } {activeTab === "support" && } diff --git a/apps/web/components/slack-connect-card.tsx b/apps/web/components/slack-connect-card.tsx new file mode 100644 index 00000000..2fc6de2c --- /dev/null +++ b/apps/web/components/slack-connect-card.tsx @@ -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 ( + + ) +} + +export function SlackConnectCard() { + const isCompanyBrain = useHasCompanyBrain() + const [status, setStatus] = useState(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 ( +
+
+

+ Add Supermemory to your Slack +

+

+ {connected + ? `Connected to ${status?.teamName ?? "your workspace"}.` + : "Answer from your company brain and act on connected apps — right inside Slack."} +

+
+ {connected ? ( + + + Connected + + ) : ( + + + Add to Slack + + )} +
+ ) +} diff --git a/apps/web/components/space-selector.tsx b/apps/web/components/space-selector.tsx index f4af92b8..8bba3f38 100644 --- a/apps/web/components/space-selector.tsx +++ b/apps/web/components/space-selector.tsx @@ -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]", diff --git a/packages/lib/posthog.tsx b/packages/lib/posthog.tsx index 540f2ab2..2d326976 100644 --- a/packages/lib/posthog.tsx +++ b/packages/lib/posthog.tsx @@ -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 ( - <> + {process.env.NODE_ENV === "production" && } {children} - + ) }