From ea2cf33fd3572d8ba9d4064127025093fddcb547 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Fri, 24 Jul 2026 03:18:39 +0000 Subject: [PATCH 01/14] fix(web): invite dialog focus trap inside settings modal (#1347) Portal the invite-teammate dialog into the settings modal and autofocus the email input, matching the delete-org dialog fix. Stacked body-portaled dialogs broke focus so the email field stopped accepting input after the first invite. Fixes ENG-1110 --- apps/web/components/settings/account.tsx | 13 ++++++++++++- apps/web/components/settings/settings-content.tsx | 4 +++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx index d054e608..99e9bf47 100644 --- a/apps/web/components/settings/account.tsx +++ b/apps/web/components/settings/account.tsx @@ -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(null) + const inviteEmailInputRef = useRef(null) const tagAnchorRef = useRef(null) const { allProjects: allContainerTags } = useContainerTags() @@ -877,6 +882,11 @@ export default function Account() { > { + event.preventDefault() + inviteEmailInputRef.current?.focus() + }} className="sm:max-w-[480px] border-none bg-[#1B1F24] p-0 gap-0 rounded-[22px] overflow-hidden" >
@@ -925,6 +935,7 @@ export default function Account() {
} > - {activeTab === "account" && } + {activeTab === "account" && ( + + )} {activeTab === "billing" && } {activeTab === "integrations" && } {activeTab === "connections" && } From e6857620124a51f346e3b5fe838d9a4a04444380 Mon Sep 17 00:00:00 2001 From: Vedant Mahajan Date: Fri, 24 Jul 2026 21:21:19 +0530 Subject: [PATCH 02/14] Add OpenCode to Agents spaces (#1354) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 --- apps/web/components/memories-grid.tsx | 42 +++++++++++++-------- apps/web/components/select-spaces-modal.tsx | 6 +-- apps/web/lib/agent-space.test.ts | 29 +++++++++++++- apps/web/lib/agent-space.ts | 41 ++++++++++++++------ apps/web/lib/plugin-space.ts | 2 +- apps/web/lib/search-params.ts | 1 + 6 files changed, 87 insertions(+), 34 deletions(-) diff --git a/apps/web/components/memories-grid.tsx b/apps/web/components/memories-grid.tsx index 5794cd19..beaee36c 100644 --- a/apps/web/components/memories-grid.tsx +++ b/apps/web/components/memories-grid.tsx @@ -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) => ( - - {filter.label} - - ({agentSourceCounts?.[filter.value] ?? 0}) - - - ))} + {AGENT_SOURCE_FILTERS.map((filter) => { + const count = agentSourceCounts?.[filter.value] + if (!count) return null + + return ( + + {filter.label} + ({count}) + + ) + })} )}
diff --git a/apps/web/components/select-spaces-modal.tsx b/apps/web/components/select-spaces-modal.tsx index 27db8ea0..f95321b0 100644 --- a/apps/web/components/select-spaces-modal.tsx +++ b/apps/web/components/select-spaces-modal.tsx @@ -95,7 +95,7 @@ type Category = { count: number } -const AGENT_CATALOG_IDS = ["claude_code", "codex"] as const +const AGENT_CATALOG_IDS = ["claude_code", "codex", "opencode"] as const export function SelectSpacesModal({ isOpen, @@ -1668,7 +1668,7 @@ function AgentsDiscoverPanel({ if (catalogIds.length === 0) { return (

- Claude Code and Codex are connected. + Claude Code, Codex, and OpenCode are connected.

) } @@ -1682,7 +1682,7 @@ function AgentsDiscoverPanel({

Agents

- Claude Code and Codex share project memory + Claude Code, Codex, and OpenCode share project memory

{catalogIds.map((catalogId) => { diff --git a/apps/web/lib/agent-space.test.ts b/apps/web/lib/agent-space.test.ts index f413fa81..9082c21c 100644 --- a/apps/web/lib/agent-space.test.ts +++ b/apps/web/lib/agent-space.test.ts @@ -7,7 +7,7 @@ import { } from "./agent-space" describe("Agents spaces", () => { - it("recognizes only Claude and Codex shared and legacy tags", () => { + it("recognizes Claude, Codex, and OpenCode shared and legacy tags", () => { expect(isAgentContainerTag("repo_supermemory__0123456789abcdef")).toBe(true) expect(isAgentContainerTag("user_project_0123456789abcdef")).toBe(true) expect(isAgentContainerTag("repo_supermemory")).toBe(true) @@ -16,7 +16,8 @@ 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) }) it("shows agent filters only for an Agents selection", () => { @@ -38,6 +39,7 @@ describe("Agents spaces", () => { "claude-code-plugin", ]) expect(agentSourceValues("codex")).toEqual(["codex"]) + expect(agentSourceValues("opencode")).toEqual(["opencode"]) expect(agentSourceValues(null)).toBeUndefined() }) @@ -46,6 +48,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 +72,7 @@ describe("Agents spaces", () => { "claudecode_project_0123456789abcdef", "repo_supermemory", "codex_project_0123456789abcdef", + "opencode_project_0123456789abcdef", ]) }) @@ -110,10 +114,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()) diff --git a/apps/web/lib/agent-space.ts b/apps/web/lib/agent-space.ts index 74693af9..7f78f7bd 100644 --- a/apps/web/lib/agent-space.ts +++ b/apps/web/lib/agent-space.ts @@ -5,7 +5,7 @@ export type AgentContainerKind = | "legacy-personal" | "legacy-project" -export type AgentSourceFilter = "claude-code" | "codex" +export type AgentSourceFilter = "claude-code" | "codex" | "opencode" export const AGENT_SOURCE_FILTERS: ReadonlyArray<{ value: AgentSourceFilter @@ -18,6 +18,7 @@ export const AGENT_SOURCE_FILTERS: ReadonlyArray<{ sources: ["claude-code", "claude-code-plugin"], }, { value: "codex", label: "Codex", sources: ["codex"] }, + { value: "opencode", label: "OpenCode", sources: ["opencode"] }, ] export type AgentSpaceMetadata = { @@ -54,6 +55,14 @@ 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, + }, ] function matchAgentTag(containerTag: string): { @@ -137,13 +146,17 @@ 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 and OpenCode personal containers were intentionally 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_") + ) { + const agent = containerTag.startsWith("codex_user_") ? "Codex" : "OpenCode" return { key: `legacy-personal:${containerTag}`, - label: "Legacy Codex personal", + label: `Legacy ${agent} personal`, kind: "legacy-personal", } } @@ -159,7 +172,8 @@ function legacyGroupIdentity( 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 +225,9 @@ function addProjectToGroup( } /** - * 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 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( projects: T[], @@ -256,9 +270,12 @@ export function groupAgentSpaces( 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, diff --git a/apps/web/lib/plugin-space.ts b/apps/web/lib/plugin-space.ts index af04eea6..ba667273 100644 --- a/apps/web/lib/plugin-space.ts +++ b/apps/web/lib/plugin-space.ts @@ -27,7 +27,7 @@ const PLUGINS: PluginDef[] = [ id: "agents", label: "Agents", iconSrc: null, - prefixes: ["user_project", "repo", "claudecode", "codex"], + prefixes: ["user_project", "repo", "claudecode", "codex", "opencode"], }, { id: "openclaw", diff --git a/apps/web/lib/search-params.ts b/apps/web/lib/search-params.ts index 661a5ed4..b900352b 100644 --- a/apps/web/lib/search-params.ts +++ b/apps/web/lib/search-params.ts @@ -62,5 +62,6 @@ export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault( export const agentSourceParam = parseAsStringLiteral([ "claude-code", "codex", + "opencode", ] as const) export const projectParam = parseAsArrayOf(parseAsString, ",").withDefault([]) From 4aa044fe55729f4f97b60f7070350de3724994f0 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:01:51 +0000 Subject: [PATCH 03/14] fix(mcp): respect readable scope for unscoped recall (#1357) Unscoped recall forced sm_project_default even when the caller could only read another organization space, causing a misleading 403 while list and graph operations succeeded. Let the search API choose the caller's readable scope, skip profile enrichment when no concrete scope is selected, and retain upstream error details. Validated with Biome, Vite production build, and Wrangler deploy dry-run. --- apps/mcp/src/client.ts | 48 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/mcp/src/client.ts b/apps/mcp/src/client.ts index 2ce8d41a..4e01a479 100644 --- a/apps/mcp/src/client.ts +++ b/apps/mcp/src/client.ts @@ -130,6 +130,7 @@ interface SDKResult { export class SupermemoryClient { private client: Supermemory private containerTag: string + private hasExplicitContainerTag: boolean private bearerToken: string private apiUrl: string @@ -145,6 +146,7 @@ export class SupermemoryClient { baseURL: apiUrl, timeout: FETCH_TIMEOUT_MS, }) + this.hasExplicitContainerTag = Boolean(containerTag) this.containerTag = containerTag || DEFAULT_PROJECT_ID } @@ -166,7 +168,7 @@ export class SupermemoryClient { containerTag: this.containerTag, } } catch (error) { - this.handleError(error) + this.handleOperationError("Create memory request", error) } } @@ -201,7 +203,13 @@ export class SupermemoryClient { // Fallback to semantic search if exact match fails const SIMILARITY_THRESHOLD = 0.85 // High threshold - only very similar memories - const searchResult = await this.search(content, 5, SIMILARITY_THRESHOLD) + const searchResult = await this.search( + content, + 5, + SIMILARITY_THRESHOLD, + undefined, + this.containerTag, + ) if (searchResult.results.length === 0) { return { @@ -236,7 +244,7 @@ export class SupermemoryClient { containerTag: this.containerTag, } } catch (error) { - this.handleError(error) + this.handleOperationError("Forget memory request", error) } } @@ -246,12 +254,16 @@ export class SupermemoryClient { limit = 10, threshold?: number, options?: SearchOptions, + 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: options?.searchMode ?? "hybrid", threshold, // Optional threshold parameter rerank: options?.rerank, @@ -284,12 +296,21 @@ export class SupermemoryClient { timing: result.timing, } } catch (error) { - this.handleError(error) + this.handleOperationError("Search request", error) } } // Get user profile using SDK async getProfile(query?: string): Promise { + if (!this.hasExplicitContainerTag) { + return { + profile: { + static: [], + dynamic: [], + }, + } + } + try { const result = await this.client.profile({ containerTag: this.containerTag, @@ -325,7 +346,7 @@ export class SupermemoryClient { return response } catch (error) { - this.handleError(error) + this.handleOperationError("Profile request", error) } } @@ -432,7 +453,8 @@ export class SupermemoryClient { throw new Error("Memory limit reached. Upgrade at supermemory.ai") case 403: throw new Error( - "Access forbidden. Your account may be restricted or blocked.", + message || + "Access forbidden. Your account may be restricted or blocked.", ) case 404: throw new Error("Memory not found. It may have been deleted.") @@ -457,4 +479,16 @@ export class SupermemoryClient { // Wrap unknown errors throw new Error(`An unexpected error occurred: ${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}`) + } + } } From c7dc49decf572ebd6dbfd924db36ec0413237691 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:00:58 +0000 Subject: [PATCH 04/14] feat(web): add Slack account linking confirmation (#1359) ## Stack Context This is the Nova UI half of Slack account linking. The paired API and database work is [mono#2650](https://github.com/supermemoryai/mono/pull/2650). ## What? - Preview the Slack and signed-in Supermemory identities before linking. - Block accounts that are not already organization members. - Require explicit confirmation when replacing an existing mapping. - Handle account switching and invalid, expired, used, success, and retry states. ## Why? Users whose Slack and Supermemory emails differ need a clear, secure way to confirm their identity. The page makes the identities and organization membership requirement explicit before creating or replacing a stable mapping. --- apps/web/app/slack/link/page.tsx | 536 +++++++++++++++++++++++++++++++ 1 file changed, 536 insertions(+) create mode 100644 apps/web/app/slack/link/page.tsx 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 + + + )} +
+ ) +} From 80af8c904397786055735531df0e63337b6d6d82 Mon Sep 17 00:00:00 2001 From: Dhravya Date: Sat, 25 Jul 2026 03:46:17 +0000 Subject: [PATCH 05/14] fix(web): use organization branding in Company Brain shares (#1360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Company Brain share snapshots now use the active organization’s branding instead of the signed-in user’s personal name. ## Changes - Detect Company Brain workspaces through the canonical hook. - Render the normalized organization name with `Company Brain`, preserving personal share branding for other workspaces. - Handle missing, organization-suffixed, and already-possessive organization names. ## Testing - **Passed:** `node_modules/.bin/biome ci apps/web/components/share-modal.tsx` - **Passed:** `git diff --check -- apps/web/components/share-modal.tsx` - **Baseline failure:** `node_modules/.bin/tsc --noEmit --incremental false -p apps/web/tsconfig.json` reports existing workspace diagnostics. The only diagnostic in `share-modal.tsx` is present on `HEAD`; none reference the new branding code. - **Blocked:** authenticated visual verification was not performed because no authenticated browser state was available and no login retry was requested. - Blocker screenshot: - Blocked-flow recording: --- **Attached Images and Videos** ![share-personal.png](https://api.us1.vorflux.com/assets/artifacts/c3VwZXJtZW1vcnk6Zjo3NDI0.XIMCcXoYysALm_LIfpnvO2S9fa6ME5753QqP6-8JZQE.png) 🎥 [View recording: share-branding-walkthrough.webm](https://api.us1.vorflux.com/assets/artifacts/c3VwZXJtZW1vcnk6Zjo3NDI1.PLlwgZToeGPRdV49tEQESgrTiADIUq5pHQcQjzfGMQ0.mp4) --- **Session Details** - Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/22b72a15-9127-44be-b15b-3d341e26d016) - Requested by: Dhravya Shah (dhravya@supermemory.com) - Address comments on this PR. Add `(aside)` to your comment to have me ignore it. --- > [!NOTE] > **Low Risk** > Copy-only UI in the share preview with no auth, API, or data-handling changes. > > **Overview** > Share snapshot previews now switch branding when the workspace is **Company Brain**, instead of always showing the signed-in user’s name and **supermemory**. > > The modal uses `useHasCompanyBrain` and org data from auth to set the preview header: a normalized org possessive label (with fallbacks for missing names, trailing “organization”, and names that already end in `'s`) plus **Company Brain** as the product line. Non–Company Brain workspaces keep the existing personal **supermemory** branding. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 087019f357b8c9c028bd2e9e915a550bcce24573. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- apps/web/components/share-modal.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/web/components/share-modal.tsx b/apps/web/components/share-modal.tsx index 877d82ac..091a9446 100644 --- a/apps/web/components/share-modal.tsx +++ b/apps/web/components/share-modal.tsx @@ -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("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 => { if (!previewRef.current) return null @@ -439,10 +450,10 @@ export function ShareModal({
- {userName} + {ownerLabel} - supermemory + {productName}
From 6f3c835e8f9f732d6abab3e2e7a137729f58fbbf Mon Sep 17 00:00:00 2001 From: ved015 <122012786+ved015@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:09:28 +0000 Subject: [PATCH 06/14] feat(web): add Cursor to Agents (#1361) Adds Cursor projects to Agents spaces with source filters, legacy labels, icons, and Codex-style structured conversation rendering. Tests: targeted Agents and plugin-document tests. --- apps/web/components/select-spaces-modal.tsx | 7 +- apps/web/lib/agent-space.test.ts | 26 +++++- apps/web/lib/agent-space.ts | 43 +++++++-- apps/web/lib/plugin-document.test.ts | 48 ++++++++++ apps/web/lib/plugin-document.ts | 98 ++++++++++++++++++++- apps/web/lib/plugin-space.ts | 11 ++- apps/web/lib/search-params.ts | 1 + 7 files changed, 221 insertions(+), 13 deletions(-) diff --git a/apps/web/components/select-spaces-modal.tsx b/apps/web/components/select-spaces-modal.tsx index f95321b0..fa283b3b 100644 --- a/apps/web/components/select-spaces-modal.tsx +++ b/apps/web/components/select-spaces-modal.tsx @@ -95,7 +95,12 @@ type Category = { count: number } -const AGENT_CATALOG_IDS = ["claude_code", "codex", "opencode"] as const +const AGENT_CATALOG_IDS = [ + "claude_code", + "codex", + "opencode", + "cursor", +] as const export function SelectSpacesModal({ isOpen, diff --git a/apps/web/lib/agent-space.test.ts b/apps/web/lib/agent-space.test.ts index 9082c21c..857072b8 100644 --- a/apps/web/lib/agent-space.test.ts +++ b/apps/web/lib/agent-space.test.ts @@ -7,7 +7,7 @@ import { } from "./agent-space" describe("Agents spaces", () => { - it("recognizes Claude, Codex, and OpenCode 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) @@ -18,6 +18,8 @@ describe("Agents spaces", () => { expect(isAgentContainerTag("codex_user_0123456789abcdef")).toBe(true) 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", () => { @@ -40,6 +42,7 @@ describe("Agents spaces", () => { ]) expect(agentSourceValues("codex")).toEqual(["codex"]) expect(agentSourceValues("opencode")).toEqual(["opencode"]) + expect(agentSourceValues("cursor")).toEqual(["cursor"]) expect(agentSourceValues(null)).toBeUndefined() }) @@ -148,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") + }) }) diff --git a/apps/web/lib/agent-space.ts b/apps/web/lib/agent-space.ts index 7f78f7bd..16044946 100644 --- a/apps/web/lib/agent-space.ts +++ b/apps/web/lib/agent-space.ts @@ -5,7 +5,7 @@ export type AgentContainerKind = | "legacy-personal" | "legacy-project" -export type AgentSourceFilter = "claude-code" | "codex" | "opencode" +export type AgentSourceFilter = "claude-code" | "codex" | "opencode" | "cursor" export const AGENT_SOURCE_FILTERS: ReadonlyArray<{ value: AgentSourceFilter @@ -19,6 +19,7 @@ export const AGENT_SOURCE_FILTERS: ReadonlyArray<{ }, { value: "codex", label: "Codex", sources: ["codex"] }, { value: "opencode", label: "OpenCode", sources: ["opencode"] }, + { value: "cursor", label: "Cursor", sources: ["cursor"] }, ] export type AgentSpaceMetadata = { @@ -63,6 +64,14 @@ const TAG_PATTERNS: Array<{ 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): { @@ -127,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": @@ -146,17 +155,25 @@ function legacyGroupIdentity( return { key: `tag:${containerTag}`, label: containerTag, kind: "project" } } - // Old Codex and OpenCode personal containers were intentionally global. + // 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("opencode_user_") || + containerTag.startsWith("cursor_user_") ) { - const agent = containerTag.startsWith("codex_user_") ? "Codex" : "OpenCode" + const agent = containerTag.startsWith("codex_user_") + ? "Codex" + : containerTag.startsWith("opencode_user_") + ? "OpenCode" + : "Cursor" return { key: `legacy-personal:${containerTag}`, - label: `Legacy ${agent} personal`, + label: + agent === "Cursor" + ? `Cursor personal · ${match.id.slice(0, 6)}` + : `Legacy ${agent} personal`, kind: "legacy-personal", } } @@ -169,6 +186,14 @@ 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_") || @@ -225,7 +250,7 @@ function addProjectToGroup( } /** - * Collapse the physical Claude/Codex/OpenCode containers into one selectable + * 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. */ @@ -280,7 +305,9 @@ export function groupAgentSpaces( addProjectToGroup( grouped, key, - projectName ?? identity.label, + identity.kind === "legacy-personal" + ? identity.label + : (projectName ?? identity.label), identity.kind, project, projectName, diff --git a/apps/web/lib/plugin-document.test.ts b/apps/web/lib/plugin-document.test.ts index 2fa58a07..992895f5 100644 --- a/apps/web/lib/plugin-document.test.ts +++ b/apps/web/lib/plugin-document.test.ts @@ -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( diff --git a/apps/web/lib/plugin-document.ts b/apps/web/lib/plugin-document.ts index 1606311f..8a80fbdb 100644 --- a/apps/web/lib/plugin-document.ts +++ b/apps/web/lib/plugin-document.ts @@ -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", diff --git a/apps/web/lib/plugin-space.ts b/apps/web/lib/plugin-space.ts index ba667273..6f5194b6 100644 --- a/apps/web/lib/plugin-space.ts +++ b/apps/web/lib/plugin-space.ts @@ -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", "opencode"], + prefixes: [ + "user_project", + "repo", + "claudecode", + "codex", + "opencode", + "cursor", + ], }, { id: "openclaw", @@ -73,6 +81,7 @@ const PLUGIN_ICON_BY_LABEL: Record = { 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", } diff --git a/apps/web/lib/search-params.ts b/apps/web/lib/search-params.ts index b900352b..5aff8218 100644 --- a/apps/web/lib/search-params.ts +++ b/apps/web/lib/search-params.ts @@ -63,5 +63,6 @@ export const agentSourceParam = parseAsStringLiteral([ "claude-code", "codex", "opencode", + "cursor", ] as const) export const projectParam = parseAsArrayOf(parseAsString, ",").withDefault([]) From 7e182fca9a2e119fbe51ce56b8d4b5dd9cf7567f Mon Sep 17 00:00:00 2001 From: Nolan Selby Date: Sun, 26 Jul 2026 13:06:25 -0700 Subject: [PATCH 07/14] fix(web): update ChatGPT MCP setup instructions (#1358) Co-authored-by: Cursor --- apps/web/components/connect-ai-modal.tsx | 20 ++++++++++++--- .../components/mcp-modal/mcp-detail-view.tsx | 25 +++++++++++++------ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/apps/web/components/connect-ai-modal.tsx b/apps/web/components/connect-ai-modal.tsx index c4b85e7a..2e590894 100644 --- a/apps/web/components/connect-ai-modal.tsx +++ b/apps/web/components/connect-ai-modal.tsx @@ -650,15 +650,29 @@ export function ConnectAIModal({ if (manual.kind === "chatgpt") { return (
+

+ Write-capable custom MCP apps are only supported on + Business & Enterprise plans. +

  1. Open ChatGPT in your browser.
  2. - Settings → Apps → Advanced settings → enable - Developer mode. + Go to Settings → Security and Login → scroll to + the bottom to enable Developer mode.
  3. - Create an app and paste the MCP URL when asked. + Go to{" "} + + chatgpt.com/plugins + + .
  4. +
  5. Paste the URL below.
  6. Complete OAuth in ChatGPT.
diff --git a/apps/web/components/mcp-modal/mcp-detail-view.tsx b/apps/web/components/mcp-modal/mcp-detail-view.tsx index cfa9ddf3..15b4f2f5 100644 --- a/apps/web/components/mcp-modal/mcp-detail-view.tsx +++ b/apps/web/components/mcp-modal/mcp-detail-view.tsx @@ -502,19 +502,30 @@ export function MCPSteps({ variant = "full" }: MCPStepsProps) { if (manual.kind === "chatgpt") { return (
+

+ Write-capable custom MCP apps are only supported on + Business & Enterprise plans. +

  1. Open ChatGPT in your browser.
  2. - Go to Settings → Apps → Advanced settings → enable - Developer mode. + Go to Settings → Security and Login → scroll to the + bottom to enable Developer mode.
  3. - Create an app and choose your MCP server URL when - asked. -
  4. -
  5. - Paste the URL below and complete OAuth in ChatGPT. + Go to{" "} + + chatgpt.com/plugins + + .
  6. +
  7. Paste the URL below.
  8. +
  9. Complete OAuth in ChatGPT.
Date: Sun, 26 Jul 2026 14:45:47 -0700 Subject: [PATCH 08/14] fix --- apps/docs/index.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 8a352dca81647a4b617e5228398d07291fa9f402 Mon Sep 17 00:00:00 2001 From: Dhravya Shah Date: Mon, 27 Jul 2026 20:34:45 -0700 Subject: [PATCH 09/14] fix(web): allow custom MCP connections (#1371) --- .../settings/company-brain-connections.tsx | 35 +++++++------------ 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/apps/web/components/settings/company-brain-connections.tsx b/apps/web/components/settings/company-brain-connections.tsx index ae1489a4..e0e3185f 100644 --- a/apps/web/components/settings/company-brain-connections.tsx +++ b/apps/web/components/settings/company-brain-connections.tsx @@ -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(null) const [catalogLoaded, setCatalogLoaded] = useState(false) const [rows, setRows] = useState([]) @@ -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 ? ( - - ) : null} + )}
From ac880a4dc6ef8bdae62c8ccccf790c5f05580288 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:25:04 +0000 Subject: [PATCH 10/14] feat(web): surface Company Brain to personal-brain users (#1370) Adds a dismissible Company Brain card to the dashboard header slot and a permanent entry in the profile menu for users whose org has no company brain, both linking to team onboarding. Onboarding now honours ?mode=team so a personal-domain email arriving from those CTAs isn't routed to personal onboarding. Fixes ENG-1132 --- apps/web/app/(app)/onboarding/page.tsx | 18 +++-- apps/web/components/app-experience.tsx | 3 +- apps/web/components/company-brain-promo.tsx | 89 +++++++++++++++++++++ apps/web/components/user-profile-menu.tsx | 14 ++++ apps/web/lib/analytics.ts | 8 ++ 5 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 apps/web/components/company-brain-promo.tsx diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx index fef83c39..0bd70af1 100644 --- a/apps/web/app/(app)/onboarding/page.tsx +++ b/apps/web/app/(app)/onboarding/page.tsx @@ -64,6 +64,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 +76,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 +119,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 +215,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) 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/company-brain-promo.tsx b/apps/web/components/company-brain-promo.tsx new file mode 100644 index 00000000..730eeb08 --- /dev/null +++ b/apps/web/components/company-brain-promo.tsx @@ -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 ( +
+
+ +
+
+

+ 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/user-profile-menu.tsx b/apps/web/components/user-profile-menu.tsx index b69701f2..ea034d73 100644 --- a/apps/web/components/user-profile-menu.tsx +++ b/apps/web/components/user-profile-menu.tsx @@ -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 + {isCompanyBrain ? null : ( + { + 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" + > + + Set up Company Brain + + )} {isCompanyBrain ? ( void setViewMode("configure")} diff --git a/apps/web/lib/analytics.ts b/apps/web/lib/analytics.ts index eebd1697..f160e612 100644 --- a/apps/web/lib/analytics.ts +++ b/apps/web/lib/analytics.ts @@ -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"), } From db7f5c3f649736a659a5766132fcaff45ac2695c Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Tue, 28 Jul 2026 06:14:22 +0000 Subject: [PATCH 11/14] fix(web): select the correct Company Brain workspace (#1372) ## What changed - Make `/brain` reuse the active Company Brain, switch to a single existing Company Brain, show a picker for multiple choices, or create one only when none exists. - Wait for active-organization restoration before making that decision. - Make Company Brain onboarding match organizations by the confirmed company domain and never offer unrelated-domain workspaces. - Create a new Company Brain when no matching workspace exists and show an actionable workspace-limit toast when creation is blocked. - Improve the organization picker, loading, and error states. ## Why The previous flows could use or mutate the currently active normal organization, start research against stale Company Brain metadata, or offer unrelated Company Brain workspaces after a quota failure. ## Impact Normal organizations are no longer silently converted. Research is scoped to the Company Brain for the confirmed domain, and users receive a clear recovery path when they reach their workspace limit. This keeps the existing `{ domain }` research API contract; no organization-reconfiguration API is required. ## Validation - Biome checks passed for all five changed web files - Targeted web TypeScript diagnostics reported no errors for the changed files - React Doctor against `origin/main`: no issues found - `git diff --check` No test files were added. --- apps/web/app/(app)/brain/page.tsx | 246 +++++++++++++++--- apps/web/app/(app)/onboarding/page.tsx | 99 ++++++- .../company-brain-onboarding.tsx | 120 +++++++-- apps/web/components/onboarding-brain/types.ts | 7 +- apps/web/lib/company-brain-entry.ts | 81 ++++++ 5 files changed, 493 insertions(+), 60 deletions(-) create mode 100644 apps/web/lib/company-brain-entry.ts 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 0bd70af1..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() @@ -245,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 @@ -328,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("/") @@ -342,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) @@ -353,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. @@ -393,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 { @@ -402,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/components/onboarding-brain/company-brain-onboarding.tsx b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx index 2d11ea48..d510d462 100644 --- a/apps/web/components/onboarding-brain/company-brain-onboarding.tsx +++ b/apps/web/components/onboarding-brain/company-brain-onboarding.tsx @@ -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 + onConfirm: ( + domain: string, + organizationId?: string, + ) => Promise onDone: () => void onUsePersonal: () => void } @@ -72,6 +83,9 @@ export function CompanyBrainOnboarding({ }: CompanyBrainOnboardingProps) { const [phase, setPhase] = useState("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) - 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 }} > - + {organizationChoices ? ( + handleConfirm(organizationId)} + onBack={() => setOrganizationChoices(null)} + /> + ) : ( + handleConfirm()} + submitting={submitting} + /> + )} ) : ( - {phase === "confirm" && ( + {phase === "confirm" && !organizationChoices && (
+ ))} +
+ + + ) +} + function ConfirmBody({ firstName, name, diff --git a/apps/web/components/onboarding-brain/types.ts b/apps/web/components/onboarding-brain/types.ts index 56cff5dd..330914dc 100644 --- a/apps/web/components/onboarding-brain/types.ts +++ b/apps/web/components/onboarding-brain/types.ts @@ -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" diff --git a/apps/web/lib/company-brain-entry.ts b/apps/web/lib/company-brain-entry.ts new file mode 100644 index 00000000..94ff5b33 --- /dev/null +++ b/apps/web/lib/company-brain-entry.ts @@ -0,0 +1,81 @@ +import { + getBrainMode, + getBrainWorkspaceDomain, + getCompanyBrainOverride, + hasCompanyBrain, +} from "./billing-utils" + +export type BrainEntryOrganization = { + id: string + name: string + slug: string + metadata?: Record | 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" } +} From 5fa0535a6419b31333f1744b297fe61fcf349bc8 Mon Sep 17 00:00:00 2001 From: ishaanxgupta <124028055+ishaanxgupta@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:58:50 +0000 Subject: [PATCH 12/14] Render Nova connector setup cards (#1071) ## Summary - Add custom assistant-message rendering for Nova connector tool results - Show integration-style setup/status cards with icon, status pill, setup steps, docs links, copy buttons, and key reveal/generate action - Keep plugin API keys client-side only by calling the existing `/v3/auth/key?client=...` endpoint from the UI - Add Nova empty-state suggestions for Cursor setup and active plugins --- apps/web/components/chat/chat-empty-state.tsx | 4 +- .../components/chat/message/agent-message.tsx | 601 +++++++++++++++++- apps/web/globals.css | 41 ++ 3 files changed, 643 insertions(+), 3 deletions(-) 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} + + +
  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 ( 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; From 8071a7b08539e2ff2497c625e37f03232f8fd031 Mon Sep 17 00:00:00 2001 From: vorflux <249966464+vorflux@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:13:59 +0000 Subject: [PATCH 13/14] Add Nova workspace prompt settings (#1323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated Workspace Prompt editor for Company Brain organizations while preserving the existing Organization Context ingestion-filter controls for every organization manager. ## Changes - Keeps Organization Context byte-for-byte unchanged and available independently to all organization managers. - Adds Workspace Prompt as a separate Company-Brain-only section below it, using the established settings styling and contextual divider. - Describes Workspace Prompt as persistent guidance that can shape operating preferences, priorities, source/tool choices, workflows, terminology, formatting, and communication style. - Adds nullable, 1,500-character `workspacePrompt` support to shared request, GET response, and PATCH response contracts. - Aligns PATCH validation with the real `{ orgId, orgSlug, updated }` API response. - Merges canonical `updated` settings into the submitting organization’s cache, then exactly refetches that organization. - Preserves drafts during background refetches, isolates organization switches, retains actionable errors, accessibility, empty `filterPrompt` compatibility, and `X-App-Source: nova`. ## Testing - Passed focused Biome checks on all changed files. - Passed `packages/lib` and `packages/validation` TypeScript checks. - Verified GET/PATCH settings response contracts, partial/null/limit validation, canonical cache merge, and exact organization-bound invalidation. - Verified Organization Context remains unchanged and Workspace Prompt is separately Company-Brain/manager-gated. - Confirmed no remaining Workspace Persona identifiers. - Public preview returns HTTP 200; authenticated settings interactions remain unavailable without a saved OAuth session. - Full web type-check remains blocked by unrelated baseline diagnostics; none reference changed files. - No dedicated tests were added, per requester instruction. --- **Session Details** - Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/7544b72b-aeca-48e2-81c3-514df21cd081) - Requested by: Soham Daga (soham@supermemory.com) - Address comments on this PR. Add `(aside)` to your comment to have me ignore it. --- apps/web/components/configure-view.tsx | 20 +- .../components/settings/workspace-prompt.tsx | 186 ++++++++++++++++++ apps/web/hooks/use-org-settings.ts | 48 ++--- packages/lib/api.ts | 17 +- packages/validation/api.ts | 2 +- packages/validation/schemas.ts | 1 + 6 files changed, 233 insertions(+), 41 deletions(-) create mode 100644 apps/web/components/settings/workspace-prompt.tsx diff --git a/apps/web/components/configure-view.tsx b/apps/web/components/configure-view.tsx index 0b75762e..c36ea5e4 100644 --- a/apps/web/components/configure-view.tsx +++ b/apps/web/components/configure-view.tsx @@ -1,15 +1,21 @@ "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 Proactiveness from "@/components/settings/proactiveness" +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" + | "automations" const SECTIONS: { id: ConfigureSection @@ -31,6 +37,13 @@ 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: "automations", label: "Automations", @@ -41,6 +54,7 @@ const SECTIONS: { ] export function ConfigureView() { + const { org } = useAuth() const [activeSection, setActiveSection] = useState("company-brain") const active = SECTIONS.find((section) => section.id === activeSection) @@ -118,6 +132,8 @@ export function ConfigureView() { ) : activeSection === "models" ? ( + ) : activeSection === "workspace-prompt" ? ( + ) : ( )} diff --git a/apps/web/components/settings/workspace-prompt.tsx b/apps/web/components/settings/workspace-prompt.tsx new file mode 100644 index 00000000..18e30d5e --- /dev/null +++ b/apps/web/components/settings/workspace-prompt.tsx @@ -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 ( +

+ {children} +

+ ) +} + +function PromptHeader() { + return ( +
+ Workspace Prompt +

+ Set persistent guidance for how Company Brain works across your + workspace. +

+
+ ) +} + +export function WorkspacePrompt({ + showHeading = true, +}: { + showHeading?: boolean +}) { + const isCompanyBrain = useHasCompanyBrain() + const { isAdmin } = useOrgMemberRole(isCompanyBrain) + const settingsQuery = useOrgSettings() + const updateSettings = useUpdateOrgSettings() + const [draft, setDraft] = useState(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 ( +
+ {showHeading ? : null} + + {settingsQuery.isLoading ? ( + + + ) : settingsQuery.isError ? ( +
+

+ Workspace prompt could not be loaded. +

+ +
+ ) : ( +
+