merge main into mcp revamp

This commit is contained in:
Prasanna A P 2026-07-29 20:55:10 -07:00
commit 36db68a499
38 changed files with 2840 additions and 218 deletions

View file

@ -40,19 +40,19 @@ export const HeroCard = ({ imageUrl, title, description, href }) => {
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<a
href="/concepts/how-it-works"
href="/docs/concepts/how-it-works"
className="inline-flex items-center justify-center rounded-lg bg-gray-900 dark:bg-zinc-100 px-4 py-2.5 text-sm font-medium text-white dark:text-zinc-900 hover:bg-gray-800 dark:hover:bg-white transition-colors"
>
Architecture
</a>
<a
href="/quickstart"
href="/docs/quickstart"
className="inline-flex items-center justify-center rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-950 px-4 py-2.5 text-sm font-medium text-gray-900 dark:text-zinc-100 hover:bg-gray-50 dark:hover:bg-zinc-900 transition-colors"
>
Quickstart
</a>
<a
href="/company-brain/setup"
href="/docs/company-brain/setup"
className="inline-flex items-center justify-center rounded-lg px-3 py-2.5 text-sm font-medium text-gray-600 dark:text-zinc-400 hover:text-gray-900 dark:hover:text-zinc-100 hover:bg-gray-100/80 dark:hover:bg-zinc-800/80 transition-colors"
>
Set up your company brain

View file

@ -119,6 +119,7 @@ interface SDKResult {
export class SupermemoryClient {
private client: Supermemory
private containerTag: string
private hasExplicitContainerTag: boolean
private bearerToken: string
private apiUrl: string
@ -134,6 +135,7 @@ export class SupermemoryClient {
baseURL: apiUrl,
timeout: FETCH_TIMEOUT_MS,
})
this.hasExplicitContainerTag = Boolean(containerTag)
this.containerTag = containerTag || DEFAULT_PROJECT_ID
}
@ -152,7 +154,7 @@ export class SupermemoryClient {
containerTag: this.containerTag,
}
} catch (error) {
this.handleError(error)
this.handleOperationError("Create memory request", error)
}
}
@ -179,7 +181,12 @@ export class SupermemoryClient {
}
const SIMILARITY_THRESHOLD = 0.85
const searchResult = await this.search(content, 5, SIMILARITY_THRESHOLD)
const searchResult = await this.search(
content,
5,
SIMILARITY_THRESHOLD,
this.containerTag,
)
if (searchResult.results.length === 0) {
return {
@ -211,7 +218,7 @@ export class SupermemoryClient {
containerTag: this.containerTag,
}
} catch (error) {
this.handleError(error)
this.handleOperationError("Forget memory request", error)
}
}
@ -219,12 +226,16 @@ export class SupermemoryClient {
query: string,
limit = 10,
threshold?: number,
containerTagOverride?: string,
): Promise<SearchResult> {
try {
const containerTag =
containerTagOverride ??
(this.hasExplicitContainerTag ? this.containerTag : undefined)
const result = await this.client.search.memories({
q: query,
limit,
containerTag: this.containerTag,
...(containerTag ? { containerTag } : {}),
searchMode: "hybrid",
threshold,
})
@ -247,11 +258,20 @@ export class SupermemoryClient {
return { results, total: result.total, timing: result.timing }
} catch (error) {
this.handleError(error)
this.handleOperationError("Search request", error)
}
}
async getProfile(query?: string): Promise<ProfileResponse> {
if (!this.hasExplicitContainerTag) {
return {
profile: {
static: [],
dynamic: [],
},
}
}
try {
const result = await this.client.profile({
containerTag: this.containerTag,
@ -287,7 +307,7 @@ export class SupermemoryClient {
return response
} catch (error) {
this.handleError(error)
this.handleOperationError("Profile request", error)
}
}
@ -489,7 +509,10 @@ export class SupermemoryClient {
case 402:
throw new Error("Memory limit reached. Upgrade at supermemory.ai")
case 403:
throw new Error("Access forbidden.")
throw new Error(
message ||
"Access forbidden. Your account may be restricted or blocked.",
)
case 404:
throw new Error("Not found.")
case 429:
@ -504,4 +527,16 @@ export class SupermemoryClient {
if (error instanceof Error) throw error
throw new Error(`Unexpected error: ${String(error)}`)
}
private handleOperationError(operation: string, error: unknown): never {
try {
this.handleError(error)
} catch (handledError) {
const message =
handledError instanceof Error
? handledError.message
: String(handledError)
throw new Error(`${operation} failed: ${message}`)
}
}
}

View file

@ -2,13 +2,19 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { useRouter } from "next/navigation"
import { Loader2 } from "lucide-react"
import { LogoFull } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { AlertTriangle, ChevronRight, Loader2, RotateCw } from "lucide-react"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
import { cn } from "@lib/utils"
import { analytics } from "@/lib/analytics"
import { dmSansClassName } from "@/lib/fonts"
import {
type BrainEntryOrganization,
resolveCompanyBrainEntry,
} from "@/lib/company-brain-entry"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import {
detectModeFromEmail,
generateOrgSlug,
@ -21,22 +27,39 @@ import {
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const modalCardStyle = {
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}
const inputBevelStyle = {
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
}
// No forms: sign up → org auto-created → Slack install.
// After OAuth, mono attaches api_scale (14d trial) + company_brain (200 credits).
export default function BrainEntryPage() {
const router = useRouter()
const { user, org, organizations, setActiveOrg, refetchOrganizations } =
useAuth()
const {
user,
org,
organizations,
isRestoring,
setActiveOrg,
refetchOrganizations,
} = useAuth()
const { email = null } = user ?? {}
const [error, setError] = useState<string | null>(null)
const [choices, setChoices] = useState<BrainEntryOrganization[] | null>(null)
const [attempt, setAttempt] = useState(0)
const startedRef = useRef(false)
const run = useCallback(async () => {
if (organizations && organizations.length > 0) {
const active =
org ?? organizations.find((o) => o.slug) ?? organizations[0]
if (!org && active?.slug) await setActiveOrg(active.slug)
const continueWithOrganization = useCallback(
async (organization: BrainEntryOrganization) => {
if (org?.id !== organization.id) {
await setActiveOrg(organization.slug)
}
const status = await fetch(`${BACKEND}/brain/slack/status`, {
credentials: "include",
headers: { "X-App-Source": "nova" },
@ -48,9 +71,11 @@ export default function BrainEntryPage() {
return
}
window.location.href = `${BACKEND}/brain/slack/oauth/install`
return
}
},
[org?.id, router, setActiveOrg],
)
const createCompanyBrain = useCallback(async () => {
// Personal email → shell org; the Slack workspace resolves identity later.
const domain =
detectModeFromEmail(email) === "team"
@ -85,53 +110,206 @@ export default function BrainEntryPage() {
has_domain: Boolean(domain),
})
window.location.href = `${BACKEND}/brain/slack/oauth/install`
}, [email, org, organizations, setActiveOrg, refetchOrganizations, router])
}, [email, refetchOrganizations, setActiveOrg])
const run = useCallback(async () => {
const organizationsWithActiveMetadata = (organizations ?? []).map(
(organization) =>
organization.id === org?.id
? { ...organization, metadata: org.metadata }
: organization,
)
const decision = resolveCompanyBrainEntry(
org?.id,
organizationsWithActiveMetadata,
)
if (decision.action === "use" || decision.action === "switch") {
await continueWithOrganization(decision.organization)
return
}
if (decision.action === "choose") {
setChoices(decision.organizations)
return
}
await createCompanyBrain()
}, [continueWithOrganization, createCompanyBrain, org, organizations])
const handleChoice = useCallback(
(organization: BrainEntryOrganization) => {
setChoices(null)
setError(null)
continueWithOrganization(organization).catch((e) => {
startedRef.current = false
console.error("Company Brain organization selection failed:", e)
setError(e instanceof Error ? e.message : "Something went wrong.")
})
},
[continueWithOrganization],
)
// Sole caller of run(): the guard is only released on failure, so a dep change
// mid-flight can't kick off a second org creation.
// biome-ignore lint/correctness/useExhaustiveDependencies: attempt retriggers the retry
useEffect(() => {
if (!user || organizations === null || startedRef.current) return
if (!user || organizations === null || isRestoring || startedRef.current)
return
startedRef.current = true
run().catch((e) => {
startedRef.current = false
console.error("Brain entry failed:", e)
setError(e instanceof Error ? e.message : "Something went wrong.")
})
}, [user, organizations, run, attempt])
}, [user, organizations, isRestoring, run, attempt])
return (
<div
className={cn(
"flex min-h-dvh flex-col items-center justify-center gap-4 bg-[#05080D] px-6 text-center",
dmSansClassName(),
)}
>
{error ? (
<>
<p className="text-[15px] font-medium text-[#FAFAFA]">
<EntryShell>
{choices ? (
<section
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] p-6 text-left md:p-8"
style={modalCardStyle}
>
<p
className={cn(
"text-[20px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Choose your Company Brain
</p>
<p className="mt-1.5 text-[14px] font-medium leading-[1.5] text-[#737373]">
You're a member of more than one workspace. Pick the one to open.
</p>
<div className="mt-6 flex flex-col gap-2">
{choices.map((organization) => (
<button
key={organization.id}
type="button"
onClick={() => handleChoice(organization)}
style={inputBevelStyle}
className="group flex w-full items-center gap-3 rounded-[14px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] px-3 py-3 text-left transition-colors hover:bg-[#1E2228] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/60"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.15)] bg-[#0D121A] text-[13px] font-semibold uppercase text-[#A1A1AA]">
{(organization.name.trim()[0] ?? "?").toUpperCase()}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-medium text-[#fafafa]">
{organization.name}
</span>
{generateOrgSlug(organization.name) !== organization.slug && (
<span className="mt-0.5 block truncate text-[12px] font-medium text-[#525D6E]">
{organization.slug}
</span>
)}
</span>
<ChevronRight className="size-4 shrink-0 text-[#525D6E] transition-[color,transform] group-hover:translate-x-0.5 group-hover:text-[#A1A1AA]" />
</button>
))}
</div>
{email && (
<p className="mt-5 border-t border-white/[0.06] pt-4 text-[12px] font-medium text-[#525D6E]">
Signed in as {email}
</p>
)}
</section>
) : error ? (
<section
className="w-full max-w-md rounded-[22px] bg-[#1B1F24] p-8 text-center"
style={modalCardStyle}
>
<div
className="mx-auto flex size-12 items-center justify-center rounded-[14px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
style={inputBevelStyle}
>
<AlertTriangle className="size-5 text-[#E5A94B]" />
</div>
<p
className={cn(
"mt-5 text-[20px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Couldn't set up your Company Brain
</p>
<p className="max-w-sm text-[13px] text-[#8A94A6]">{error}</p>
<button
type="button"
<p className="mt-2 text-[14px] font-medium leading-[1.5] text-[#737373]">
{error}
</p>
<Button
variant="insideOut"
onClick={() => {
setError(null)
setAttempt((a) => a + 1)
}}
className="rounded-full bg-white px-5 py-2 text-[13px] font-semibold text-[#1D1C1D] hover:bg-white/95"
className="mt-6 rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
<RotateCw className="size-3.5" />
Try again
</button>
</>
</Button>
</section>
) : (
<>
<Loader2 className="size-6 animate-spin text-[#4BA0FA]" />
<p className="text-[14px] font-medium text-[#8A94A6]">
Setting up your Company Brain
<div className="flex flex-col items-center text-center">
<div className="relative flex size-14 items-center justify-center">
<span className="absolute inset-0 animate-ping rounded-[18px] bg-[#4BA0FA]/10" />
<span
className="absolute inset-0 rounded-[18px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
style={inputBevelStyle}
/>
<Loader2 className="relative size-5 animate-spin text-[#4BA0FA]" />
</div>
<p
className={cn(
"mt-6 text-[20px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Setting up your Company Brain
</p>
</>
<p className="mt-2 max-w-sm text-[14px] font-medium leading-[1.5] text-[#737373]">
Preparing your workspace, then we'll connect it to Slack.
</p>
</div>
)}
</EntryShell>
)
}
function EntryShell({ children }: { children: React.ReactNode }) {
return (
<div
className={cn(
"relative min-h-dvh overflow-hidden bg-[#05080D] text-[#fafafa]",
dmSansClassName(),
)}
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 select-none"
style={{
background:
"radial-gradient(ellipse 80% 60% at 50% 40%, rgba(75,160,250,0.08) 0%, rgba(34,97,202,0.04) 35%, transparent 70%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 select-none"
style={{
backgroundImage:
"radial-gradient(circle at center, rgba(105,167,240,0.22) 1px, transparent 1px)",
backgroundSize: "28px 28px",
maskImage:
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
WebkitMaskImage:
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
}}
/>
<header className="pointer-events-none absolute inset-x-0 top-0 z-20 px-4 py-4 md:px-10">
<LogoFull className="h-5 text-[#fafafa] md:h-6" />
</header>
<main className="relative z-10 flex min-h-dvh flex-col items-center justify-center px-4 py-20 md:px-10">
{children}
</main>
</div>
)
}

View file

@ -8,6 +8,7 @@ import { useAuth } from "@lib/auth-context"
import { authClient } from "@lib/auth"
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
import { analytics } from "@/lib/analytics"
import { resolveCompanyBrainEntry } from "@/lib/company-brain-entry"
import { BrainShell } from "@/components/onboarding-brain/shell"
import {
StepAbout,
@ -55,6 +56,20 @@ const getErrorMessage = (error: unknown, fallback: string) => {
return fallback
}
const getWorkspaceCreationErrorCopy = (message: string) => {
const limit = message.match(/maximum number of workspaces \((\d+)\)/i)?.[1]
if (limit) {
return {
title: "Workspace limit reached",
description: `You can own up to ${limit} workspaces. Delete one in Settings or contact support@supermemory.com for a higher limit.`,
}
}
return {
title: "Couldn't create workspace",
description: message,
}
}
export default function BrainOnboardingPage() {
const router = useRouter()
const params = useSearchParams()
@ -64,6 +79,9 @@ export default function BrainOnboardingPage() {
// `?new=1` forces creating an additional org even when the user already has one.
const forceCreate = params?.get("new") === "1"
// ensureOrg strips `new` once the org exists, so latch it for `finish`'s reload.
const forcedCreateRef = useRef(forceCreate)
if (forceCreate) forcedCreateRef.current = true
const nameParam = params?.get("name")?.trim() || ""
const stepFromUrl = (params?.get("step") as BrainStep | null) ?? "about"
@ -73,9 +91,12 @@ export default function BrainOnboardingPage() {
const [step, setStep] = useState<BrainStep>(initialStep)
// `?mode=team` wins over email detection so a personal-domain user arriving
// from a "set up a Company Brain" CTA doesn't land in personal onboarding.
const modeParam = params?.get("mode") === "team" ? "team" : null
const detectedMode = useMemo(
() => detectModeFromEmail(user?.email),
[user?.email],
() => modeParam ?? detectModeFromEmail(user?.email),
[modeParam, user?.email],
)
const suggestedWorkspaceName = useMemo(
() => workspaceNameFromEmail(user?.email),
@ -113,12 +134,12 @@ export default function BrainOnboardingPage() {
sources?: SourcesValues
team?: TeamValues
}
if (cached.mode) setMode(cached.mode)
if (cached.mode && !modeParam) setMode(cached.mode)
if (cached.about) setAbout((a) => ({ ...a, ...cached.about }))
if (cached.sources) setSources((s) => ({ ...s, ...cached.sources }))
if (cached.team) setTeam((t) => ({ ...t, ...cached.team }))
} catch {}
}, [forceCreate])
}, [forceCreate, modeParam])
useEffect(() => {
try {
@ -209,12 +230,12 @@ export default function BrainOnboardingPage() {
localStorage.removeItem(STORAGE_KEY)
} catch {}
// Extra org from settings: hard-reload so org-scoped caches don't show the previous org's data.
if (forceCreate) {
if (forcedCreateRef.current) {
window.location.href = "/?onboarded=1"
return
}
router.push("/?onboarded=1")
}, [router, mode, sources, team, forceCreate])
}, [router, mode, sources, team])
const goNext = useCallback(() => {
const idx = steps.indexOf(step)
@ -239,8 +260,16 @@ export default function BrainOnboardingPage() {
const creatingOrgRef = useRef(false)
const ensureOrg = useCallback(
async (domainOverride?: string): Promise<boolean> => {
if (!forceCreate && organizations && organizations.length > 0)
async (
domainOverride?: string,
createEvenIfExisting = false,
): Promise<boolean> => {
if (
!createEvenIfExisting &&
!forceCreate &&
organizations &&
organizations.length > 0
)
return false
const name = (
domainOverride
@ -322,8 +351,14 @@ export default function BrainOnboardingPage() {
analytics.onboardingWorkspaceCreateFailed({
error: message,
})
toast.error("Organization was not created", {
description: "Please try again from Settings.",
const errorCopy = getWorkspaceCreationErrorCopy(message)
toast.error(errorCopy.title, {
description: errorCopy.description,
duration: 8000,
action: {
label: "Open Settings",
onClick: () => router.push("/settings"),
},
})
if (forceCreate && (organizations?.length ?? 0) > 0) {
router.replace("/")
@ -336,7 +371,10 @@ export default function BrainOnboardingPage() {
const isCompanyBrain = mode === "team"
const handleBrainConfirm = useCallback(
async (confirmedDomain: string): Promise<CompanyBrainConfirmResult> => {
async (
confirmedDomain: string,
organizationId?: string,
): Promise<CompanyBrainConfirmResult> => {
if (creatingOrgRef.current) return { ok: false }
creatingOrgRef.current = true
setCreatingOrg(true)
@ -347,7 +385,42 @@ export default function BrainOnboardingPage() {
workspaceDomain: confirmedDomain,
workspaceName: workspaceName || a.workspaceName,
}))
const orgCreated = await ensureOrg(confirmedDomain)
let orgCreated = false
if (forceCreate) {
orgCreated = await ensureOrg(confirmedDomain, true)
} else if (organizationId) {
const selected = organizations?.find(
(organization) => organization.id === organizationId,
)
if (!selected) return { ok: false }
if (selected.id !== org?.id) await setActiveOrg(selected.slug)
} else {
const organizationsWithActiveMetadata = (organizations ?? []).map(
(organization) =>
organization.id === org?.id
? { ...organization, metadata: org.metadata }
: organization,
)
const decision = resolveCompanyBrainEntry(
org?.id,
organizationsWithActiveMetadata,
confirmedDomain,
)
if (decision.action === "choose") {
return {
ok: false,
choices: decision.organizations.map((organization) => ({
id: organization.id,
name: organization.name,
})),
}
}
if (decision.action === "switch") {
await setActiveOrg(decision.organization.slug)
} else if (decision.action === "create") {
orgCreated = await ensureOrg(confirmedDomain, true)
}
}
// Re-entering onboarding on an existing org ("Try onboarding") must
// kick research from the client. New orgs rely on the signup hook after
// provisioning — a duplicate /start races and can strand the DO task.
@ -387,8 +460,14 @@ export default function BrainOnboardingPage() {
const message = getErrorMessage(e, "Organization was not created.")
console.error("Failed to create organization:", e)
analytics.onboardingWorkspaceCreateFailed({ error: message })
toast.error("Organization was not created", {
description: "Please try again.",
const errorCopy = getWorkspaceCreationErrorCopy(message)
toast.error(errorCopy.title, {
description: errorCopy.description,
duration: 8000,
action: {
label: "Open Settings",
onClick: () => router.push("/settings"),
},
})
return { ok: false }
} finally {
@ -396,7 +475,15 @@ export default function BrainOnboardingPage() {
setCreatingOrg(false)
}
},
[ensureOrg, queryClient],
[
ensureOrg,
forceCreate,
org,
organizations,
queryClient,
setActiveOrg,
router,
],
)
const [sendingInvites, setSendingInvites] = useState(false)

View file

@ -0,0 +1,536 @@
"use client"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import { Logo } from "@ui/assets/Logo"
import { ArrowRight, Check, LoaderIcon } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { useSearchParams } from "next/navigation"
import { type ReactNode, useCallback, useEffect, useState } from "react"
import { SlackMark } from "@/components/brain-connector-icons"
import { dmSans125ClassName } from "@/lib/fonts"
import { getBackendUrl } from "@/lib/url-helpers"
const GRADIENT_BG =
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)"
const GRADIENT_SHADOW =
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)"
type LinkPreview = {
status: "ready"
orgName: string
teamId: string
teamName: string | null
slackDisplayName: string | null
slackEmail: string | null
signedInEmail: string
isOrgMember: boolean
requiresRelink: boolean
}
type PageState =
| { kind: "loading" }
| { kind: "ready"; preview: LinkPreview }
| { kind: "linking"; preview: LinkPreview }
| { kind: "linked"; orgName: string; teamId: string }
| {
kind: "error"
reason: "expired" | "used" | "invalid" | "not_in_org" | "unknown"
}
const ERROR_COPY: Record<
Extract<PageState, { kind: "error" }>["reason"],
{ title: string; body: string }
> = {
expired: {
title: "This link has expired",
body: "Return to Slack and ask Company Brain again to generate a fresh account link.",
},
used: {
title: "This link was already used",
body: "Your account may already be connected. Return to Slack and try your request again.",
},
invalid: {
title: "We couldn't verify this link",
body: "Return to Slack and use the latest link sent by Company Brain.",
},
not_in_org: {
title: "This account isn't in the workspace",
body: "Sign in with a Supermemory account that already belongs to this organization, or ask an admin to add you.",
},
unknown: {
title: "We couldn't finish the connection",
body: "Nothing was changed. Please try again, or return to Slack for a fresh link.",
},
}
function loginRedirectUrl(): string {
const redirect = window.location.href
return `/login?redirect=${encodeURIComponent(redirect)}`
}
async function readJson(response: Response): Promise<Record<string, unknown>> {
return (await response.json().catch(() => ({}))) as Record<string, unknown>
}
export default function SlackAccountLinkPage() {
const params = useSearchParams()
const token = params.get("token")
const { session, user, isSessionPending } = useAuth()
const [state, setState] = useState<PageState>({ kind: "loading" })
const loadPreview = useCallback(async () => {
if (!token) {
setState({ kind: "error", reason: "invalid" })
return
}
const response = await fetch(
`${getBackendUrl()}/brain/slack/account-link/${encodeURIComponent(token)}`,
{
credentials: "include",
headers: { "X-App-Source": "nova" },
},
)
const body = await readJson(response)
if (!response.ok) {
const reason = body.status
setState({
kind: "error",
reason:
reason === "expired" || reason === "used" || reason === "invalid"
? reason
: "unknown",
})
return
}
setState({ kind: "ready", preview: body as LinkPreview })
}, [token])
useEffect(() => {
if (isSessionPending) return
if (!session) {
window.location.replace(loginRedirectUrl())
return
}
void loadPreview().catch(() => {
setState({ kind: "error", reason: "unknown" })
})
}, [isSessionPending, session, loadPreview])
const confirmLink = async (preview: LinkPreview) => {
if (!token) return
setState({ kind: "linking", preview })
try {
const response = await fetch(
`${getBackendUrl()}/brain/slack/account-link/${encodeURIComponent(token)}`,
{
method: "POST",
credentials: "include",
headers: { "X-App-Source": "nova" },
},
)
const body = await readJson(response)
if (!response.ok) {
const reason = body.status
setState({
kind: "error",
reason:
reason === "not_in_org" ||
reason === "expired" ||
reason === "used" ||
reason === "invalid"
? reason
: "unknown",
})
return
}
setState({
kind: "linked",
orgName:
typeof body.orgName === "string" ? body.orgName : preview.orgName,
teamId: preview.teamId,
})
} catch {
setState({ kind: "error", reason: "unknown" })
}
}
const switchAccount = async () => {
await authClient.signOut()
window.location.assign(loginRedirectUrl())
}
const recheck = () => {
setState({ kind: "loading" })
void loadPreview().catch(() => {
setState({ kind: "error", reason: "unknown" })
})
}
return (
<CardShell>
<AnimatePresence mode="wait">
{state.kind === "loading" ? (
<Fade key="loading">
<Card>
<div className="flex min-h-[260px] flex-col items-center justify-center gap-4 px-6 text-center">
<LoaderIcon className="size-5 animate-spin text-[#9AA0A6]" />
<p className="text-[13px] text-[#737373]">
Verifying your secure link
</p>
</div>
</Card>
</Fade>
) : null}
{state.kind === "ready" || state.kind === "linking" ? (
<Fade key="ready">
<Card>
<div className="px-6 pt-7 pb-5 text-center">
<ConnectingHeader />
<h1 className="mt-5 text-[19px] font-semibold tracking-[-0.2px] text-[#FAFAFA]">
{state.preview.isOrgMember
? `Link Slack to ${state.preview.orgName}`
: `This account isn't in ${state.preview.orgName}`}
</h1>
<p className="mx-auto mt-1 max-w-[340px] text-[13px] text-[#737373]">
{state.preview.isOrgMember
? "Company Brain will recognize you by your Slack identity, even when your emails differ."
: `Switch to a Supermemory account that belongs to ${state.preview.orgName}, or ask an admin to add ${state.preview.signedInEmail}.`}
</p>
</div>
<div className="mx-6 h-px bg-white/[0.06]" />
<InfoRow
label="Slack"
name={
state.preview.slackDisplayName ||
state.preview.teamName ||
"Slack account"
}
detail={
state.preview.slackEmail ||
state.preview.teamName ||
undefined
}
/>
<div className="mx-6 h-px bg-white/[0.06]" />
<InfoRow
label="Supermemory"
name={user?.name || "Signed-in account"}
detail={state.preview.signedInEmail}
warn={!state.preview.isOrgMember}
/>
<div className="mx-6 h-px bg-white/[0.06]" />
{state.preview.isOrgMember && state.preview.requiresRelink ? (
<p className="px-6 pt-4 text-[12px] leading-5 text-[#5C6470]">
This Slack identity is linked to another Supermemory account.
Confirming will replace that link for {state.preview.orgName}.
</p>
) : null}
<div className="flex items-center justify-between gap-3 px-6 py-4">
{state.preview.isOrgMember ? (
<>
<TextButton
disabled={state.kind === "linking"}
onClick={() => void switchAccount()}
>
Switch account
</TextButton>
<GradientButton
disabled={state.kind === "linking"}
onClick={() => void confirmLink(state.preview)}
>
{state.kind === "linking" ? (
<LoaderIcon className="size-4 animate-spin" />
) : (
<>
<Check className="size-4" />
{state.preview.requiresRelink
? "Replace and link"
: "Confirm link"}
</>
)}
</GradientButton>
</>
) : (
<>
<TextButton onClick={recheck}>
I've been added check again
</TextButton>
<NeutralButton onClick={() => void switchAccount()}>
Switch account
</NeutralButton>
</>
)}
</div>
<p className="px-6 pb-4 text-center text-[11px] text-[#5C5C5C]">
Signed in as {state.preview.signedInEmail}
</p>
</Card>
</Fade>
) : null}
{state.kind === "linked" ? (
<Fade key="linked">
<Card>
<div className="flex flex-col items-center px-6 pt-8 pb-6 text-center">
<div className="flex size-12 items-center justify-center rounded-[13px] bg-[#0B0D11] text-emerald-300 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.07)]">
<Check className="size-6" />
</div>
<h1 className="mt-5 text-[19px] font-semibold tracking-[-0.2px] text-[#FAFAFA]">
Slack now knows who you are
</h1>
<p className="mt-1 max-w-[320px] text-[13px] text-[#737373]">
Your account is linked to {state.orgName}. Return to Slack and
retry your Company Brain request.
</p>
<a
className={cn(
"relative mt-6 flex h-11 min-w-[180px] items-center justify-center gap-2 rounded-[10px] px-6",
"text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA]",
"transition-opacity hover:opacity-90",
)}
href={`slack://open?team=${encodeURIComponent(state.teamId)}`}
style={{
background: GRADIENT_BG,
boxShadow: GRADIENT_SHADOW,
}}
>
Return to Slack
<ArrowRight className="size-4" />
<div className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</a>
</div>
</Card>
</Fade>
) : null}
{state.kind === "error" ? (
<Fade key="error">
<Card>
<ErrorState
reason={state.reason}
onSwitchAccount={() => void switchAccount()}
/>
</Card>
</Fade>
) : null}
</AnimatePresence>
</CardShell>
)
}
function CardShell({ children }: { children: ReactNode }) {
return (
<main className="relative flex min-h-dvh items-center justify-center bg-[#08090C] p-4">
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(60% 50% at 50% 0%, rgba(75,160,250,0.05), transparent 70%)",
}}
/>
<div
className={cn("relative w-full max-w-[440px]", dmSans125ClassName())}
>
{children}
</div>
</main>
)
}
function Card({ children }: { children: ReactNode }) {
return (
<div className="flex flex-col overflow-hidden rounded-[14px] bg-[#14161A] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
{children}
</div>
)
}
function Fade({ children }: { children: ReactNode }) {
return (
<motion.div
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
transition={{ duration: 0.18 }}
>
{children}
</motion.div>
)
}
function ConnectingHeader() {
return (
<div className="flex items-center justify-center gap-3">
<div className="flex size-12 items-center justify-center rounded-[13px] bg-[#0B0D11] text-[#FAFAFA] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.07)]">
<Logo className="h-6 w-auto text-white" />
</div>
<div className="flex items-center gap-1">
{["a", "b", "c"].map((k, i) => (
<span
className="size-1.5 animate-pulse rounded-full bg-[#525660]"
key={k}
style={{
animationDelay: `${i * 220}ms`,
animationDuration: "1100ms",
}}
/>
))}
</div>
<div className="flex size-12 items-center justify-center rounded-[13px] bg-[#0B0D11] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.07)]">
<SlackMark className="size-6" />
</div>
</div>
)
}
function InfoRow({
label,
name,
detail,
warn,
}: {
label: string
name: string
detail?: string
warn?: boolean
}) {
return (
<div className="flex items-center justify-between gap-3 px-6 py-3.5">
<div className="min-w-0">
<span className="text-[11px] font-medium uppercase tracking-[0.08em] text-[#737373]">
{label}
</span>
<p className="truncate text-[14px] font-medium text-[#FAFAFA]">
{name}
</p>
{detail ? (
<p className="truncate text-[12px] text-[#737373]">{detail}</p>
) : null}
</div>
{warn ? (
<span className="shrink-0 rounded-[7px] bg-amber-400/10 px-2 py-1 text-[11px] font-medium text-amber-300">
Not a member
</span>
) : null}
</div>
)
}
function TextButton({
children,
onClick,
disabled,
}: {
children: ReactNode
onClick: () => void
disabled?: boolean
}) {
return (
<button
className="rounded-[10px] px-3 py-2.5 text-left text-[13px] font-medium text-[#9AA0A6] transition-colors hover:text-[#FAFAFA] disabled:opacity-50"
disabled={disabled}
onClick={onClick}
type="button"
>
{children}
</button>
)
}
function NeutralButton({
children,
onClick,
disabled,
}: {
children: ReactNode
onClick: () => void
disabled?: boolean
}) {
return (
<button
className={cn(
"flex h-10 shrink-0 items-center justify-center gap-2 rounded-[10px] bg-[#FAFAFA] px-5",
"text-[13px] font-medium text-[#0B0D11]",
"cursor-pointer transition-colors hover:bg-white/85 disabled:cursor-not-allowed disabled:opacity-50",
)}
disabled={disabled}
onClick={onClick}
type="button"
>
{children}
</button>
)
}
function GradientButton({
children,
onClick,
disabled,
}: {
children: ReactNode
onClick: () => void
disabled?: boolean
}) {
return (
<button
className={cn(
"relative flex h-11 min-w-[150px] shrink-0 items-center justify-center gap-2 rounded-[10px] px-6",
"text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA]",
"cursor-pointer transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40",
)}
disabled={disabled}
onClick={onClick}
style={{ background: GRADIENT_BG, boxShadow: GRADIENT_SHADOW }}
type="button"
>
{children}
<div className="pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</button>
)
}
function ErrorState({
reason,
onSwitchAccount,
}: {
reason: Extract<PageState, { kind: "error" }>["reason"]
onSwitchAccount: () => void
}) {
const copy = ERROR_COPY[reason]
return (
<div className="flex flex-col items-center px-6 pt-8 pb-6 text-center">
<div className="flex size-12 items-center justify-center rounded-[13px] bg-[#0B0D11] text-amber-300 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.07)]">
<SlackMark className="size-6" />
</div>
<h1 className="mt-5 text-[19px] font-semibold tracking-[-0.2px] text-[#FAFAFA]">
{copy.title}
</h1>
<p className="mt-1 max-w-[320px] text-[13px] text-[#737373]">
{copy.body}
</p>
{reason === "not_in_org" ? (
<div className="mt-6">
<NeutralButton onClick={onSwitchAccount}>
Switch account
</NeutralButton>
</div>
) : (
<a
className="mt-6 flex h-10 items-center justify-center gap-2 rounded-[10px] border border-white/[0.08] bg-[#0B0D11] px-5 text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#1B1E25]"
href="slack://open"
>
Return to Slack
<ArrowRight className="size-4" />
</a>
)}
</div>
)
}

View file

@ -16,6 +16,7 @@ import { ChatSidebar, HomeChatComposer } from "@/components/chat"
import type { ChatAttachmentDraft } from "@/components/chat/attachments"
import { DashboardView } from "@/components/dashboard-view"
import { BrainHomeView } from "@/components/brain-home/brain-home-view"
import { CompanyBrainPromo } from "@/components/company-brain-promo"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { MemoriesGrid } from "@/components/memories-grid"
import { GraphLayoutView } from "@/components/graph-layout-view"
@ -802,7 +803,7 @@ export function AppExperience() {
) : (
<DashboardView
spaceLabel={dashboardSpaceLabel}
headerNotice={undefined}
headerNotice={<CompanyBrainPromo />}
highlights={highlightsData?.highlights ?? []}
isLoadingHighlights={isLoadingHighlights}
onAddMemory={handleAddMemory}

View file

@ -7,8 +7,8 @@ import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
export const DEFAULT_CHAT_PROMPTS = [
"What do you know about me?",
"What have I been working on lately?",
"What themes keep showing up in my memories?",
"Set up Cursor",
"Show my active plugins",
] as const
const SUGGESTION_PILL_CLASS = cn(

View file

@ -6,9 +6,12 @@ import { useQuery } from "@tanstack/react-query"
import { Streamdown } from "streamdown"
import {
BookOpenIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
ClockIcon,
CopyIcon,
ExternalLinkIcon,
GlobeIcon,
ListIcon,
Loader2,
@ -17,6 +20,7 @@ import {
TerminalIcon,
WrenchIcon,
XCircleIcon,
ZapIcon,
} from "lucide-react"
import { cn } from "@lib/utils"
import { isWebSearchToolName } from "@/lib/chat-web-search-tools"
@ -84,6 +88,107 @@ function faviconUrl(host: string): string {
return `https://www.google.com/s2/favicons?sz=64&domain=${host}`
}
type NovaConnectorStatus =
| "active"
| "setup_pending"
| "not_connected"
| "upgrade_required"
| "setup_available"
type NovaConnectorStep = {
title?: string
description?: string
code?: string
link?: { url: string; label: string }
createPluginKey?: boolean
}
type NovaConnectorCardData = {
kind?: "plugin" | "mcp"
id?: string
name?: string
icon?: string
description?: string
features?: string[]
docsUrl?: string
repoUrl?: string
installSteps?: NovaConnectorStep[]
status?: NovaConnectorStatus
requiresPro?: boolean
canGenerateKey?: boolean
keyPluginId?: string
}
type NovaConnectorToolOutput = {
success?: boolean
error?: string
kind?: string
connectors?: NovaConnectorCardData[]
connector?: NovaConnectorCardData
keyReveal?: { pluginId: string; label?: string } | null
available?: Array<{ kind: "plugin" | "mcp"; id: string; name: string }>
}
const NOVA_CONNECTOR_TOOLS = new Set([
"listNovaConnectors",
"getNovaConnectorSetup",
"prepareNovaPluginSetup",
])
const CONNECTOR_ICON_FALLBACKS: Record<string, string> = {
codex: "/images/plugins/codex.png",
cursor: "/images/plugins/cursor.png",
mcp_cursor: "/mcp-supported-tools/cursor.png",
}
const STATUS_COPY: Record<
NovaConnectorStatus,
{ label: string; className: string }
> = {
active: {
label: "Active",
className: "border-emerald-400/20 bg-emerald-400/10 text-emerald-300",
},
setup_pending: {
label: "Finish setup",
className: "border-amber-400/20 bg-amber-400/10 text-amber-300",
},
not_connected: {
label: "Not connected",
className: "border-white/10 bg-white/[0.05] text-white/55",
},
upgrade_required: {
label: "Pro required",
className: "border-[#4BA0FA]/25 bg-[#4BA0FA]/10 text-[#4BA0FA]",
},
setup_available: {
label: "Setup available",
className: "border-white/10 bg-white/[0.05] text-white/65",
},
}
function connectorToolName(part: ToolCallDisplayPart): string {
return part.type.startsWith("tool-")
? part.type.slice("tool-".length)
: part.type
}
function connectorToolNameFromPart(part: unknown): string | null {
if (!part || typeof part !== "object") return null
const record = part as { type?: string; toolName?: string }
if (record.type === "dynamic-tool") return record.toolName ?? null
if (record.type?.startsWith("tool-")) return record.type.slice("tool-".length)
return null
}
function parseConnectorOutput(value: string): NovaConnectorToolOutput | null {
try {
return JSON.parse(value) as NovaConnectorToolOutput
} catch {
return null
}
}
function safeExternalUrl(url: string | null | undefined): string | null {
if (!url) return null
if (url.startsWith("/") && !url.startsWith("//")) return url
@ -98,6 +203,491 @@ function safeExternalUrl(url: string | null | undefined): string | null {
}
}
function unwrapToolOutput(output: unknown): NovaConnectorToolOutput | null {
if (typeof output === "string") {
return parseConnectorOutput(output)
}
if (!output || typeof output !== "object") return null
const record = output as Record<string, unknown>
for (const key of ["value", "result", "data", "output"]) {
const nested = record[key]
if (nested && nested !== output) {
const parsed = unwrapToolOutput(nested)
if (parsed) return parsed
}
}
if (
record.type === "json" &&
record.value &&
typeof record.value === "object"
) {
return record.value as NovaConnectorToolOutput
}
if (record.type === "text" && typeof record.value === "string") {
return parseConnectorOutput(record.value)
}
if (typeof record.text === "string") {
return parseConnectorOutput(record.text)
}
return record as NovaConnectorToolOutput
}
function connectorIconSrc(
connector: NovaConnectorCardData,
): string | undefined {
if (connector.id && CONNECTOR_ICON_FALLBACKS[connector.id]) {
return CONNECTOR_ICON_FALLBACKS[connector.id]
}
if (connector.icon?.endsWith("/codex.svg"))
return CONNECTOR_ICON_FALLBACKS.codex
if (connector.icon?.endsWith("/cursor.svg"))
return CONNECTOR_ICON_FALLBACKS.cursor
return connector.icon
}
function connectorCardKey(connector: NovaConnectorCardData): string {
return `${connector.kind ?? "connector"}-${connector.id ?? connector.name ?? "unknown"}`
}
function connectorIdentity(
output: NovaConnectorToolOutput | null,
): string | null {
if (!output) return null
if (output.connectors && output.connectors.length !== 1) return null
const connector = output.connector ?? output.connectors?.[0]
if (!connector) return null
return `${connector.kind ?? "connector"}:${connector.id ?? connector.name ?? ""}`
}
function connectorOutputFromPart(
part: unknown,
): NovaConnectorToolOutput | null {
if (!part || typeof part !== "object") return null
const record = part as {
type?: string
toolName?: string
output?: unknown
}
const toolName = connectorToolNameFromPart(record)
if (!toolName || !NOVA_CONNECTOR_TOOLS.has(toolName)) return null
return unwrapToolOutput(record.output)
}
function connectorToolPriority(toolName: string | null): number {
if (toolName === "prepareNovaPluginSetup") return 2
if (toolName === "getNovaConnectorSetup") return 1
return 0
}
function shouldSkipNovaConnectorPart(parts: unknown[], index: number): boolean {
const part = parts[index]
const toolName = connectorToolNameFromPart(part)
if (!toolName || !NOVA_CONNECTOR_TOOLS.has(toolName)) return false
const identity = connectorIdentity(connectorOutputFromPart(part))
if (!identity) return false
const priority = connectorToolPriority(toolName)
for (let i = 0; i < parts.length; i++) {
if (i === index) continue
const otherTool = connectorToolNameFromPart(parts[i])
if (!otherTool || !NOVA_CONNECTOR_TOOLS.has(otherTool)) continue
const otherIdentity = connectorIdentity(connectorOutputFromPart(parts[i]))
if (otherIdentity !== identity) continue
const otherPriority = connectorToolPriority(otherTool)
if (i < index && otherPriority >= priority) return true
if (i > index && otherPriority > priority) return true
}
return false
}
function StatusPill({ status }: { status?: NovaConnectorStatus }) {
const copy =
STATUS_COPY[status ?? "not_connected"] ?? STATUS_COPY.not_connected
return (
<span
className={cn(
"inline-flex h-6 shrink-0 items-center rounded-full border px-2 text-[11px] font-medium",
copy.className,
)}
>
{copy.label}
</span>
)
}
function MiniCopyButton({ text, label }: { text: string; label?: string }) {
const [copied, setCopied] = useState(false)
return (
<button
type="button"
aria-label={`Copy ${label ?? "value"}`}
onClick={async () => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1800)
} catch {
setCopied(false)
}
}}
className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-white/45 transition-colors hover:text-white/80"
>
{copied ? (
<CheckIcon className="size-3.5 text-emerald-300" />
) : (
<CopyIcon className="size-3.5" />
)}
</button>
)
}
function ConnectorCodeBlock({
code,
apiKey,
}: {
code: string
apiKey?: string
}) {
const rendered = apiKey ? code.replaceAll("sm_...", apiKey) : code
return (
<div className="group flex min-w-0 items-center gap-2 rounded-[10px] border border-white/[0.07] bg-[#080B10] px-3 py-2.5">
<pre
className={cn(
"scrollbar-none min-w-0 flex-1 overflow-x-auto whitespace-pre font-mono text-[12px] leading-[1.6] text-[#E4E4E7]",
code.includes("sm_...") &&
!apiKey &&
"select-none blur-[4px] transition-[filter] group-hover:blur-none",
)}
>
{rendered}
</pre>
<MiniCopyButton text={rendered} label="setup step" />
</div>
)
}
function RevealPluginKeyButton({
pluginId,
onReveal,
}: {
pluginId: string
onReveal: (key: string) => void
}) {
const [state, setState] = useState<"idle" | "loading" | "copied" | "error">(
"idle",
)
return (
<button
type="button"
disabled={state === "loading"}
onClick={async () => {
setState("loading")
try {
const API_URL =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const params = new URLSearchParams({ client: pluginId })
const res = await fetch(`${API_URL}/v3/auth/key?${params}`, {
credentials: "include",
})
if (!res.ok) throw new Error("Failed to create plugin key")
const data = (await res.json()) as { key?: string }
if (!data.key) throw new Error("Plugin key missing")
onReveal(data.key)
await navigator.clipboard.writeText(data.key).catch(() => undefined)
setState("copied")
setTimeout(() => setState("idle"), 2200)
} catch {
setState("error")
setTimeout(() => setState("idle"), 2200)
}
}}
className={cn(
"inline-flex h-8 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 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-60",
)}
>
{state === "loading" ? (
<Loader2 className="size-3.5 animate-spin" />
) : state === "copied" ? (
<CheckIcon className="size-3.5 text-emerald-300" />
) : state === "error" ? (
<XCircleIcon className="size-3.5 text-red-300" />
) : null}
{state === "copied"
? "Key copied"
: state === "error"
? "Try again"
: state === "loading"
? "Generating"
: "Generate key"}
</button>
)
}
function NovaConnectorCard({
connector,
}: {
connector: NovaConnectorCardData
}) {
const [revealedKey, setRevealedKey] = useState<string | undefined>()
const needsKey = Boolean(connector.canGenerateKey && connector.keyPluginId)
const isUpgrade = connector.status === "upgrade_required"
const iconSrc = connectorIconSrc(connector)
return (
<div className="rounded-xl border border-white/[0.08] bg-[#0D121A] p-3 text-sm text-white/90 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.55)]">
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[#080B0F]">
{iconSrc ? (
<img
src={iconSrc}
alt=""
className="size-6 rounded object-contain"
onError={(event) => {
const img = event.currentTarget
const fallback = connector.id
? CONNECTOR_ICON_FALLBACKS[connector.id]
: undefined
if (fallback && img.dataset.fallbackApplied !== "true") {
img.dataset.fallbackApplied = "true"
img.src = fallback
} else {
img.style.display = "none"
}
}}
/>
) : (
<WrenchIcon className="size-5 text-white/50" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-[14px] font-semibold text-[#FAFAFA]">
{connector.name ?? connector.id ?? "Connector"}
</p>
<StatusPill status={connector.status} />
</div>
{connector.description ? (
<p className="mt-1 text-[12px] leading-snug text-[#A1A1AA]">
{connector.description}
</p>
) : null}
</div>
</div>
{connector.installSteps?.length ? (
<ol className="mt-3 space-y-3">
{connector.installSteps.map((step, index) => (
<li key={`${step.title}-${index}`} className="flex gap-2.5">
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-[#080B10] text-[10px] font-semibold text-[#4BA0FA]">
{index + 1}
</span>
<div className="min-w-0 flex-1 space-y-1.5">
<p className="text-[12px] font-medium text-[#FAFAFA]">
{step.title}
</p>
{step.description ? (
<p className="text-[12px] leading-snug text-[#A1A1AA]">
{step.description}
</p>
) : null}
{step.code ? (
<ConnectorCodeBlock code={step.code} apiKey={revealedKey} />
) : null}
{step.link ? (
<a
href={step.link.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[12px] text-[#4BA0FA] hover:text-[#86C5FF]"
>
{step.link.label}
<ExternalLinkIcon className="size-3" />
</a>
) : null}
</div>
</li>
))}
</ol>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-2">
{needsKey && connector.keyPluginId && !isUpgrade ? (
<RevealPluginKeyButton
pluginId={connector.keyPluginId}
onReveal={setRevealedKey}
/>
) : null}
{isUpgrade ? (
<span className="inline-flex h-8 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-medium text-[#4BA0FA]">
<ZapIcon className="size-3.5" />
Upgrade to connect
</span>
) : null}
{connector.docsUrl ? (
<a
href={connector.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-[12px] text-[#A1A1AA] transition-colors hover:text-white"
>
<BookOpenIcon className="size-3.5" />
Docs
</a>
) : null}
</div>
</div>
)
}
function NovaConnectorCompactCard({
connector,
expanded,
onToggle,
}: {
connector: NovaConnectorCardData
expanded: boolean
onToggle: () => void
}) {
const iconSrc = connectorIconSrc(connector)
return (
<div className="min-w-0 rounded-xl">
<button
type="button"
aria-expanded={expanded}
onClick={onToggle}
className={cn(
"flex min-h-14 w-full cursor-pointer items-center gap-2 rounded-xl border px-2.5 py-2 text-left transition-colors focus:outline-none focus-visible:border-[#4BA0FA]/50",
expanded
? "border-[#4BA0FA]/30 bg-[#111820]"
: "border-white/[0.08] bg-[#0D121A] hover:border-white/[0.14] hover:bg-[#111820]",
)}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-[8px] bg-[#080B0F]">
{iconSrc ? (
<img
src={iconSrc}
alt=""
className="size-5 rounded object-contain"
onError={(event) => {
event.currentTarget.style.display = "none"
}}
/>
) : (
<WrenchIcon className="size-4 text-white/50" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-[12px] font-semibold text-[#FAFAFA]">
{connector.name ?? connector.id ?? "Connector"}
</p>
<p className="mt-0.5 truncate text-[11px] text-[#737373]">
{connector.kind === "mcp" ? "MCP" : "Plugin"}
</p>
</div>
<StatusPill status={connector.status} />
{expanded ? (
<ChevronDownIcon className="size-3.5 shrink-0 text-[#737373]" />
) : (
<ChevronRightIcon className="size-3.5 shrink-0 text-[#737373]" />
)}
</button>
</div>
)
}
function NovaConnectorToolDisplay({ part }: { part: ToolCallDisplayPart }) {
const [expandedConnectorKey, setExpandedConnectorKey] = useState<
string | null
>(null)
const toolName = connectorToolName(part)
const output = unwrapToolOutput(part.output)
const isLoading =
part.state === "input-streaming" || part.state === "input-available"
const isError = part.state === "error" || part.state === "output-error"
if (isLoading) {
return (
<div className="my-2 flex items-center gap-2 rounded-xl border border-white/[0.08] bg-[#0D121A] px-3 py-2 text-xs text-white/55">
<Loader2 className="size-3.5 animate-spin text-[#4BA0FA]" />
<span>Checking Supermemory setup</span>
</div>
)
}
if (isError) {
return (
<div className="my-2 rounded-xl border border-red-400/15 bg-red-400/10 px-3 py-2 text-xs text-red-200">
Couldn't load connector setup.
</div>
)
}
if (!output) return null
if (output.success === false) {
return (
<div className="my-2 rounded-xl border border-white/[0.08] bg-[#0D121A] p-3 text-sm text-white/80">
<p className="font-medium text-[#FAFAFA]">
{output.error ?? "Connector not found"}
</p>
{output.available?.length ? (
<p className="mt-1 text-xs text-[#A1A1AA]">
Try one of: {output.available.map((item) => item.name).join(", ")}
</p>
) : null}
</div>
)
}
const connectors = output.connector
? [output.connector]
: (output.connectors ?? [])
const isConnectorList =
toolName === "listNovaConnectors" && connectors.length > 1
const expandedConnector =
isConnectorList && expandedConnectorKey
? connectors.find(
(connector) => connectorCardKey(connector) === expandedConnectorKey,
)
: null
return (
<div className="my-2 space-y-2">
{isConnectorList ? (
<p className="px-1 text-[11px] font-medium uppercase tracking-[0.08em] text-[#737373]">
Supermemory setup options
</p>
) : null}
<div
className={cn(
isConnectorList && "grid grid-cols-1 gap-2 sm:grid-cols-2",
!isConnectorList && "space-y-2",
)}
>
{connectors.map((connector) =>
isConnectorList ? (
<NovaConnectorCompactCard
key={connectorCardKey(connector)}
connector={connector}
expanded={connectorCardKey(connector) === expandedConnectorKey}
onToggle={() => {
const nextKey = connectorCardKey(connector)
setExpandedConnectorKey((current) =>
current === nextKey ? null : nextKey,
)
}}
/>
) : (
<NovaConnectorCard
key={connectorCardKey(connector)}
connector={connector}
/>
),
)}
</div>
{expandedConnector ? (
<div className="pt-1">
<NovaConnectorCard connector={expandedConnector} />
</div>
) : null}
</div>
)
}
function isWebSearchPart(part: { type: string; toolName?: string }): boolean {
if (part.type === "dynamic-tool") {
return isWebSearchToolName(part.toolName ?? "")
@ -548,7 +1138,10 @@ function BashToolDisplay({ part }: { part: ToolCallDisplayPart }) {
function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
const [expanded, setExpanded] = useState(false)
const toolName = part.type.replace("tool-", "")
const toolName = connectorToolName(part)
if (NOVA_CONNECTOR_TOOLS.has(toolName)) {
return <NovaConnectorToolDisplay part={part} />
}
if (toolName === "bash") {
return <BashToolDisplay part={part} />
}
@ -829,6 +1422,9 @@ export function AgentMessage({
)
}
if (part.type === "dynamic-tool") {
if (shouldSkipNovaConnectorPart(message.parts, partIndex)) {
return null
}
const dt = part as {
type: "dynamic-tool"
toolName: string
@ -856,6 +1452,9 @@ export function AgentMessage({
)
}
if (part.type.startsWith("tool-")) {
if (shouldSkipNovaConnectorPart(message.parts, partIndex)) {
return null
}
return (
<ToolCallDisplay
key={`${message.id}-${partIndex}`}

View file

@ -0,0 +1,89 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { ArrowRight, XIcon } from "lucide-react"
import { Logo } from "@ui/assets/Logo"
import { Button } from "@repo/ui/components/button"
import { cn } from "@lib/utils"
import { analytics } from "@/lib/analytics"
import { dmSansClassName } from "@/lib/fonts"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
const DISMISS_KEY = "supermemory-company-brain-promo-dismissed-v1"
export function CompanyBrainPromo() {
const router = useRouter()
const hasCompanyBrain = useHasCompanyBrain()
const [dismissed, setDismissed] = useState(true)
useEffect(() => {
if (hasCompanyBrain) return
try {
setDismissed(localStorage.getItem(DISMISS_KEY) === "1")
} catch {
setDismissed(false)
}
}, [hasCompanyBrain])
const visible = !hasCompanyBrain && !dismissed
useEffect(() => {
if (visible) analytics.companyBrainPromoSeen()
}, [visible])
if (!visible) return null
const dismiss = () => {
setDismissed(true)
try {
localStorage.setItem(DISMISS_KEY, "1")
} catch {}
analytics.companyBrainPromoDismissed()
}
return (
<div
className={cn(
"flex items-center gap-4 rounded-xl bg-surface-card/60 px-4 py-4 backdrop-blur-md",
"shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
dmSansClassName(),
)}
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-[#0562ef]">
<Logo className="h-4 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[15px] font-semibold text-[#fafafa]">
Give your team a Company Brain
</p>
<p className="text-[13px] text-[#a1a1a1]">
Lives in your Slack. Answers from your team's tools, and brings things
up before you ask.
</p>
</div>
<Button
className={cn(
"rounded-full! h-9! min-h-9 shrink-0 gap-1.5 px-3 font-medium",
dmSansClassName(),
)}
onClick={() => {
analytics.companyBrainPromoClicked({ source: "dashboard_card" })
router.push("/onboarding?new=1&mode=team")
}}
variant="headers"
>
Set it up
<ArrowRight className="size-4 shrink-0" />
</Button>
<button
aria-label="Dismiss"
type="button"
onClick={dismiss}
className="shrink-0 rounded-full p-1.5 text-[#737373] transition-colors hover:text-[#fafafa]"
>
<XIcon className="size-4" />
</button>
</div>
)
}

View file

@ -1,21 +1,30 @@
"use client"
import { cn } from "@lib/utils"
import { Blocks, CalendarClock, Cpu } from "lucide-react"
import { Blocks, CalendarClock, Cpu, ScrollText } from "lucide-react"
import { useState } from "react"
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
import CompanyBrainModels from "@/components/settings/company-brain-models"
import CompanyBrainProactivity from "@/components/settings/company-brain-proactivity"
import Proactiveness from "@/components/settings/proactiveness"
import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
import { WorkspacePrompt } from "@/components/settings/workspace-prompt"
import { ErrorBoundary } from "@/components/error-boundary"
import { useAuth } from "@lib/auth-context"
import { dmSans125ClassName } from "@/lib/fonts"
type ConfigureSection = "company-brain" | "models" | "automations"
type ConfigureSection =
| "company-brain"
| "models"
| "workspace-prompt"
| "proactivity"
| "automations"
const SECTIONS: {
id: ConfigureSection
label: string
description: string
icon: typeof Blocks
icon: React.ComponentType<{ className?: string }>
}[] = [
{
id: "company-brain",
@ -31,6 +40,20 @@ const SECTIONS: {
"Pick how fast or thorough your brain should be. Fine-tune each task under Advanced.",
icon: Cpu,
},
{
id: "workspace-prompt",
label: "Workspace Prompt",
description:
"Persistent guidance for how your brain works across the workspace. Fixed safety, access, and approval constraints still apply.",
icon: ScrollText,
},
{
id: "proactivity",
label: "Proactivity",
description:
"When Company Brain speaks up in Slack without being asked. Quiet channels are still read and remembered.",
icon: ProactivenessIcon,
},
{
id: "automations",
label: "Automations",
@ -41,6 +64,7 @@ const SECTIONS: {
]
export function ConfigureView() {
const { org } = useAuth()
const [activeSection, setActiveSection] =
useState<ConfigureSection>("company-brain")
const active = SECTIONS.find((section) => section.id === activeSection)
@ -48,16 +72,13 @@ export function ConfigureView() {
return (
<div
className={cn(
dmSans125ClassName(),
"mx-auto flex min-h-full w-full max-w-[88rem] flex-col",
)}
className={cn(dmSans125ClassName(), "flex min-h-full w-full flex-col")}
>
<section
aria-label="Configure Company Brain"
className="flex flex-1 flex-col rounded-[14px] bg-[#191D24] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] sm:p-6"
>
<div className="flex flex-1 flex-col gap-5 md:flex-row md:gap-8">
<div className="mx-auto flex w-full max-w-[88rem] flex-1 flex-col gap-5 md:flex-row md:gap-8">
<nav
aria-label="Configure sections"
className="scrollbar-none flex shrink-0 gap-1 overflow-x-auto md:w-52 md:flex-col md:overflow-x-visible"
@ -118,6 +139,10 @@ export function ConfigureView() {
<CompanyBrainConnections />
) : activeSection === "models" ? (
<CompanyBrainModels showHeading={false} />
) : activeSection === "workspace-prompt" ? (
<WorkspacePrompt key={org?.id} showHeading={false} />
) : activeSection === "proactivity" ? (
<CompanyBrainProactivity />
) : (
<Proactiveness />
)}

View file

@ -650,15 +650,29 @@ export function ConnectAIModal({
if (manual.kind === "chatgpt") {
return (
<div className="space-y-3">
<p className="text-xs leading-relaxed text-amber-600 dark:text-amber-500/90">
Write-capable custom MCP apps are only supported on
Business & Enterprise plans.
</p>
<ol className="list-decimal space-y-2 pl-5 text-sm text-muted-foreground">
<li>Open ChatGPT in your browser.</li>
<li>
Settings Apps Advanced settings enable
Developer mode.
Go to Settings Security and Login scroll to
the bottom to enable Developer mode.
</li>
<li>
Create an app and paste the MCP URL when asked.
Go to{" "}
<a
className="text-primary underline"
href="https://chatgpt.com/plugins"
rel="noopener noreferrer"
target="_blank"
>
chatgpt.com/plugins
</a>
.
</li>
<li>Paste the URL below.</li>
<li>Complete OAuth in ChatGPT.</li>
</ol>
<div className="relative max-w-full sm:max-w-xl">

View file

@ -502,19 +502,30 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) {
if (manual.kind === "chatgpt") {
return (
<div className="space-y-3">
<p className="text-[12px] leading-relaxed text-amber-500/90">
Write-capable custom MCP apps are only supported on
Business & Enterprise plans.
</p>
<ol className="list-decimal space-y-2 pl-5 text-[13px] leading-relaxed text-[#A1A1AA]">
<li>Open ChatGPT in your browser.</li>
<li>
Go to Settings Apps Advanced settings enable
Developer mode.
Go to Settings Security and Login scroll to the
bottom to enable Developer mode.
</li>
<li>
Create an app and choose your MCP server URL when
asked.
</li>
<li>
Paste the URL below and complete OAuth in ChatGPT.
Go to{" "}
<a
className="text-[#4BA0FA] underline hover:text-[#8BC6FF]"
href="https://chatgpt.com/plugins"
rel="noopener noreferrer"
target="_blank"
>
chatgpt.com/plugins
</a>
.
</li>
<li>Paste the URL below.</li>
<li>Complete OAuth in ChatGPT.</li>
</ol>
<McpCodeBlock
code={CHATGPT_REMOTE_MCP_URL}

View file

@ -388,6 +388,13 @@ export function MemoriesGrid({
enabled: !!user && showAgentFilters,
})
useEffect(() => {
if (!selectedAgentSource || !agentSourceCounts) return
if ((agentSourceCounts[selectedAgentSource] ?? 0) === 0) {
void setSelectedAgentSource(null)
}
}, [agentSourceCounts, selectedAgentSource, setSelectedAgentSource])
const {
data,
error,
@ -700,22 +707,25 @@ export function MemoriesGrid({
aria-label="Filter memories by agent"
className="gap-1.5"
>
{AGENT_SOURCE_FILTERS.map((filter) => (
<ToggleGroupItem
key={filter.value}
value={filter.value}
aria-label={`Show ${filter.label} memories`}
className={cn(
dmSansClassName(),
"h-auto min-w-0 flex-none shrink-0 rounded-full! border border-[#161F2C]! bg-[#0D121A] px-2.5 py-1 text-xs hover:border-[#2261CA33]! hover:bg-[#00173C] data-[state=on]:border-[#2261CA33]! data-[state=on]:bg-[#00173C]",
)}
>
{filter.label}
<span className="ml-1 text-[#737373]">
({agentSourceCounts?.[filter.value] ?? 0})
</span>
</ToggleGroupItem>
))}
{AGENT_SOURCE_FILTERS.map((filter) => {
const count = agentSourceCounts?.[filter.value]
if (!count) return null
return (
<ToggleGroupItem
key={filter.value}
value={filter.value}
aria-label={`Show ${filter.label} memories`}
className={cn(
dmSansClassName(),
"h-auto min-w-0 flex-none shrink-0 rounded-full! border border-[#161F2C]! bg-[#0D121A] px-2.5 py-1 text-xs hover:border-[#2261CA33]! hover:bg-[#00173C] data-[state=on]:border-[#2261CA33]! data-[state=on]:bg-[#00173C]",
)}
>
{filter.label}
<span className="ml-1 text-[#737373]">({count})</span>
</ToggleGroupItem>
)
})}
</ToggleGroup>
)}
</div>

View file

@ -4,7 +4,14 @@ import { LogoFull } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import { cn } from "@lib/utils"
import { ArrowRight, Check, Globe, Loader2 } from "lucide-react"
import {
ArrowRight,
Building2,
Check,
ChevronRight,
Globe,
Loader2,
} from "lucide-react"
import { useQueryClient } from "@tanstack/react-query"
import { AnimatePresence, motion } from "motion/react"
import { type ReactNode, useEffect, useRef, useState } from "react"
@ -25,6 +32,7 @@ import {
import { ResearchActionRail } from "./research-action-rail"
import {
type CompanyBrainConfirmResult,
type CompanyBrainOrganizationChoice,
workspaceNameFromDomain,
} from "./types"
@ -33,7 +41,10 @@ interface CompanyBrainOnboardingProps {
avatarUrl: string | null
domain: string
submitting: boolean
onConfirm: (domain: string) => Promise<CompanyBrainConfirmResult>
onConfirm: (
domain: string,
organizationId?: string,
) => Promise<CompanyBrainConfirmResult>
onDone: () => void
onUsePersonal: () => void
}
@ -72,6 +83,9 @@ export function CompanyBrainOnboarding({
}: CompanyBrainOnboardingProps) {
const [phase, setPhase] = useState<Phase>("confirm")
const [domain, setDomain] = useState(initialDomain)
const [organizationChoices, setOrganizationChoices] = useState<
CompanyBrainOrganizationChoice[] | null
>(null)
const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false)
const firstName = name.trim().split(/\s+/)[0] ?? ""
const clean = normalizeDomain(domain)
@ -84,10 +98,14 @@ export function CompanyBrainOnboarding({
)
const [retryUi, setRetryUi] = useState<null | "retrying" | "exhausted">(null)
const handleConfirm = async () => {
const handleConfirm = async (organizationId?: string) => {
if (!clean || submitting) return
const result = await onConfirm(clean)
if (!result.ok) return
const result = await onConfirm(clean, organizationId)
if (!result.ok) {
if (result.choices?.length) setOrganizationChoices(result.choices)
return
}
setOrganizationChoices(null)
setServerSchedulesResearch(result.serverSchedulesResearch)
setPhase("research")
}
@ -211,15 +229,24 @@ export function CompanyBrainOnboarding({
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<ConfirmBody
firstName={firstName}
name={name}
avatarUrl={avatarUrl}
domain={domain}
onDomainChange={setDomain}
onConfirm={handleConfirm}
submitting={submitting}
/>
{organizationChoices ? (
<OrganizationChoiceBody
organizations={organizationChoices}
submitting={submitting}
onSelect={(organizationId) => handleConfirm(organizationId)}
onBack={() => setOrganizationChoices(null)}
/>
) : (
<ConfirmBody
firstName={firstName}
name={name}
avatarUrl={avatarUrl}
domain={domain}
onDomainChange={setDomain}
onConfirm={() => handleConfirm()}
submitting={submitting}
/>
)}
</motion.div>
) : (
<motion.div
@ -240,7 +267,7 @@ export function CompanyBrainOnboarding({
</AnimatePresence>
</motion.div>
{phase === "confirm" && (
{phase === "confirm" && !organizationChoices && (
<div className="w-full max-w-xl mx-auto mt-5 flex items-center justify-between gap-4 px-1">
<button
type="button"
@ -252,7 +279,7 @@ export function CompanyBrainOnboarding({
</button>
<Button
variant="insideOut"
onClick={handleConfirm}
onClick={() => handleConfirm()}
disabled={!clean || submitting}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
@ -300,6 +327,67 @@ export function CompanyBrainOnboarding({
)
}
function OrganizationChoiceBody({
organizations,
submitting,
onSelect,
onBack,
}: {
organizations: CompanyBrainOrganizationChoice[]
submitting: boolean
onSelect: (organizationId: string) => void
onBack: () => void
}) {
return (
<>
<div className="flex items-start gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-full border border-[rgba(82,89,102,0.2)] bg-[#14161A] text-[#4BA0FA]">
<Building2 className="size-5" />
</div>
<div>
<p className="text-[20px] font-semibold leading-tight text-[#FAFAFA]">
Choose your Company Brain
</p>
<p className="mt-1 text-[14px] font-medium leading-[1.4] text-[#737373]">
You already belong to more than one Company Brain workspace.
</p>
</div>
</div>
<div className="mt-6 flex flex-col gap-2">
{organizations.map((organization) => (
<button
key={organization.id}
type="button"
disabled={submitting}
onClick={() => onSelect(organization.id)}
className="flex w-full items-center gap-3 rounded-xl border border-white/[0.08] bg-white/[0.04] px-3 py-3 text-left transition-colors hover:bg-white/[0.08] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] disabled:opacity-50"
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-[#4BA0FA]/10 text-[#4BA0FA]">
<Building2 className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate text-[14px] font-medium text-[#FAFAFA]">
{organization.name}
</span>
{submitting ? (
<Loader2 className="size-4 shrink-0 animate-spin text-[#8A94A6]" />
) : (
<ChevronRight className="size-4 shrink-0 text-[#8A94A6]" />
)}
</button>
))}
</div>
<button
type="button"
disabled={submitting}
onClick={onBack}
className="mt-4 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#FAFAFA] disabled:opacity-50"
>
Use a different domain
</button>
</>
)
}
function ConfirmBody({
firstName,
name,

View file

@ -1,6 +1,11 @@
export type CompanyBrainOrganizationChoice = {
id: string
name: string
}
export type CompanyBrainConfirmResult =
| { ok: true; serverSchedulesResearch: boolean }
| { ok: false }
| { ok: false; choices?: CompanyBrainOrganizationChoice[] }
export type BrainMode = "personal" | "team"

View file

@ -95,7 +95,12 @@ type Category = {
count: number
}
const AGENT_CATALOG_IDS = ["claude_code", "codex"] as const
const AGENT_CATALOG_IDS = [
"claude_code",
"codex",
"opencode",
"cursor",
] as const
export function SelectSpacesModal({
isOpen,
@ -1668,7 +1673,7 @@ function AgentsDiscoverPanel({
if (catalogIds.length === 0) {
return (
<p className="py-8 text-center text-sm text-[#737373]">
Claude Code and Codex are connected.
Claude Code, Codex, and OpenCode are connected.
</p>
)
}
@ -1682,7 +1687,7 @@ function AgentsDiscoverPanel({
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[#FAFAFA]">Agents</p>
<p className="text-[11px] text-[#737373]">
Claude Code and Codex share project memory
Claude Code, Codex, and OpenCode share project memory
</p>
</div>
{catalogIds.map((catalogId) => {

View file

@ -132,7 +132,11 @@ function isPendingInvitation(invitation: {
return new Date(invitation.expiresAt).getTime() > Date.now()
}
export default function Account() {
export default function Account({
dialogPortalContainer,
}: {
dialogPortalContainer?: HTMLElement | null
}) {
const { user, org, refetchActiveOrg, refetchOrganizations } = useAuth()
const autumn = useCustomer()
const { currentPlan, searchesUsed } = useTokenUsage(autumn)
@ -152,6 +156,7 @@ export default function Account() {
const [isEditingOrgName, setIsEditingOrgName] = useState(false)
const [orgNameDraft, setOrgNameDraft] = useState("")
const tagInputRef = useRef<HTMLInputElement>(null)
const inviteEmailInputRef = useRef<HTMLInputElement>(null)
const tagAnchorRef = useRef<HTMLDivElement>(null)
const { allProjects: allContainerTags } = useContainerTags()
@ -877,6 +882,11 @@ export default function Account() {
>
<DialogContent
showCloseButton={false}
portalContainer={dialogPortalContainer}
onOpenAutoFocus={(event) => {
event.preventDefault()
inviteEmailInputRef.current?.focus()
}}
className="sm:max-w-[480px] border-none bg-[#1B1F24] p-0 gap-0 rounded-[22px] overflow-hidden"
>
<div className="flex items-start justify-between gap-3 px-6 pt-6 pb-4">
@ -925,6 +935,7 @@ export default function Account() {
<div className="relative min-w-0">
<Mail className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#525D6E]" />
<input
ref={inviteEmailInputRef}
id="team-invite-email"
type="email"
value={inviteEmail}

View file

@ -20,7 +20,6 @@ import {
import { toast } from "sonner"
import { dmSans125ClassName } from "@/lib/fonts"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { useAuth } from "@lib/auth-context"
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
import { PillButton } from "../integrations/install-steps"
@ -310,7 +309,6 @@ function RowSkeleton() {
export default function CompanyBrainConnections() {
const isCompanyBrain = useHasCompanyBrain()
const { user } = useAuth()
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
const [catalogLoaded, setCatalogLoaded] = useState(false)
const [rows, setRows] = useState<ConnRow[]>([])
@ -368,9 +366,6 @@ export default function CompanyBrainConnections() {
(shared ? r.userId === null : r.userId !== null),
)
const isStaff =
user?.email?.toLowerCase().endsWith("@supermemory.com") ?? false
const connect = async (entry: CatalogEntry, shared: boolean) => {
const key = `${entry.slug}:${shared ? "org" : "user"}`
setBusy(key)
@ -462,10 +457,6 @@ export default function CompanyBrainConnections() {
redirectUrl: window.location.href,
}),
})
if (res.status === 403) {
toast.error("Custom MCP URLs are staff-only.")
return
}
const data = (await res.json().catch(() => ({}))) as {
authUrl?: string
ok?: boolean
@ -609,20 +600,18 @@ export default function CompanyBrainConnections() {
}
/>
))}
{isStaff ? (
<button
type="button"
onClick={() => setCustomOpen(true)}
className={cn(
dmSans125ClassName(),
"flex min-h-[104px] cursor-pointer items-center justify-center gap-2 rounded-xl border border-[#2A313C] border-dashed",
"text-[13px] font-medium text-[#737B87] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]",
)}
>
<Plus className="size-4" />
Add custom MCP
</button>
) : null}
<button
type="button"
onClick={() => setCustomOpen(true)}
className={cn(
dmSans125ClassName(),
"flex min-h-[104px] cursor-pointer items-center justify-center gap-2 rounded-xl border border-[#2A313C] border-dashed",
"text-[13px] font-medium text-[#737B87] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]",
)}
>
<Plus className="size-4" />
Add custom MCP
</button>
</>
)}
</div>

View file

@ -0,0 +1,292 @@
"use client"
import { useQuery } from "@tanstack/react-query"
import { cn } from "@lib/utils"
import { Check, Loader2, Lock, X } from "lucide-react"
import { useAuth } from "@lib/auth-context"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/select"
import {
type BrainChannelProactivity,
type BrainProactivityDefault,
useBrainSettings,
useUpdateBrainSettings,
} from "@/hooks/use-brain-settings"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
import { dmSans125ClassName } from "@/lib/fonts"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
type Channel = { id: string; name: string; isPrivate: boolean }
const HOME_CHANNEL_NAME = "company-brain"
const ADD_PLACEHOLDER = "__add__"
const MODES: {
id: BrainProactivityDefault
label: string
description: string
}[] = [
{
id: "all_channels",
label: "All channels",
description: "Joins any conversation it's been added to when it can help.",
},
{
id: "own_channel_only",
label: "Only its own channel",
description: "Speaks only in #company-brain unless @mentioned or DMed.",
},
]
const fieldLabel = cn(
dmSans125ClassName(),
"text-[11px] font-medium uppercase tracking-[0.06em] text-[#5B6675]",
)
const controlClass = cn(
dmSans125ClassName(),
"h-9 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3.5 text-[13px] text-[#FAFAFA] outline-none disabled:opacity-50",
)
const selectContentClass = cn(
dmSans125ClassName(),
"rounded-[10px] border-white/[0.08] bg-[#1B1F24] text-[#FAFAFA] shadow-[0px_8px_24px_rgba(0,0,0,0.5)]",
)
const selectItemClass =
"cursor-pointer rounded-[8px] text-[13px] text-[#FAFAFA] hover:bg-white/10 hover:text-white data-[highlighted]:bg-white/10 data-[highlighted]:text-white focus:bg-white/10 focus:text-white"
export default function CompanyBrainProactivity() {
const isCompanyBrain = useHasCompanyBrain()
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
const { org } = useAuth()
const settingsQuery = useBrainSettings(isCompanyBrain)
const update = useUpdateBrainSettings()
const slackStatusQuery = useQuery({
queryKey: ["brain", "slack-status", org?.id],
queryFn: async () => {
const res = await fetch(`${BACKEND}/brain/slack/status`, {
credentials: "include",
})
if (!res.ok) throw new Error("Failed to load Slack status")
return (await res.json()) as { connected: boolean }
},
enabled: isCompanyBrain,
staleTime: 60_000,
})
// Same key + endpoint as the automations picker so react-query dedupes.
const channelsQuery = useQuery({
queryKey: ["company-brain-automations", "channels", org?.id],
queryFn: async () => {
const res = await fetch(`${BACKEND}/brain/automations/channels`, {
credentials: "include",
})
if (!res.ok) throw new Error("Failed to load channels")
return ((await res.json()) as { channels: Channel[] }).channels ?? []
},
enabled: isCompanyBrain,
staleTime: 60_000,
})
if (!isCompanyBrain) {
return (
<div className="px-1 pt-2">
<p className={cn(dmSans125ClassName(), "text-[13px] text-[#6B6B6B]")}>
Company Brain isn't enabled for this organization.
</p>
</div>
)
}
const proactivity = settingsQuery.data?.proactivity
const activeMode = proactivity?.default ?? "all_channels"
const overrides = proactivity?.channels ?? {}
const channels = channelsQuery.data ?? []
const channelName = (id: string) =>
channels.find((ch) => ch.id === id)?.name ?? id
const addable = channels.filter(
(ch) => !overrides[ch.id] && ch.name !== HOME_CHANNEL_NAME,
)
const disabled = !isAdmin || settingsQuery.isLoading || update.isPending
const setMode = (mode: BrainProactivityDefault) => {
if (mode === activeMode) return
update.mutate({ proactivity: { default: mode } })
}
const setOverride = (
channelId: string,
value: BrainChannelProactivity | null,
) => {
update.mutate({ proactivity: { channels: { [channelId]: value } } })
}
return (
<section className="flex flex-col gap-4 px-1">
{settingsQuery.isLoading ? (
<div className="flex items-center gap-2 text-[13px] text-[#9A9A9A]">
<Loader2 className="size-4 animate-spin" />
Loading proactivity
</div>
) : settingsQuery.isError ? (
<p className={cn(dmSans125ClassName(), "text-[13px] text-red-400")}>
Couldn't load proactivity settings.
</p>
) : (
<div className="flex flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-2">
{MODES.map((mode) => {
const isActive = mode.id === activeMode
return (
<button
key={mode.id}
type="button"
disabled={disabled}
aria-pressed={isActive}
onClick={() => setMode(mode.id)}
className={cn(
dmSans125ClassName(),
"flex min-w-0 cursor-pointer flex-col gap-1.5 rounded-xl p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
"bg-[#14161A] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
isActive
? "bg-[#10161f] ring-1 ring-[#2261CA]/45"
: "hover:bg-[#171A1F]",
)}
>
<span className="flex items-center justify-between gap-2">
<span className="truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]">
{mode.label}
{mode.id === "all_channels" ? (
<span className="ml-1.5 text-[11px] font-medium text-[#737B87]">
Default
</span>
) : null}
</span>
{isActive ? (
<Check className="size-4 shrink-0 text-[#6BB0FF]" />
) : null}
</span>
<span className="text-[12px] font-medium leading-[1.5] text-[#737373]">
{mode.description}
</span>
</button>
)
})}
</div>
<div className="flex flex-col gap-2">
<span className={fieldLabel}>Channel exceptions</span>
{Object.entries(overrides).map(([channelId, value]) => (
<div
key={channelId}
className="flex items-center justify-between gap-3 rounded-xl bg-[#14161A] px-4 py-2.5 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
>
<span
className={cn(
dmSans125ClassName(),
"truncate text-[13px] font-medium text-[#FAFAFA]",
)}
>
#{channelName(channelId)}
</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-0.5 rounded-full bg-[#0D121A] p-0.5 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]">
{(["proactive", "quiet"] as const).map((option) => (
<button
key={option}
type="button"
aria-pressed={value === option}
disabled={disabled}
onClick={() => {
if (value !== option) setOverride(channelId, option)
}}
className={cn(
dmSans125ClassName(),
"h-7 cursor-pointer rounded-full px-3 text-[11.5px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
value === option
? "bg-white/[0.10] text-[#FAFAFA]"
: "text-[#8B929E] hover:text-[#FAFAFA]",
)}
>
{option === "proactive" ? "Proactive" : "Quiet"}
</button>
))}
</div>
<button
type="button"
disabled={disabled}
onClick={() => setOverride(channelId, null)}
className="cursor-pointer text-[#6B6B6B] transition-colors hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Remove exception for #${channelName(channelId)}`}
>
<X className="size-4" />
</button>
</div>
</div>
))}
{addable.length > 0 ? (
<Select
value={ADD_PLACEHOLDER}
disabled={disabled}
onValueChange={(channelId) => {
if (channelId === ADD_PLACEHOLDER) return
setOverride(
channelId,
activeMode === "all_channels" ? "quiet" : "proactive",
)
}}
>
<SelectTrigger className={cn(controlClass, "sm:w-72")}>
<SelectValue />
</SelectTrigger>
<SelectContent className={selectContentClass}>
<SelectItem
value={ADD_PLACEHOLDER}
className={selectItemClass}
>
Add a channel exception
</SelectItem>
{addable.map((ch) => (
<SelectItem
key={ch.id}
value={ch.id}
className={selectItemClass}
>
{ch.isPrivate ? "🔒 " : "# "}
{ch.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : channelsQuery.isLoading || slackStatusQuery.isLoading ? null : (
<p
className={cn(
dmSans125ClassName(),
"text-[12px] text-[#737373]",
)}
>
{slackStatusQuery.data?.connected === false
? "Connect Slack to set per-channel exceptions."
: "Invite Company Brain to a Slack channel to list it here."}
</p>
)}
</div>
{!isAdmin ? (
<div className="flex items-center gap-1.5 text-[12px] text-[#737373]">
<Lock className="size-3.5" />
Only organization admins can change these.
</div>
) : null}
</div>
)}
</section>
)
}

View file

@ -6,24 +6,16 @@ export function ProactivenessIcon({ className }: { className?: string }) {
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M18.8284 18.8284C17.6569 20 15.7712 20 12 20C8.22876 20 6.34315 20 5.17157 18.8284C4 17.6569 4 15.7712 4 12C4 8.22876 4 6.34315 5.17157 5.17157C6.34315 4 8.22876 4 12 4C15.7712 4 17.6569 4 18.8284 5.17157C20 6.34315 20 8.22876 20 12C20 15.7712 20 17.6569 18.8284 18.8284Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 2V4M16 2V4M12 2V4M8 20V22M12 20V22M16 20V22M22 16H20M4 8H2M4 16H2M4 12H2M22 8H20M22 12H20"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M11.4802 7.86193C11.6587 7.37936 12.3413 7.37936 12.5198 7.86193L13.3202 10.0248C13.4325 10.3283 13.6717 10.5675 13.9752 10.6798L16.1381 11.4802C16.6206 11.6587 16.6206 12.3413 16.1381 12.5198L13.9752 13.3202C13.6717 13.4325 13.4325 13.6717 13.3202 13.9752L12.5198 16.1381C12.3413 16.6206 11.6587 16.6206 11.4802 16.1381L10.6798 13.9752C10.5675 13.6717 10.3283 13.4325 10.0248 13.3202L7.86193 12.5198C7.37936 12.3413 7.37936 11.6587 7.86193 11.4802L10.0248 10.6798C10.3283 10.5675 10.5675 10.3283 10.6798 10.0248L11.4802 7.86193Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M13.5 4H11C6.75736 4 4.63604 4 3.31802 5.31802C2 6.63604 2 8.75736 2 13C2 17.2426 2 19.364 3.31802 20.682C4.63604 22 6.75736 22 11 22C15.2426 22 17.364 22 18.682 20.682C20 19.364 20 17.2426 20 13V10.5" />
<path d="M6 8L16 18" />
<path d="M6 14L10 18" />
<path d="M12 8L16 12" />
<path d="M19.5 2.9375V4.5M19.5 4.5V6.0625M19.5 4.5H18.25M19.5 4.5H20.75M22 4.5L20.9156 4.13852C20.4179 3.97263 20.0274 3.58211 19.8615 3.08443L19.5 2L19.1385 3.08443C18.9726 3.58211 18.5821 3.97263 18.0844 4.13852L17 4.5L18.0844 4.86148C18.5821 5.02737 18.9726 5.41789 19.1385 5.91557L19.5 7L19.8615 5.91557C20.0274 5.41789 20.4179 5.02737 20.9156 4.86148L22 4.5Z" />
</svg>
)
}

View file

@ -477,7 +477,9 @@ export function SettingsContent({
</p>
}
>
{activeTab === "account" && <Account />}
{activeTab === "account" && (
<Account dialogPortalContainer={dialogPortalContainer} />
)}
{activeTab === "billing" && <Billing />}
{activeTab === "integrations" && <Integrations />}
{activeTab === "connections" && <ConnectionsMCP />}

View file

@ -0,0 +1,186 @@
"use client"
import { LoaderIcon } from "lucide-react"
import { useState } from "react"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
import { useOrgSettings, useUpdateOrgSettings } from "@/hooks/use-org-settings"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
const DESCRIPTION_ID = "workspace-prompt-description"
const COUNTER_ID = "workspace-prompt-counter"
const HEADING_ID = "workspace-prompt-heading"
function SectionHeading({ children }: { children: React.ReactNode }) {
return (
<h2
id={HEADING_ID}
className={cn(
dmSans125ClassName(),
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
)}
>
{children}
</h2>
)
}
function PromptHeader() {
return (
<div className="min-w-0">
<SectionHeading>Workspace Prompt</SectionHeading>
<p
id={DESCRIPTION_ID}
className={cn(
dmSans125ClassName(),
"text-[13px] tracking-[-0.13px] text-[#737373]",
)}
>
Set persistent guidance for how Company Brain works across your
workspace.
</p>
</div>
)
}
export function WorkspacePrompt({
showHeading = true,
}: {
showHeading?: boolean
}) {
const isCompanyBrain = useHasCompanyBrain()
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
const settingsQuery = useOrgSettings()
const updateSettings = useUpdateOrgSettings()
const [draft, setDraft] = useState<string | null>(null)
const savedPrompt = settingsQuery.data?.workspacePrompt ?? ""
const prompt = draft ?? savedPrompt
const dirty = draft !== null && draft.trim() !== savedPrompt.trim()
const canClear = !dirty && savedPrompt.length > 0 && isAdmin
const handleSave = () => {
updateSettings.mutate(
{
workspacePrompt: prompt.trim() ? prompt.trim() : null,
},
{ onSuccess: () => setDraft(null) },
)
}
if (!isCompanyBrain) return null
return (
<section
id="workspace-prompt"
aria-label={showHeading ? undefined : "Workspace prompt"}
aria-labelledby={showHeading ? HEADING_ID : undefined}
aria-busy={settingsQuery.isLoading || updateSettings.isPending}
className="flex w-full max-w-3xl flex-col gap-3 px-1"
>
{showHeading ? <PromptHeader /> : null}
{settingsQuery.isLoading ? (
<output
aria-live="polite"
className={cn(
dmSansClassName(),
"flex min-h-[96px] items-center justify-center gap-2 rounded-[12px] border border-white/[0.08] bg-[#0D121A] text-[12px] text-[#737373]",
)}
>
<LoaderIcon aria-hidden="true" className="size-3 animate-spin" />
Loading workspace prompt
</output>
) : settingsQuery.isError ? (
<div
role="alert"
className={cn(dmSansClassName(), "flex flex-col items-start gap-2")}
>
<p className="text-[13px] text-[#A3A3A3]">
Workspace prompt could not be loaded.
</p>
<button
type="button"
onClick={() => void settingsQuery.refetch()}
disabled={settingsQuery.isFetching}
className="inline-flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1B1F24] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
>
{settingsQuery.isFetching && (
<LoaderIcon aria-hidden="true" className="size-3 animate-spin" />
)}
Try again
</button>
</div>
) : (
<div className={cn(dmSansClassName(), "flex flex-col gap-4")}>
<textarea
aria-label={showHeading ? undefined : "Workspace prompt"}
aria-labelledby={showHeading ? HEADING_ID : undefined}
aria-describedby={
showHeading ? `${DESCRIPTION_ID} ${COUNTER_ID}` : COUNTER_ID
}
disabled={!isAdmin}
value={prompt}
onChange={(event) => setDraft(event.target.value)}
placeholder="Describe operating preferences, priorities, source and tool choices, workflows, terminology, formatting, and communication style. Fixed safety, access, and approval constraints still apply."
maxLength={1500}
className="min-h-[160px] w-full resize-y rounded-[12px] border border-white/[0.08] bg-[#0D121A] px-3.5 py-3 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus-visible:border-white/[0.16] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1B1F24] disabled:cursor-not-allowed disabled:opacity-60"
/>
<div className="flex items-center justify-between">
<span
id={COUNTER_ID}
className="text-[11px] text-[#737373] tabular-nums"
>
{prompt.length}/1500
</span>
{canClear && (
<button
type="button"
onClick={() => setDraft("")}
className={cn(
dmSansClassName(),
"h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#E5484D] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1B1F24] cursor-pointer",
)}
>
Clear prompt
</button>
)}
{dirty && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setDraft(null)}
disabled={updateSettings.isPending}
className={cn(
dmSansClassName(),
"h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A3A3A3] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1B1F24] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={updateSettings.isPending}
className={cn(
dmSansClassName(),
"inline-flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA] focus-visible:ring-offset-2 focus-visible:ring-offset-[#1B1F24] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{updateSettings.isPending && (
<LoaderIcon
aria-hidden="true"
className="size-3 animate-spin"
/>
)}
Save
</button>
</div>
)}
</div>
</div>
)}
</section>
)
}

View file

@ -15,6 +15,7 @@ import { XIcon, Download, Copy, Check } from "lucide-react"
import { GradientLogo } from "@ui/assets/Logo"
import { useAuth } from "@lib/auth-context"
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { toast } from "sonner"
import * as htmlToImage from "html-to-image"
@ -276,7 +277,8 @@ export function ShareModal({
onClose,
graphCanvasRef,
}: ShareModalProps) {
const { user } = useAuth()
const { user, org } = useAuth()
const isCompanyBrain = useHasCompanyBrain()
const [selectedTheme, setSelectedTheme] =
useState<BackgroundTheme>("gradient")
const [isCopying, setIsCopying] = useState(false)
@ -291,6 +293,15 @@ export function ShareModal({
user?.email?.split("@")[0] ||
""
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "Your"
const orgLabel = org?.name.replace(/\s*organizations?\s*$/i, "").trim()
const ownerLabel = isCompanyBrain
? orgLabel
? /[']s$/i.test(orgLabel)
? orgLabel
: `${orgLabel}'s`
: "Your company's"
: userName
const productName = isCompanyBrain ? "Company Brain" : "supermemory"
const capturePreview = useCallback(async (): Promise<Blob | null> => {
if (!previewRef.current) return null
@ -439,10 +450,10 @@ export function ShareModal({
<GradientLogo className="w-7 h-6" />
<div className="flex flex-col">
<span className="text-[10px] text-white/70 leading-tight">
{userName}
{ownerLabel}
</span>
<span className="text-sm text-white font-semibold leading-tight">
supermemory
{productName}
</span>
</div>
</div>

View file

@ -13,6 +13,7 @@ import {
import { authClient } from "@lib/auth"
import { useRouter } from "next/navigation"
import {
Brain,
LogOut,
Settings,
Settings2,
@ -22,6 +23,7 @@ import {
Sun,
} from "lucide-react"
import { cn } from "@lib/utils"
import { analytics } from "@/lib/analytics"
import { dmSansClassName } from "@/lib/fonts"
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
import { useTokenUsage } from "@/hooks/use-token-usage"
@ -164,6 +166,18 @@ export function UserProfileMenu({
<Settings className="size-4 text-[#737373]" />
Settings
</DropdownMenuItem>
{isCompanyBrain ? null : (
<DropdownMenuItem
onClick={() => {
analytics.companyBrainPromoClicked({ source: "profile_menu" })
router.push("/onboarding?new=1&mode=team")
}}
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"
>
<Brain className="size-4 text-[#737373]" />
Set up Company Brain
</DropdownMenuItem>
)}
{isCompanyBrain ? (
<DropdownMenuItem
onClick={() => void setViewMode("configure")}

View file

@ -195,6 +195,47 @@
margin-top: 0;
}
.chat-markdown-content pre {
margin: 0.75rem 0;
overflow-x: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
background: #080b10 !important;
padding: 0.875rem 1rem;
color: #f4f4f5 !important;
}
.chat-markdown-content pre code {
display: block;
background: transparent !important;
padding: 0;
color: #f4f4f5 !important;
font-size: 0.875rem;
line-height: 1.65;
text-shadow: none;
white-space: pre;
}
.chat-markdown-content pre code *,
.chat-markdown-content pre [style] {
background: transparent !important;
color: #f4f4f5 !important;
text-shadow: none !important;
}
.chat-markdown-content pre code .line {
min-height: 1.45em;
}
.chat-markdown-content :not(pre) > code {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.375rem;
background: rgba(255, 255, 255, 0.06);
padding: 0.125rem 0.3125rem;
color: #fafafa;
font-size: 0.875em;
}
/* Model spams `---` between every section; headings already separate them */
.chat-markdown-content hr {
display: none;

View file

@ -0,0 +1,76 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { useAuth } from "@lib/auth-context"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const BASE = `${BACKEND}/brain/settings`
export type BrainProactivityDefault = "all_channels" | "own_channel_only"
export type BrainChannelProactivity = "proactive" | "quiet"
export type BrainSettingsResponse = {
proactivity: {
default: BrainProactivityDefault
channels: Record<string, BrainChannelProactivity>
}
choices: {
proactivityDefault: BrainProactivityDefault[]
channelProactivity: BrainChannelProactivity[]
}
}
// null clears: default -> reset, channels[id] -> remove that override.
export type BrainSettingsPatch = {
proactivity?: {
default?: BrainProactivityDefault | null
channels?: Record<string, BrainChannelProactivity | null> | null
} | null
}
export function useBrainSettings(enabled: boolean) {
const { org } = useAuth()
return useQuery({
queryKey: ["brain", "settings", org?.id],
queryFn: async (): Promise<BrainSettingsResponse> => {
const res = await fetch(`${BASE}/`, { credentials: "include" })
if (!res.ok) throw new Error("Failed to load settings")
return res.json()
},
enabled,
staleTime: 60_000,
})
}
export function useUpdateBrainSettings() {
const { org } = useAuth()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (patch: BrainSettingsPatch) => {
const res = await fetch(`${BASE}/`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json", "X-App-Source": "nova" },
body: JSON.stringify(patch),
})
if (res.status === 403)
throw new Error("Only admins can change these settings.")
if (!res.ok) {
const b = (await res.json().catch(() => ({}))) as {
message?: string
error?: string
}
throw new Error(b.message ?? b.error ?? "Failed to save settings")
}
return res.json() as Promise<BrainSettingsResponse>
},
onSuccess: (data) => {
queryClient.setQueryData(["brain", "settings", org?.id], data)
toast.success("Proactivity saved")
},
onError: (err) =>
toast.error(
err instanceof Error ? err.message : "Failed to save settings",
),
})
}

View file

@ -3,19 +3,14 @@ import { toast } from "sonner"
import { $fetch } from "@lib/api"
import { useAuth } from "@lib/auth-context"
const API_BASE = `${process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"}/v3`
export type OrgSettings = {
shouldLLMFilter: boolean
filterPrompt: string | null
workspacePrompt: string | null
includeItems?: string[] | null
excludeItems?: string[] | null
}
type OrgSettingsResponse = {
settings?: Partial<OrgSettings>
} & Partial<OrgSettings>
export function useOrgSettings() {
const { org } = useAuth()
const orgId = org?.id ?? ""
@ -23,17 +18,15 @@ export function useOrgSettings() {
return useQuery({
queryKey: ["settings", "org", orgId],
queryFn: async (): Promise<OrgSettings> => {
const response = await $fetch("@get/settings", {
disableValidation: true,
})
const response = await $fetch("@get/settings")
if (response.error) {
throw new Error(response.error.message || "Failed to load settings")
}
const data = response.data as OrgSettingsResponse | null
const settings = data?.settings ?? data ?? {}
const settings = response.data ?? {}
return {
shouldLLMFilter: settings.shouldLLMFilter ?? false,
filterPrompt: settings.filterPrompt ?? null,
workspacePrompt: settings.workspacePrompt ?? null,
includeItems: settings.includeItems ?? null,
excludeItems: settings.excludeItems ?? null,
}
@ -50,25 +43,26 @@ export function useUpdateOrgSettings() {
return useMutation({
mutationFn: async (settings: Partial<OrgSettings>) => {
const res = await fetch(`${API_BASE}/settings`, {
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-App-Source": "nova",
},
body: JSON.stringify(settings),
const response = await $fetch("@patch/settings", {
body: settings,
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
message?: string
}
throw new Error(body?.message || "Failed to save settings")
if (response.error) {
throw new Error(response.error.message || "Failed to save settings", {
cause: response.error,
})
}
return res.json()
return response.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["settings", "org", orgId] })
onMutate: () => ({ orgId }),
onSuccess: async (data, _settings, mutationContext) => {
const queryKey = ["settings", "org", mutationContext.orgId] as const
const canonicalSettings = data?.updated
if (canonicalSettings) {
queryClient.setQueryData<OrgSettings>(queryKey, (current) =>
current ? { ...current, ...canonicalSettings } : current,
)
}
await queryClient.invalidateQueries({ queryKey, exact: true })
toast.success("Settings saved")
},
onError: (error) => {

View file

@ -7,7 +7,7 @@ import {
} from "./agent-space"
describe("Agents spaces", () => {
it("recognizes only Claude and Codex shared and legacy tags", () => {
it("recognizes shared and legacy tags from every unified agent", () => {
expect(isAgentContainerTag("repo_supermemory__0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("user_project_0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("repo_supermemory")).toBe(true)
@ -16,7 +16,10 @@ describe("Agents spaces", () => {
)
expect(isAgentContainerTag("codex_project_0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("codex_user_0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("opencode_project_0123456789abcdef")).toBe(false)
expect(isAgentContainerTag("opencode_project_0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("opencode_user_0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("cursor_project_0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("cursor_user_0123456789abcdef")).toBe(true)
})
it("shows agent filters only for an Agents selection", () => {
@ -38,6 +41,8 @@ describe("Agents spaces", () => {
"claude-code-plugin",
])
expect(agentSourceValues("codex")).toEqual(["codex"])
expect(agentSourceValues("opencode")).toEqual(["opencode"])
expect(agentSourceValues("cursor")).toEqual(["cursor"])
expect(agentSourceValues(null)).toBeUndefined()
})
@ -46,6 +51,7 @@ describe("Agents spaces", () => {
{ containerTag: "repo_supermemory__fedcba9876543210" },
{ containerTag: "repo_supermemory" },
{ containerTag: "codex_project_0123456789abcdef" },
{ containerTag: "opencode_project_0123456789abcdef" },
{ containerTag: "claudecode_project_0123456789abcdef" },
{ containerTag: "user_project_0123456789abcdef" },
]
@ -69,6 +75,7 @@ describe("Agents spaces", () => {
"claudecode_project_0123456789abcdef",
"repo_supermemory",
"codex_project_0123456789abcdef",
"opencode_project_0123456789abcdef",
])
})
@ -110,10 +117,31 @@ describe("Agents spaces", () => {
expect(groups[1]?.kind).toBe("legacy-personal")
})
it("keeps the old global OpenCode personal container separate", () => {
const projects = [
{ containerTag: "user_project_0123456789abcdef" },
{ containerTag: "opencode_user_fedcba9876543210" },
]
const metadata = new Map(
projects.map((project) => [
project.containerTag,
{ projectName: "supermemory" },
]),
)
const groups = groupAgentSpaces(projects, metadata)
expect(groups).toHaveLength(2)
expect(groups[0]?.label).toBe("supermemory")
expect(groups[1]?.label).toBe("Legacy OpenCode personal")
expect(groups[1]?.kind).toBe("legacy-personal")
})
it("groups path-scoped legacy tags even before metadata loads", () => {
const projects = [
{ containerTag: "claudecode_project_0123456789abcdef" },
{ containerTag: "codex_project_0123456789abcdef" },
{ containerTag: "opencode_project_0123456789abcdef" },
]
const groups = groupAgentSpaces(projects, new Map())
@ -123,4 +151,25 @@ describe("Agents spaces", () => {
"claudecode_project_0123456789abcdef",
)
})
it("shows an unambiguous Cursor project label before metadata loads", () => {
const groups = groupAgentSpaces(
[{ containerTag: "cursor_project_0123456789abcdef" }],
new Map(),
)
expect(groups).toHaveLength(1)
expect(groups[0]?.label).toBe("Cursor project · 012345")
})
it("shows old Cursor personal memory without a Legacy prefix", () => {
const groups = groupAgentSpaces(
[{ containerTag: "cursor_user_fedcba9876543210" }],
new Map(),
)
expect(groups).toHaveLength(1)
expect(groups[0]?.label).toBe("Cursor personal · fedcba")
expect(groups[0]?.kind).toBe("legacy-personal")
})
})

View file

@ -5,7 +5,7 @@ export type AgentContainerKind =
| "legacy-personal"
| "legacy-project"
export type AgentSourceFilter = "claude-code" | "codex"
export type AgentSourceFilter = "claude-code" | "codex" | "opencode" | "cursor"
export const AGENT_SOURCE_FILTERS: ReadonlyArray<{
value: AgentSourceFilter
@ -18,6 +18,8 @@ export const AGENT_SOURCE_FILTERS: ReadonlyArray<{
sources: ["claude-code", "claude-code-plugin"],
},
{ value: "codex", label: "Codex", sources: ["codex"] },
{ value: "opencode", label: "OpenCode", sources: ["opencode"] },
{ value: "cursor", label: "Cursor", sources: ["cursor"] },
]
export type AgentSpaceMetadata = {
@ -54,6 +56,22 @@ const TAG_PATTERNS: Array<{
kind: "legacy-project",
pattern: /^codex_project_([0-9a-f]{6,64})$/i,
},
{
kind: "legacy-personal",
pattern: /^opencode_user_([0-9a-f]{6,64})$/i,
},
{
kind: "legacy-project",
pattern: /^opencode_project_([0-9a-f]{6,64})$/i,
},
{
kind: "legacy-personal",
pattern: /^cursor_user_([0-9a-f]{6,64})$/i,
},
{
kind: "legacy-project",
pattern: /^cursor_project_([0-9a-f]{6,64})$/i,
},
]
function matchAgentTag(containerTag: string): {
@ -118,7 +136,7 @@ function tagPriority(containerTag: string): number {
case "personal":
return 1
case "legacy-personal":
return containerTag.startsWith("claudecode_project_") ? 2 : 5
return containerTag.startsWith("claudecode_project_") ? 2 : 6
case "project":
return 3
case "legacy-project":
@ -137,13 +155,25 @@ function legacyGroupIdentity(
return { key: `tag:${containerTag}`, label: containerTag, kind: "project" }
}
// Old Codex personal memory was intentionally global. Even if its newest
// document contains a project name, assigning the whole container to that
// project would leak memories from its other historical projects.
if (containerTag.startsWith("codex_user_")) {
// Old Codex, OpenCode, and Cursor personal containers were global.
// Even if the newest document has a project name, assigning the whole
// container to that project would mix memories from historical projects.
if (
containerTag.startsWith("codex_user_") ||
containerTag.startsWith("opencode_user_") ||
containerTag.startsWith("cursor_user_")
) {
const agent = containerTag.startsWith("codex_user_")
? "Codex"
: containerTag.startsWith("opencode_user_")
? "OpenCode"
: "Cursor"
return {
key: `legacy-personal:${containerTag}`,
label: "Legacy Codex personal",
label:
agent === "Cursor"
? `Cursor personal · ${match.id.slice(0, 6)}`
: `Legacy ${agent} personal`,
kind: "legacy-personal",
}
}
@ -156,10 +186,19 @@ function legacyGroupIdentity(
}
}
if (containerTag.startsWith("cursor_project_")) {
return {
key: `cursor-project:${match.id.toLocaleLowerCase()}`,
label: `Cursor project · ${match.id.slice(0, 6)}`,
kind: "project",
}
}
if (
containerTag.startsWith("user_project_") ||
containerTag.startsWith("claudecode_project_") ||
containerTag.startsWith("codex_project_")
containerTag.startsWith("codex_project_") ||
containerTag.startsWith("opencode_project_")
) {
return {
key: `path:${match.id.toLocaleLowerCase()}`,
@ -211,9 +250,9 @@ function addProjectToGroup<T extends { containerTag: string }>(
}
/**
* Collapse the physical Claude/Codex containers into one selectable Agents row
* per project. Every returned container tag remains real; the UI never writes
* to a synthetic "agents" tag.
* Collapse the physical Claude/Codex/OpenCode/Cursor containers into one selectable
* Agents row per project. Every returned container tag remains real; the UI
* never writes to a synthetic "agents" tag.
*/
export function groupAgentSpaces<T extends { containerTag: string }>(
projects: T[],
@ -256,14 +295,19 @@ export function groupAgentSpaces<T extends { containerTag: string }>(
const canonicalMatches = projectName
? (canonicalKeysByName.get(projectName.toLocaleLowerCase()) ?? [])
: []
const firstCanonical = canonicalMatches[0]
const key =
identity.kind === "project" && canonicalMatches.length === 1
? canonicalMatches[0]!
identity.kind === "project" &&
canonicalMatches.length === 1 &&
firstCanonical !== undefined
? firstCanonical
: identity.key
addProjectToGroup(
grouped,
key,
projectName ?? identity.label,
identity.kind === "legacy-personal"
? identity.label
: (projectName ?? identity.label),
identity.kind,
project,
projectName,

View file

@ -257,4 +257,12 @@ export const analytics = {
rating: "up" | "down" | null
message: string
}) => safeCapture("digest_feedback_detail", props),
// company brain promo
companyBrainPromoSeen: () => safeCapture("company_brain_promo_seen"),
companyBrainPromoClicked: (props: {
source: "dashboard_card" | "profile_menu"
}) => safeCapture("company_brain_promo_clicked", props),
companyBrainPromoDismissed: () =>
safeCapture("company_brain_promo_dismissed"),
}

View file

@ -0,0 +1,81 @@
import {
getBrainMode,
getBrainWorkspaceDomain,
getCompanyBrainOverride,
hasCompanyBrain,
} from "./billing-utils"
export type BrainEntryOrganization = {
id: string
name: string
slug: string
metadata?: Record<string, unknown> | string | null
}
export type CompanyBrainEntryDecision =
| { action: "use"; organization: BrainEntryOrganization }
| { action: "switch"; organization: BrainEntryOrganization }
| { action: "choose"; organizations: BrainEntryOrganization[] }
| { action: "create" }
export function isCompanyBrainOrganization(
organization: BrainEntryOrganization,
): boolean {
const override = getCompanyBrainOverride(organization.metadata)
if (override !== undefined) return override
return (
hasCompanyBrain(organization.metadata) ||
getBrainMode(organization.metadata) === "team"
)
}
export function getCompanyBrainOrganizations(
organizations: BrainEntryOrganization[],
): BrainEntryOrganization[] {
return organizations.filter(isCompanyBrainOrganization)
}
function normalizeDomain(domain: string): string {
return domain
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.replace(/\/.*$/, "")
}
export function resolveCompanyBrainEntry(
activeOrganizationId: string | null | undefined,
organizations: BrainEntryOrganization[],
requestedDomain?: string,
): CompanyBrainEntryDecision {
const normalizedRequestedDomain = requestedDomain
? normalizeDomain(requestedDomain)
: null
const companyBrains = getCompanyBrainOrganizations(organizations).filter(
(organization) => {
if (!normalizedRequestedDomain) return true
const organizationDomain = getBrainWorkspaceDomain(organization.metadata)
return (
organizationDomain !== null &&
normalizeDomain(organizationDomain) === normalizedRequestedDomain
)
},
)
const active = organizations.find(
(organization) => organization.id === activeOrganizationId,
)
if (
active &&
companyBrains.some((organization) => organization.id === active.id)
) {
return { action: "use", organization: active }
}
if (companyBrains.length === 1 && companyBrains[0]) {
return { action: "switch", organization: companyBrains[0] }
}
if (companyBrains.length > 1) {
return { action: "choose", organizations: companyBrains }
}
return { action: "create" }
}

View file

@ -46,6 +46,54 @@ describe("parsePluginDocument — session transcripts", () => {
expect(parsed?.pluginIconSrc).toBe("/images/plugins/claude-code.svg")
})
it("renders a new Cursor capture as structured conversation cards", () => {
const parsed = parsePluginDocument({
id: "doc_cursor",
title: "Cursor conversation",
content: [
"[Conversation cursor-session-1]",
"1. [user] Keep the API boundary stable",
"2. [assistant] I will preserve it.",
].join("\n"),
source: "cursor",
metadata: { sm_source: "cursor", type: "conversation" },
containerTags: ["repo_supermemory__0123456789abcdef"],
memoryEntries: [],
} as unknown as PluginDocumentInput)
expect(parsed?.pluginLabel).toBe("Cursor")
expect(parsed?.pluginIconSrc).toBe("/images/plugins/cursor.png")
expect(parsed?.formatLabel).toBe("Conversation")
expect(parsed?.messages).toHaveLength(2)
expect(parsed?.messages[0]?.role).toBe("user")
expect(parsed?.messages[1]?.role).toBe("assistant")
})
it("renders old Cursor tags and transcripts without source metadata", () => {
const parsed = parsePluginDocument({
id: "doc_cursor_legacy",
title: "Cursor session",
content: [
"Cursor IDE session transcript:",
"User: Fix the renderer",
"with the existing card design.",
"Assistant: Implemented the parser.",
].join("\n"),
source: "api",
metadata: {},
containerTags: ["cursor_project_0123456789abcdef"],
memoryEntries: [],
} as unknown as PluginDocumentInput)
expect(parsed?.pluginLabel).toBe("Cursor")
expect(parsed?.pluginIconSrc).toBe("/images/plugins/cursor.png")
expect(parsed?.messages).toHaveLength(2)
expect(parsed?.messages[0]?.text).toBe(
"Fix the renderer\nwith the existing card design.",
)
expect(parsed?.messages[1]?.text).toBe("Implemented the parser.")
})
it("keeps multi-line message bodies intact", () => {
const parsed = parsePluginDocument(
makeCodexSessionDocument(

View file

@ -89,6 +89,7 @@ function formatClientName(value: string | null | undefined): string | null {
if (lower === "claude desktop") return "Claude Desktop"
if (lower === "claude code") return "Claude Code"
if (lower === "opencode") return "OpenCode"
if (lower === "cursor") return "Cursor"
if (lower === "openclaw") return "OpenClaw"
if (lower === "hermes") return "Hermes"
if (lower === "amp") return "Amp"
@ -136,6 +137,12 @@ function pluginIdentityFromSource(
label: "OpenCode",
iconSrc: "/images/plugins/opencode.svg",
}
case "cursor":
return {
pluginId: "cursor",
label: "Cursor",
iconSrc: "/images/plugins/cursor.png",
}
case "amp":
return {
pluginId: "amp",
@ -164,7 +171,19 @@ function pluginIdentityFromSpace(
.filter((tag): tag is string => typeof tag === "string" && !!tag)
: []
for (const tag of [...containerTags, ...memorySpaceTags]) {
const allTags = [...containerTags, ...memorySpaceTags]
for (const tag of allTags) {
if (/^cursor_(?:user|project)_[0-9a-f]{6,64}$/i.test(tag)) {
return {
pluginId: "cursor",
label: "Cursor",
iconSrc: "/images/plugins/cursor.png",
projectId: tag.split("_").at(-1)?.slice(0, 6),
}
}
}
for (const tag of allTags) {
const plugin = detectPluginSpace(tag)
if (plugin) return plugin
}
@ -380,7 +399,7 @@ function parseSessionTranscript(
content: string,
config: {
kind: "codex-session" | "amp-thread" | "plugin-session"
headerLabel: "Session" | "Amp thread"
headerLabel: "Session" | "Amp thread" | "Conversation"
pluginLabel: string
pluginIconSrc?: string | null
formatLabel: string
@ -424,6 +443,61 @@ function parseSessionTranscript(
}
}
function parseLegacyCursorTranscript(
content: string,
plugin: PluginIdentity | null,
): ParsedPluginDocument | null {
if (
plugin?.pluginId !== "cursor" ||
!/^Cursor IDE session transcript:\s*/i.test(content)
) {
return null
}
const transcript = content.replace(/^Cursor IDE session transcript:\s*/i, "")
const messages: PluginDocumentMessage[] = []
const regex =
/^(User|Assistant):\s*([\s\S]*?)(?=^(?:User|Assistant):\s*|(?![\s\S]))/gim
for (const match of transcript.matchAll(regex)) {
const role =
match[1]?.toLowerCase() === "user"
? ("user" as const)
: ("assistant" as const)
const text = match[2]?.trim()
if (!text) continue
messages.push({
id: `${role}-${messages.length}`,
role,
text,
})
}
if (messages.length === 0) return null
const userCount = messages.filter((message) => message.role === "user").length
const assistantCount = messages.filter(
(message) => message.role === "assistant",
).length
const previewSource =
messages.find((message) => message.role === "user")?.text ??
messages[0]?.text ??
"Conversation"
return {
kind: "plugin-session",
pluginLabel: plugin.label,
pluginIconSrc: plugin.iconSrc ?? undefined,
formatLabel: "Conversation",
title: "Cursor conversation",
preview: takePreview(previewSource, 140),
summary: `${userCount} user message${userCount === 1 ? "" : "s"} and ${assistantCount} assistant message${assistantCount === 1 ? "" : "s"} captured from Cursor.`,
artifacts: [],
messages,
sections: [],
rawContent: content,
}
}
function parseRoleBlockTranscript(
content: string,
plugin: PluginIdentity | null,
@ -710,6 +784,26 @@ export function parsePluginDocument(
}
}
if (plugin?.pluginId === "cursor") {
const cursorSession = parseSessionTranscript(content, {
kind: "plugin-session",
headerLabel: "Conversation",
pluginLabel: plugin.label,
pluginIconSrc: plugin.iconSrc,
formatLabel: "Conversation",
})
if (cursorSession) {
if (clientName) {
cursorSession.clientLabel = "Client"
cursorSession.clientValue = clientName
}
return withIcon(cursorSession)
}
const legacyCursorSession = parseLegacyCursorTranscript(content, plugin)
if (legacyCursorSession) return withIcon(legacyCursorSession)
}
if (plugin?.pluginId === "amp") {
const ampThread = parseSessionTranscript(content, {
kind: "amp-thread",

View file

@ -8,6 +8,7 @@ export type PluginSpaceInfo = {
| "openclaw"
| "opencode"
| "codex"
| "cursor"
| "amp"
| "hermes"
label: string
@ -27,7 +28,14 @@ const PLUGINS: PluginDef[] = [
id: "agents",
label: "Agents",
iconSrc: null,
prefixes: ["user_project", "repo", "claudecode", "codex"],
prefixes: [
"user_project",
"repo",
"claudecode",
"codex",
"opencode",
"cursor",
],
},
{
id: "openclaw",
@ -73,6 +81,7 @@ const PLUGIN_ICON_BY_LABEL: Record<string, string> = {
OpenClaw: "/images/plugins/openclaw.svg",
OpenCode: "/images/plugins/opencode.svg",
Codex: "/images/plugins/codex.png",
Cursor: "/images/plugins/cursor.png",
Hermes: "/images/plugins/hermes.svg",
}

View file

@ -62,5 +62,7 @@ export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault(
export const agentSourceParam = parseAsStringLiteral([
"claude-code",
"codex",
"opencode",
"cursor",
] as const)
export const projectParam = parseAsArrayOf(parseAsString, ",").withDefault([])

View file

@ -29,15 +29,10 @@ import {
UpdateContainerTagSettingsRequestSchema,
} from "../validation/api"
// Settings response schema - this is custom to console (not in shared validation)
const SettingsResponseSchema = z.object({
message: z.string(),
settings: z.object({
excludeItems: z.array(z.string().min(1).max(20)).optional(),
filterPrompt: z.string().min(1).max(750).optional(),
includeItems: z.array(z.string().min(1).max(20)).optional(),
shouldLLMFilter: z.boolean().optional(),
}),
const UpdateSettingsResponseSchema = z.object({
orgId: z.string(),
orgSlug: z.string(),
updated: SettingsRequestSchema,
})
// Analytics request schema - custom to console
@ -195,11 +190,11 @@ export const apiSchema = createSchema({
// Settings operations
"@get/settings": {
output: z.object({}).passthrough(),
output: SettingsRequestSchema,
},
"@patch/settings": {
input: SettingsRequestSchema,
output: SettingsResponseSchema,
output: UpdateSettingsResponseSchema,
},
"@post/settings/reset": {
input: z.object({ confirmation: z.string() }),

View file

@ -801,7 +801,7 @@ export const SettingsRequestSchema = OrganizationSettingsSchema.omit({
id: true,
orgId: true,
updatedAt: true,
})
}).partial()
export const ConnectionResponseSchema = z.object({
createdAt: z.string().datetime(),

View file

@ -315,6 +315,7 @@ export const OrganizationSettingsSchema = z.object({
filterPrompt: z.string().nullable().optional(),
includeItems: z.array(z.string()).nullable().optional(),
excludeItems: z.array(z.string()).nullable().optional(),
workspacePrompt: z.string().max(1500).nullable().optional(),
// Google Drive custom keys
googleDriveCustomKeyEnabled: z.boolean().default(false),