diff --git a/apps/docs/index.mdx b/apps/docs/index.mdx index 1a1da915..647f13e4 100644 --- a/apps/docs/index.mdx +++ b/apps/docs/index.mdx @@ -40,19 +40,19 @@ export const HeroCard = ({ imageUrl, title, description, href }) => {

Architecture Quickstart Set up your company brain diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index 9a3072b7..e66d53dc 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -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 { 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 { + 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}`) + } + } } diff --git a/apps/web/app/(app)/brain/page.tsx b/apps/web/app/(app)/brain/page.tsx index 95541281..3a96950d 100644 --- a/apps/web/app/(app)/brain/page.tsx +++ b/apps/web/app/(app)/brain/page.tsx @@ -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(null) + const [choices, setChoices] = useState(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 ( -
- {error ? ( - <> -

+ + {choices ? ( +

+

+ Choose your Company Brain +

+

+ You're a member of more than one workspace. Pick the one to open. +

+ +
+ {choices.map((organization) => ( + + ))} +
+ + {email && ( +

+ Signed in as {email} +

+ )} +
+ ) : error ? ( +
+
+ +
+

Couldn't set up your Company Brain

-

{error}

- - + +
) : ( - <> - -

- Setting up your Company Brain… +

+
+ + + +
+

+ Setting up your Company Brain

- +

+ Preparing your workspace, then we'll connect it to Slack. +

+
)} + + ) +} + +function EntryShell({ children }: { children: React.ReactNode }) { + return ( +
+
+
+
+ +
+
+ {children} +
) } diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index fef83c39..1cde19cf 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -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(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 => { - if (!forceCreate && organizations && organizations.length > 0) + async ( + domainOverride?: string, + createEvenIfExisting = false, + ): Promise => { + 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 => { + async ( + confirmedDomain: string, + organizationId?: string, + ): Promise => { 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) diff --git a/apps/web/app/slack/link/page.tsx b/apps/web/app/slack/link/page.tsx new file mode 100644 index 00000000..f91c3dd0 --- /dev/null +++ b/apps/web/app/slack/link/page.tsx @@ -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["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> { + return (await response.json().catch(() => ({}))) as Record +} + +export default function SlackAccountLinkPage() { + const params = useSearchParams() + const token = params.get("token") + const { session, user, isSessionPending } = useAuth() + const [state, setState] = useState({ 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 ( + + + {state.kind === "loading" ? ( + + +
+ +

+ Verifying your secure link… +

+
+
+
+ ) : null} + + {state.kind === "ready" || state.kind === "linking" ? ( + + +
+ +

+ {state.preview.isOrgMember + ? `Link Slack to ${state.preview.orgName}` + : `This account isn't in ${state.preview.orgName}`} +

+

+ {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}.`} +

+
+ +
+ +
+ +
+ + {state.preview.isOrgMember && state.preview.requiresRelink ? ( +

+ This Slack identity is linked to another Supermemory account. + Confirming will replace that link for {state.preview.orgName}. +

+ ) : null} + +
+ {state.preview.isOrgMember ? ( + <> + void switchAccount()} + > + Switch account + + void confirmLink(state.preview)} + > + {state.kind === "linking" ? ( + + ) : ( + <> + + {state.preview.requiresRelink + ? "Replace and link" + : "Confirm link"} + + )} + + + ) : ( + <> + + I've been added — check again + + void switchAccount()}> + Switch account + + + )} +
+ +

+ Signed in as {state.preview.signedInEmail} +

+ + + ) : null} + + {state.kind === "linked" ? ( + + +
+
+ +
+

+ Slack now knows who you are +

+

+ Your account is linked to {state.orgName}. Return to Slack and + retry your Company Brain request. +

+
+ Return to Slack + +
+ +
+ + + ) : null} + + {state.kind === "error" ? ( + + + void switchAccount()} + /> + + + ) : null} + + + ) +} + +function CardShell({ children }: { children: ReactNode }) { + return ( +
+
+
+ {children} +
+
+ ) +} + +function Card({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function Fade({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +function ConnectingHeader() { + return ( +
+
+ +
+
+ {["a", "b", "c"].map((k, i) => ( + + ))} +
+
+ +
+
+ ) +} + +function InfoRow({ + label, + name, + detail, + warn, +}: { + label: string + name: string + detail?: string + warn?: boolean +}) { + return ( +
+
+ + {label} + +

+ {name} +

+ {detail ? ( +

{detail}

+ ) : null} +
+ {warn ? ( + + Not a member + + ) : null} +
+ ) +} + +function TextButton({ + children, + onClick, + disabled, +}: { + children: ReactNode + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} + +function NeutralButton({ + children, + onClick, + disabled, +}: { + children: ReactNode + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} + +function GradientButton({ + children, + onClick, + disabled, +}: { + children: ReactNode + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} + +function ErrorState({ + reason, + onSwitchAccount, +}: { + reason: Extract["reason"] + onSwitchAccount: () => void +}) { + const copy = ERROR_COPY[reason] + return ( +
+
+ +
+

+ {copy.title} +

+

+ {copy.body} +

+ {reason === "not_in_org" ? ( +
+ + Switch account + +
+ ) : ( + + Return to Slack + + + )} +
+ ) +} diff --git a/apps/web/components/app-experience.tsx b/apps/web/components/app-experience.tsx index 28b93b18..42fedec9 100644 --- a/apps/web/components/app-experience.tsx +++ b/apps/web/components/app-experience.tsx @@ -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() { ) : ( } highlights={highlightsData?.highlights ?? []} isLoadingHighlights={isLoadingHighlights} onAddMemory={handleAddMemory} diff --git a/apps/web/components/chat/chat-empty-state.tsx b/apps/web/components/chat/chat-empty-state.tsx index 1fe230bf..bef9894a 100644 --- a/apps/web/components/chat/chat-empty-state.tsx +++ b/apps/web/components/chat/chat-empty-state.tsx @@ -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( diff --git a/apps/web/components/chat/message/agent-message.tsx b/apps/web/components/chat/message/agent-message.tsx index eac0f7b9..d9e490f7 100644 --- a/apps/web/components/chat/message/agent-message.tsx +++ b/apps/web/components/chat/message/agent-message.tsx @@ -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 = { + 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 + 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 ( + + {copy.label} + + ) +} + +function MiniCopyButton({ text, label }: { text: string; label?: string }) { + const [copied, setCopied] = useState(false) + return ( + + ) +} + +function ConnectorCodeBlock({ + code, + apiKey, +}: { + code: string + apiKey?: string +}) { + const rendered = apiKey ? code.replaceAll("sm_...", apiKey) : code + return ( +
+
+				{rendered}
+			
+ +
+ ) +} + +function RevealPluginKeyButton({ + pluginId, + onReveal, +}: { + pluginId: string + onReveal: (key: string) => void +}) { + const [state, setState] = useState<"idle" | "loading" | "copied" | "error">( + "idle", + ) + return ( + + ) +} + +function NovaConnectorCard({ + connector, +}: { + connector: NovaConnectorCardData +}) { + const [revealedKey, setRevealedKey] = useState() + const needsKey = Boolean(connector.canGenerateKey && connector.keyPluginId) + const isUpgrade = connector.status === "upgrade_required" + const iconSrc = connectorIconSrc(connector) + return ( +
+
+
+ {iconSrc ? ( + { + 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" + } + }} + /> + ) : ( + + )} +
+
+
+

+ {connector.name ?? connector.id ?? "Connector"} +

+ +
+ {connector.description ? ( +

+ {connector.description} +

+ ) : null} +
+
+ + {connector.installSteps?.length ? ( +
    + {connector.installSteps.map((step, index) => ( +
  1. + + {index + 1} + +
    +

    + {step.title} +

    + {step.description ? ( +

    + {step.description} +

    + ) : null} + {step.code ? ( + + ) : null} + {step.link ? ( + + {step.link.label} + + + ) : null} +
    +
  2. + ))} +
+ ) : null} + +
+ {needsKey && connector.keyPluginId && !isUpgrade ? ( + + ) : null} + {isUpgrade ? ( + + + Upgrade to connect + + ) : null} + {connector.docsUrl ? ( + + + Docs + + ) : null} +
+
+ ) +} + +function NovaConnectorCompactCard({ + connector, + expanded, + onToggle, +}: { + connector: NovaConnectorCardData + expanded: boolean + onToggle: () => void +}) { + const iconSrc = connectorIconSrc(connector) + return ( +
+ +
+ ) +} + +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 ( +
+ + Checking Supermemory setup… +
+ ) + } + if (isError) { + return ( +
+ Couldn't load connector setup. +
+ ) + } + if (!output) return null + if (output.success === false) { + return ( +
+

+ {output.error ?? "Connector not found"} +

+ {output.available?.length ? ( +

+ Try one of: {output.available.map((item) => item.name).join(", ")} +

+ ) : null} +
+ ) + } + 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 ( +
+ {isConnectorList ? ( +

+ Supermemory setup options +

+ ) : null} +
+ {connectors.map((connector) => + isConnectorList ? ( + { + const nextKey = connectorCardKey(connector) + setExpandedConnectorKey((current) => + current === nextKey ? null : nextKey, + ) + }} + /> + ) : ( + + ), + )} +
+ {expandedConnector ? ( +
+ +
+ ) : null} +
+ ) +} + 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 + } if (toolName === "bash") { return } @@ -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 ( { + 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 ( +
+
+ +
+
+

+ Give your team a Company Brain +

+

+ Lives in your Slack. Answers from your team's tools, and brings things + up before you ask. +

+
+ + +
+ ) +} diff --git a/apps/web/components/configure-view.tsx b/apps/web/components/configure-view.tsx index 0b75762e..21206920 100644 --- a/apps/web/components/configure-view.tsx +++ b/apps/web/components/configure-view.tsx @@ -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("company-brain") const active = SECTIONS.find((section) => section.id === activeSection) @@ -48,16 +72,13 @@ export function ConfigureView() { return (
-
+