Merge origin/main into fix/openai-sdk-python-v4-api

Resolve pyproject.toml conflict by keeping supermemory>=3.50.0
(required for v4 API migration) and main's package version 1.0.5.

Co-authored-by: Dhravya Shah <dhravya@supermemory.com>
This commit is contained in:
Cursor Agent 2026-08-18 15:20:49 +00:00
commit f68bd30262
No known key found for this signature in database
67 changed files with 1649 additions and 447 deletions

View file

@ -17,6 +17,7 @@ import {
acceptMemorySuggestion,
clearMemorySuggestion,
hasAcceptedSupermemoryContext,
serializeMemoriesForDataset,
setMemoryMarkerStatus,
showLoadingSuggestion,
showMarkerPopover,
@ -212,7 +213,9 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
memoryLength: memoryText.length,
})
iconElement.dataset.memoriesData = String(response.data)
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
response.data,
)
if (isAutoSearch) {
setMemoryMarkerStatus(iconElement, "found")

View file

@ -17,6 +17,7 @@ import {
acceptMemorySuggestion,
clearMemorySuggestion,
hasAcceptedSupermemoryContext,
serializeMemoriesForDataset,
setMemoryMarkerStatus,
showLoadingSuggestion,
showMarkerPopover,
@ -459,7 +460,9 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
memoryLength: memoryText.length,
})
iconElement.dataset.memoriesData = String(response.data)
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
response.data,
)
if (isAutoSearch) {
setMemoryMarkerStatus(iconElement, "found")

View file

@ -17,6 +17,7 @@ import {
acceptMemorySuggestion,
clearMemorySuggestion,
hasAcceptedSupermemoryContext,
serializeMemoriesForDataset,
setMemoryMarkerStatus,
showLoadingSuggestion,
showMarkerPopover,
@ -417,7 +418,9 @@ async function getRelatedMemoriesForGemini(actionSource: string) {
if (response?.success && response?.data && input) {
const memoryText = showMemorySuggestion("gemini", input, response.data)
iconElement.dataset.memoriesData = String(response.data)
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
response.data,
)
iconElement.dataset.supermemories = memoryText
if (isAutoSearch) {
setMemoryMarkerStatus(iconElement, "found")

View file

@ -12,6 +12,46 @@ export function buildSupermemoryText(memories: unknown): string {
return `\n\n${SUPERMEMORY_PREFIX} ${memoryText}`
}
function normalizeMemoryList(memories: unknown): string[] {
const list = Array.isArray(memories)
? memories
: memories == null
? []
: [memories]
return list
.map((memory) => (typeof memory === "string" ? memory : String(memory)))
.map((memory) => memory.trim())
.filter((memory) => memory.length > 0)
}
export function serializeMemoriesForDataset(memories: unknown): string {
const list = normalizeMemoryList(memories)
return list.length > 0 ? JSON.stringify(list) : ""
}
export function parseMemoriesFromDataset(
raw: string | null | undefined,
): string[] {
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return normalizeMemoryList(parsed)
} catch {
// Not JSON — fall through to the legacy delimiter split.
}
return raw
.split(/[,\n]/)
.map((memory) => memory.trim())
.filter((memory) => memory.length > 0 && memory !== ",")
}
export function renumberIncludedMemories(memories: string[]): string[] {
return memories.map((memory, index) => {
const text = memory.replace(/^\d+\.\s*/, "").replace(/\s+$/, "")
return `${index + 1}. ${text} \n`
})
}
export function showMemorySuggestion(
platform: string,
input: SuggestionInput,
@ -305,10 +345,7 @@ export function showMarkerPopover(
color: rgba(255, 255, 255, 0.76);
`
memories
.split(/[,\n]/)
.map((memory) => memory.trim())
.filter((memory) => memory.length > 0 && memory !== ",")
parseMemoriesFromDataset(memories)
.slice(0, 5)
.forEach((memory) => {
const item = document.createElement("div")

View file

@ -10,11 +10,30 @@ import {
autoCapturePromptsEnabled,
} from "../../utils/storage"
import { createT3InputBarElement, DOMUtils } from "../../utils/ui-components"
import {
buildSupermemoryText,
parseMemoriesFromDataset,
renumberIncludedMemories,
serializeMemoriesForDataset,
} from "./memory-suggestion"
let t3DebounceTimeout: NodeJS.Timeout | null = null
let t3RouteObserver: MutationObserver | null = null
let t3UrlCheckInterval: NodeJS.Timeout | null = null
let t3ObserverThrottle: NodeJS.Timeout | null = null
let t3IncludedPopup: {
el: HTMLElement
onClick: (event: MouseEvent) => void
timer: ReturnType<typeof setTimeout>
} | null = null
function disposeT3IncludedPopup() {
if (!t3IncludedPopup) return
document.removeEventListener("click", t3IncludedPopup.onClick)
clearTimeout(t3IncludedPopup.timer)
t3IncludedPopup.el.remove()
t3IncludedPopup = null
}
export function initializeT3() {
if (!DOMUtils.isOnDomain(DOMAINS.T3)) {
@ -53,6 +72,7 @@ function setupT3RouteChangeDetection() {
const checkForRouteChange = () => {
if (window.location.href !== currentUrl) {
disposeT3IncludedPopup()
currentUrl = window.location.href
setTimeout(() => {
addSupermemoryIconToT3Input()
@ -231,9 +251,13 @@ async function getRelatedMemoriesForT3(actionSource: string) {
}
if (textareaElement) {
textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}`
textareaElement.dataset.supermemories = buildSupermemoryText(
response.data,
)
iconElement.dataset.memoriesData = response.data
iconElement.dataset.memoriesData = serializeMemoriesForDataset(
response.data,
)
updateT3IconFeedback("Included Memories", iconElement)
} else {
@ -268,6 +292,8 @@ function updateT3IconFeedback(
iconElement.dataset.originalHtml = iconElement.innerHTML
}
disposeT3IncludedPopup()
const feedbackDiv = document.createElement("div")
feedbackDiv.style.cssText = `
display: flex;
@ -329,11 +355,9 @@ function updateT3IconFeedback(
overflow-y: auto;
`
const memoriesText = iconElement.dataset.memoriesData || ""
const individualMemories = memoriesText
.split(/[,\n]/)
.map((memory) => memory.trim())
.filter((memory) => memory.length > 0 && memory !== ",")
const individualMemories = parseMemoriesFromDataset(
iconElement.dataset.memoriesData,
)
individualMemories.forEach((memory, index) => {
const memoryItem = document.createElement("div")
@ -405,66 +429,65 @@ function updateT3IconFeedback(
popup.style.display = "block"
})
document.addEventListener("click", (e) => {
const onClick = (e: MouseEvent) => {
if (!popup.contains(e.target as Node)) {
popup.style.display = "none"
}
})
}
document.addEventListener("click", onClick)
t3IncludedPopup = {
el: popup,
onClick,
timer: setTimeout(disposeT3IncludedPopup, 300000),
}
content.querySelectorAll("button[data-memory-index]").forEach((button) => {
const htmlButton = button as HTMLButtonElement
htmlButton.addEventListener("click", () => {
const index = Number.parseInt(htmlButton.dataset.memoryIndex || "0", 10)
const memoryItem = htmlButton.parentElement
htmlButton.parentElement?.remove()
if (memoryItem) {
content.removeChild(memoryItem)
}
const currentMemories = (iconElement.dataset.memoriesData || "")
.split(/[,\n]/)
.map((memory) => memory.trim())
.filter((memory) => memory.length > 0 && memory !== ",")
currentMemories.splice(index, 1)
const updatedMemories = currentMemories.join(" ,")
iconElement.dataset.memoriesData = updatedMemories
const remainingMemories = parseMemoriesFromDataset(
iconElement.dataset.memoriesData,
)
remainingMemories.splice(index, 1)
const remaining = renumberIncludedMemories(remainingMemories)
const textareaElement =
(document.querySelector("textarea") as HTMLTextAreaElement) ||
(document.querySelector('div[contenteditable="true"]') as HTMLElement)
// Only wipe when nothing remains — `<= 1` used to discard the last kept memory.
if (remaining.length === 0) {
if (textareaElement?.dataset.supermemories) {
delete textareaElement.dataset.supermemories
}
delete iconElement.dataset.memoriesData
iconElement.innerHTML = iconElement.dataset.originalHtml || ""
delete iconElement.dataset.originalHtml
disposeT3IncludedPopup()
return
}
iconElement.dataset.memoriesData =
serializeMemoriesForDataset(remaining)
if (textareaElement) {
textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}`
textareaElement.dataset.supermemories =
buildSupermemoryText(remaining)
}
content
.querySelectorAll("button[data-memory-index]")
.forEach((btn, newIndex) => {
const htmlBtn = btn as HTMLButtonElement
htmlBtn.dataset.memoryIndex = newIndex.toString()
htmlBtn.dataset.memoryIndex = String(newIndex)
const label = htmlBtn.previousElementSibling
if (label) {
label.textContent = remaining[newIndex].trim()
}
})
if (currentMemories.length <= 1) {
if (textareaElement?.dataset.supermemories) {
delete textareaElement.dataset.supermemories
delete iconElement.dataset.memoriesData
iconElement.innerHTML = iconElement.dataset.originalHtml || ""
delete iconElement.dataset.originalHtml
}
popup.style.display = "none"
if (document.body.contains(popup)) {
document.body.removeChild(popup)
}
}
})
})
setTimeout(() => {
if (document.body.contains(popup)) {
document.body.removeChild(popup)
}
}, 300000)
}
iconElement.innerHTML = ""
@ -556,6 +579,7 @@ function setupT3PromptCapture() {
if (textareaElement?.dataset.supermemories) {
delete textareaElement.dataset.supermemories
}
disposeT3IncludedPopup()
}
const handleT3SendButtonClick = async (event: Event) => {
@ -711,6 +735,7 @@ async function setupT3AutoFetch() {
if (textareaElement.dataset.supermemories) {
delete textareaElement.dataset.supermemories
}
disposeT3IncludedPopup()
}
}, UI_CONFIG.AUTO_SEARCH_DEBOUNCE_DELAY)
}

View file

@ -9,6 +9,7 @@
"dev:firefox": "wxt -b firefox",
"build": "wxt build",
"build:firefox": "wxt build -b firefox",
"check-types": "bun run compile",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox",
"compile": "tsc --noEmit",

View file

@ -194,6 +194,11 @@
{
"group": "Other resources",
"pages": [
{
"group": "General",
"icon": "book-open",
"pages": ["ingestion/batch-ingest-historical-data"]
},
{
"group": "Benchmarking",
"icon": "flask-conical",

View file

@ -496,6 +496,7 @@ console.log(doc.status); // "queued" | "processing" | "done"
## Next Steps
- [How to backfill historical data](/ingestion/batch-ingest-historical-data) — Import dated content with the batch API
- [Search Memories](/recall/search) — Query your content
- [User Profiles](/recall/user-profiles) — Get user context
- [Organizing & Filtering](/concepts/filtering) — Container tags and metadata

View file

@ -0,0 +1,145 @@
---
title: "How to backfill historical data into Supermemory"
sidebarTitle: "Backfill historical data"
description: "Backfill historical documents into Supermemory with documentDate, stable custom IDs, and the batch ingestion API."
icon: "history"
---
Use `POST /v3/documents/batch` to backfill exports, emails, messages, or other dated records.
<Warning>
Sort the source data oldest to newest, add `documentDate` to every document.
</Warning>
## Backfill in batches
Backfill dated content by setting `documentDate` on each document, sorting the source records oldest to newest, and sending them in batches. Each request can contain up to 600 documents.
**Endpoint:** [`POST /v3/documents/batch`](/api-reference/ingest/batch-add-documents)
<CodeGroup>
```typescript TypeScript
import Supermemory from "supermemory";
type SourceDocument = {
id: string;
content: string;
createdAt: string;
};
const client = new Supermemory();
const batchSize = 100;
async function backfillHistoricalData(sourceDocuments: SourceDocument[]) {
const documents = sourceDocuments
.map((document) => ({
content: document.content,
customId: document.id,
documentDate: new Date(document.createdAt).toISOString()
}))
.sort((a, b) => a.documentDate.localeCompare(b.documentDate));
for (let offset = 0; offset < documents.length; offset += batchSize) {
const result = await client.documents.batchAdd({
containerTag: "historical_import",
documents: documents.slice(offset, offset + batchSize)
});
if (result.failed > 0) {
throw new Error(`${result.failed} documents failed to ingest`);
}
}
}
```
```python Python
from datetime import datetime, timezone
from supermemory import Supermemory
client = Supermemory()
batch_size = 100
def to_utc(value: str) -> str:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise ValueError("created_at must include a timezone")
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def backfill_historical_data(source_documents: list[dict[str, str]]) -> None:
documents = sorted(
[
{
"content": document["content"],
"custom_id": document["id"],
"document_date": to_utc(document["created_at"]),
}
for document in source_documents
],
key=lambda document: document["document_date"],
)
for offset in range(0, len(documents), batch_size):
result = client.documents.batch_add(
container_tag="historical_import",
documents=documents[offset : offset + batch_size],
)
if result.failed > 0:
raise RuntimeError(f"{result.failed} documents failed to ingest")
```
</CodeGroup>
## Optional: wait for processing to finish
**Endpoint:** [`GET /v3/documents/{id}`](/api-reference/documents/get-document)
The batch endpoint returns after accepting the documents. If a later step depends on completed memory generation, poll the returned document IDs until both `status` and `dreamingStatus` are `done`.
<CodeGroup>
```typescript TypeScript
async function waitUntilDone(ids: string[]) {
while (true) {
const documents = await Promise.all(
ids.map((id) => client.documents.get(id))
);
if (documents.some((document) => document.status === "failed")) {
throw new Error("A document failed to process");
}
if (
documents.every(
(document) =>
document.status === "done" && document.dreamingStatus === "done"
)
) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 10_000));
}
}
```
```python Python
import time
def wait_until_done(ids: list[str]) -> None:
while True:
documents = [client.documents.get(document_id) for document_id in ids]
if any(document.status == "failed" for document in documents):
raise RuntimeError("A document failed to process")
if all(
document.status == "done" and document.dreaming_status == "done"
for document in documents
):
return
time.sleep(10)
```
</CodeGroup>

View file

@ -27,6 +27,28 @@ bunx supermemory local
The installer detects your OS and architecture, downloads the right binary, verifies it, and (when run interactively) prompts you for an LLM API key. Supported platforms: macOS (Apple Silicon & Intel), Linux (x64 & arm64).
### Pin or change versions
Pass an explicit version to install (or roll back to) a specific release instead of `latest`:
```bash
curl -fsSL https://supermemory.ai/install | bash -s -- 0.0.3
```
<Warning>
Before rolling back, back up your [data directory](#where-things-live). The installer replaces the binary, but an older server may not understand data or schema changes made by a newer release.
</Warning>
Release tags are `server-v<version>` on [GitHub Releases](https://github.com/supermemoryai/supermemory/releases) (for example [`server-v0.0.3`](https://github.com/supermemoryai/supermemory/releases/tag/server-v0.0.3)).
To move to the newest release later:
```bash
supermemory-server upgrade
```
The binary may also print an “update available” notification on startup. If you intentionally pinned an older version (for example while debugging a regression), you can ignore that message until you are ready to upgrade.
## Run
```bash

View file

@ -18,6 +18,7 @@ Everything in this section is one of four steps. Same loop whether you're buildi
<JourneyStep number="2" title="Ingest — get content in">
<JourneyItem icon="plus" title="Add memories & documents" href="/ingestion/add-memories" />
<JourneyItem icon="history" title="Backfill historical data" href="/ingestion/batch-ingest-historical-data" />
<JourneyItem icon="files" title="Document operations" href="/ingestion/document-operations" />
<JourneyItem icon="plug" title="Connectors" href="/connectors/overview" />
<JourneyItem icon="file-stack" title="Content types" href="/concepts/content-types" />

View file

@ -149,6 +149,19 @@ function objectProperty(value: unknown, key: string): unknown {
: undefined
}
// API error bodies are JSON like {"error": "..."} — unwrap them so users see
// the real reason instead of raw JSON or a generic fallback.
function extractApiErrorMessage(raw: unknown): string | undefined {
if (typeof raw !== "string" || !raw) return undefined
try {
const parsed = JSON.parse(raw) as { error?: unknown; message?: unknown }
if (typeof parsed.error === "string" && parsed.error) return parsed.error
if (typeof parsed.message === "string" && parsed.message)
return parsed.message
} catch {}
return raw
}
export class SupermemoryClient {
private client: Supermemory
private containerTag: string
@ -371,7 +384,8 @@ export class SupermemoryClient {
signal,
})
if (!response.ok) {
throw Object.assign(new Error("Failed to fetch documents"), {
const message = extractApiErrorMessage(await response.text())
throw Object.assign(new Error(message ?? ""), {
status: response.status,
})
}
@ -432,11 +446,10 @@ export class SupermemoryClient {
})
if (!response.ok) {
const message = await response.text()
throw Object.assign(
new Error(message || "Failed to fetch memory entries"),
{ status: response.status },
)
const message = extractApiErrorMessage(await response.text())
throw Object.assign(new Error(message ?? ""), {
status: response.status,
})
}
return memoryEntriesResponseSchema.parse(await response.json())
@ -466,8 +479,7 @@ export class SupermemoryClient {
const status = objectProperty(error, "status")
if (typeof status === "number") {
const rawMessage = objectProperty(error, "message")
const message = typeof rawMessage === "string" ? rawMessage : undefined
const message = extractApiErrorMessage(objectProperty(error, "message"))
switch (status) {
case 400:
case 422:
@ -479,7 +491,7 @@ export class SupermemoryClient {
case 403:
throw new Error(
message ||
"Access forbidden. Your account may be restricted or blocked.",
"Access forbidden. This connection may be read-only or scoped to specific spaces — reconnect with broader access, or check your account status.",
)
case 404:
throw new Error("Not found.")

View file

@ -7,6 +7,7 @@
"dev": "portless",
"dev:app": "next dev --port ${PORT:-3004}",
"build": "next build",
"check-types": "tsc --noEmit",
"start": "next start"
},
"dependencies": {

View file

@ -1,4 +1,5 @@
NEXT_PUBLIC_BACKEND_URL=https://api.supermemory.ai
NEXT_PUBLIC_POSTHOG_KEY=
EXA_API_KEY=
XAI_API_KEY=
XAI_API_KEY=
NEXT_PUBLIC_AGENTID_AUTH_ENABLED=

View file

@ -3,10 +3,12 @@
import { EnsureWorkspace } from "@/components/ensure-workspace"
import { PWAInstallPrompt } from "@/components/pwa-install-prompt"
import { SettingsModalProvider } from "@/components/settings/settings-modal"
import { PromoCodeHost } from "@/hooks/use-promo-code"
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<SettingsModalProvider>
<PromoCodeHost />
<EnsureWorkspace>{children}</EnsureWorkspace>
<PWAInstallPrompt />
</SettingsModalProvider>

View file

@ -591,6 +591,79 @@ export default function LoginPage() {
/>
</div>
) : null}
{process.env.NEXT_PUBLIC_HOST_ID === "supermemory" ||
process.env.NEXT_PUBLIC_AGENTID_AUTH_ENABLED ? (
<div className="w-full">
<LastUsedBadge show={lastUsedMethod === "agentid"} />
<ExternalAuthButton
authIcon={
<svg
className="size-4 sm:size-5 text-foreground"
fill="none"
height="25"
viewBox="0 0 24 25"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<title>AgentID</title>
<rect
height="11"
rx="2.5"
stroke="currentColor"
strokeWidth="1.8"
width="14"
x="5"
y="8.21"
/>
<path
d="M12 8.21V4.71M12 4.71a1.5 1.5 0 1 0-.01-3 1.5 1.5 0 0 0 .01 3Z"
stroke="currentColor"
strokeWidth="1.8"
/>
<circle
cx="9.25"
cy="13.21"
fill="currentColor"
r="1.25"
/>
<circle
cx="14.75"
cy="13.21"
fill="currentColor"
r="1.25"
/>
<path
d="M9 16.21h6"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.8"
/>
</svg>
}
authProvider="AgentID"
className="w-full"
disabled={Boolean(loadingMessage)}
onClick={() => {
if (loadingMessage) return
setIsLoading(true)
posthog.capture("login_attempt", {
method: "social",
provider: "agentid",
})
setPendingLoginMethod("agentid")
signIn
.oauth2({
callbackURL: getCallbackURL(),
providerId: "agentid",
})
.catch((err: unknown) => {
setError(getErrorMessage(err))
setIsLoading(false)
})
}}
/>
</div>
) : null}
</div>
<TextSeparator

View file

@ -4,11 +4,10 @@ import { useAuth } from "@lib/auth-context"
import { useSession } from "@lib/auth"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import { useCustomer } from "autumn-js/react"
import { ArrowRight, Loader, XCircle } from "lucide-react"
import { ArrowRight, XCircle } from "lucide-react"
import Image from "next/image"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { Suspense, useEffect, useMemo, useState } from "react"
import { PENDING_CONNECT_URL_KEY } from "@/lib/constants"
@ -88,7 +87,7 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
"Auto-capture of project decisions",
"Context-aware suggestions",
],
icon: "/images/plugins/cursor.svg",
icon: "/images/plugins/cursor.png",
},
codex: {
name: "OpenAI Codex",
@ -103,11 +102,77 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
},
}
const MULTI_PLUGIN_FEATURES = [
"Share one persistent memory layer across selected coding agents.",
"Recall project context, coding decisions, and prior sessions.",
"Connect every selected plugin with one approval.",
]
function isKnownPlugin(value: string): boolean {
return Object.hasOwn(PLUGIN_INFO, value)
}
function getPluginName(client: string): string {
return PLUGIN_INFO[client]?.name ?? "External Tool"
}
type Status = "loading" | "creating" | "success" | "error" | "upgrade"
function formatPluginNames(clients: string[]): string {
const names = clients.map((id) => getPluginName(id))
if (names.length === 0) return "External Tool"
if (names.length === 1) return names[0] ?? "External Tool"
if (names.length === 2) {
return `${names[0] ?? "External Tool"} and ${names[1] ?? "External Tool"}`
}
return `${names.slice(0, -1).join(", ")}, and ${names.at(-1) ?? "External Tool"}`
}
function encodeBase64UrlJson(value: Record<string, string>): string {
return btoa(JSON.stringify(value))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "")
}
function PluginLogoStack({ clients }: { clients: string[] }) {
if (clients.length === 0) {
return (
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
<ArrowRight className="size-5 text-[#4BA0FA]" />
</div>
)
}
return (
<div className="flex items-center justify-center">
{clients.map((id, index) => {
const plugin = PLUGIN_INFO[id]
return (
<div
className="-ml-2 flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F] p-2 first:ml-0"
key={`${id}-${index}`}
style={{ zIndex: clients.length - index }}
title={plugin?.name ?? id}
>
{plugin ? (
<Image
alt={plugin.name}
className="size-6 object-contain"
height={24}
src={plugin.icon}
width={24}
/>
) : (
<ArrowRight className="size-5 text-[#4BA0FA]" />
)}
</div>
)
})}
</div>
)
}
type Status = "loading" | "creating" | "success" | "error"
const pageWrapperClass =
"flex items-center justify-center min-h-screen bg-background p-4"
@ -121,16 +186,34 @@ function AuthConnectContent() {
const router = useRouter()
const { data: session, isPending } = useSession()
const { org, organizations, isRestoring } = useAuth()
const autumn = useCustomer()
const [status, setStatus] = useState<Status>("loading")
const [error, setError] = useState<string | null>(null)
const [isUpgrading, setIsUpgrading] = useState(false)
const callback = params.get("callback")
const client = params.get("client")
const validClient = client && client in PLUGIN_INFO ? client : null
const displayName = validClient ? getPluginName(validClient) : "External Tool"
const pluginInfo = validClient ? PLUGIN_INFO[validClient] : null
const clientsParam = params.get("clients")
const hasClientList = params.has("clients")
const rawRequestedClients = useMemo(
() =>
(clientsParam !== null ? clientsParam.split(",") : client ? [client] : [])
.map((value) => value.trim())
.filter(Boolean),
[client, clientsParam],
)
const requestedClients = useMemo(
() => Array.from(new Set(rawRequestedClients.filter(isKnownPlugin))),
[rawRequestedClients],
)
const invalidClients = useMemo(
() => rawRequestedClients.filter((value) => !isKnownPlugin(value)),
[rawRequestedClients],
)
const validClient = requestedClients[0] ?? null
const displayName = formatPluginNames(requestedClients)
const pluginInfo =
requestedClients.length === 1 && validClient
? PLUGIN_INFO[validClient]
: null
// Redirect new users (logged in but no organization) to onboarding.
// Store the current connect URL so onboarding can redirect back here.
@ -166,6 +249,16 @@ function AuthConnectContent() {
setError("Invalid callback URL.")
return
}
if (invalidClients.length > 0) {
setStatus("error")
setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`)
return
}
if (requestedClients.length === 0) {
setStatus("error")
setError("Invalid or missing client.")
return
}
if (!session || !org) {
setStatus("error")
setError(
@ -177,17 +270,13 @@ function AuthConnectContent() {
try {
setStatus("creating")
const fetchParams = new URLSearchParams({ callback })
if (validClient) fetchParams.set("client", validClient)
fetchParams.set("client", requestedClients[0] ?? "")
const res = await fetch(`${API_URL}/v3/auth/key?${fetchParams}`, {
credentials: "include",
})
if (!res.ok) {
if (res.status === 403) {
setStatus("upgrade")
return
}
const errorData = (await res.json().catch(() => ({}))) as {
message?: string
}
@ -198,7 +287,21 @@ function AuthConnectContent() {
setStatus("success")
const redirectUrl = new URL(callback)
redirectUrl.searchParams.set("apikey", data.key)
if (hasClientList) {
redirectUrl.searchParams.set(
"keys",
encodeBase64UrlJson(
Object.fromEntries(
requestedClients.map((requestedClient) => [
requestedClient,
data.key,
]),
),
),
)
} else {
redirectUrl.searchParams.set("apikey", data.key)
}
redirectUrl.searchParams.set("api_url", API_URL)
window.location.href = redirectUrl.toString()
} catch (err) {
@ -208,23 +311,23 @@ function AuthConnectContent() {
}
}
async function handleUpgrade() {
try {
setIsUpgrading(true)
const safeSuccessUrl = `${window.location.origin}${window.location.pathname}?callback=${encodeURIComponent(callback ?? "")}&client=${encodeURIComponent(validClient ?? "")}`
await autumn.attach({
planId: "api_pro",
successUrl: safeSuccessUrl,
})
} catch (err) {
console.error("Upgrade failed:", err)
setIsUpgrading(false)
}
}
// Show a spinner while session/org data is loading or while we're about
// to redirect to onboarding (prevents a brief flash of the connect card).
const isAuthLoading = isPending || isRestoring || organizations === null
useEffect(() => {
if (status !== "loading") return
if (rawRequestedClients.length === 0) {
setStatus("error")
setError("Invalid or missing client.")
return
}
if (invalidClients.length > 0) {
setStatus("error")
setError(`Unsupported plugin requested: ${invalidClients.join(", ")}.`)
}
}, [invalidClients, rawRequestedClients.length, status])
if (isAuthLoading || shouldRedirectToOnboarding) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
@ -238,19 +341,7 @@ function AuthConnectContent() {
<div className={pageWrapperClass}>
<div className={cardClass}>
<div className="flex flex-col items-center gap-5">
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
{pluginInfo ? (
<Image
alt={pluginInfo.name}
className="size-6"
height={24}
src={pluginInfo.icon}
width={24}
/>
) : (
<ArrowRight className="size-5 text-[#4BA0FA]" />
)}
</div>
<PluginLogoStack clients={requestedClients} />
<div className="text-center">
<h2
className={dmSans125ClassName(
@ -265,13 +356,15 @@ function AuthConnectContent() {
)}
>
{pluginInfo?.description ??
`Allow ${displayName} to access your Supermemory account.`}
(requestedClients.length > 1
? "Use one Supermemory account across these plugins."
: `Use your Supermemory account with ${displayName}.`)}
</p>
</div>
{pluginInfo && (
<ul className="w-full space-y-2.5">
{pluginInfo.features.map((feature) => (
<ul className="w-full space-y-2.5">
{(pluginInfo?.features ?? MULTI_PLUGIN_FEATURES).map(
(feature) => (
<li key={feature} className="flex items-start gap-2.5">
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
<span
@ -282,16 +375,16 @@ function AuthConnectContent() {
{feature}
</span>
</li>
))}
</ul>
)}
),
)}
</ul>
<button
type="button"
onClick={handleConnect}
className={cn(
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
"text-[#FAFAFA] font-medium text-[14px]",
"shadow-[0px_2px_10px_rgba(5,1,0,0.2)]",
"cursor-pointer transition-opacity hover:opacity-90",
dmSans125ClassName(),
@ -311,104 +404,6 @@ function AuthConnectContent() {
</div>
)
}
if (status === "upgrade") {
return (
<div className={pageWrapperClass}>
<div className={cardClass}>
<div className="flex flex-col items-center gap-5">
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
{pluginInfo ? (
<Image
alt={pluginInfo.name}
className="size-6"
height={24}
src={pluginInfo.icon}
width={24}
/>
) : (
<ArrowRight className="size-5 text-[#4BA0FA]" />
)}
</div>
<div className="text-center">
<h2
className={dmSans125ClassName(
"font-semibold text-[18px] text-[#FAFAFA]",
)}
>
{pluginInfo?.name ?? displayName}
</h2>
<p
className={dmSans125ClassName(
"text-[13px] text-[#737373] mt-1",
)}
>
{pluginInfo?.description ??
`A paid plan is required to use ${displayName} with Supermemory.`}
</p>
</div>
{pluginInfo && (
<ul className="w-full space-y-2.5">
{pluginInfo.features.map((feature) => (
<li key={feature} className="flex items-start gap-2.5">
<ArrowRight className="mt-0.5 size-3.5 shrink-0 text-[#4BA0FA]" />
<span
className={dmSans125ClassName(
"text-[13px] text-[#8B8B8B]",
)}
>
{feature}
</span>
</li>
))}
</ul>
)}
<button
type="button"
onClick={handleUpgrade}
disabled={isUpgrading || autumn.isLoading}
className={cn(
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
"shadow-[0px_2px_10px_rgba(5,1,0,0.2)]",
"disabled:opacity-60 disabled:cursor-not-allowed",
"cursor-pointer transition-opacity hover:opacity-90",
dmSans125ClassName(),
)}
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
>
{isUpgrading || autumn.isLoading ? (
<>
<Loader className="size-4 animate-spin mr-2" />
Upgrading
</>
) : (
"Upgrade to Pro \u2014 $19/month"
)}
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</button>
<a
href="https://app.supermemory.ai/settings#billing"
className={dmSans125ClassName(
"text-[12px] text-[#737373] hover:text-[#FAFAFA] transition-colors",
)}
>
View all plans
</a>
</div>
</div>
</div>
)
}
if (status === "error") {
return (
<div className={pageWrapperClass}>
@ -435,7 +430,7 @@ function AuthConnectContent() {
<div className="flex flex-col gap-2 w-full">
<button
type="button"
onClick={() => window.location.reload()}
onClick={() => void handleConnect()}
className={cn(
"w-full flex items-center justify-center gap-2 rounded-full h-10 px-4",
"bg-[#0D121A] border border-[#1E293B] text-[#FAFAFA]",

View file

@ -11,6 +11,7 @@ import { Suspense } from "react"
import { Toaster } from "@ui/components/sonner"
import { NuqsAdapter } from "nuqs/adapters/next/app"
import { ThemeProvider } from "@/lib/theme-provider"
import { PromoCodeCapture } from "@/hooks/use-promo-code"
const font = Space_Grotesk({
subsets: ["latin"],
@ -95,6 +96,7 @@ export default function RootLayout({
includeCredentials={true}
headers={{ "X-App-Source": "nova" }}
>
<PromoCodeCapture />
<QueryProvider>
<AuthProvider>
<PostHogProvider>

View file

@ -42,6 +42,7 @@ import {
getConnectionSubtitle,
} from "@/components/settings/sync-utils"
import type { ImportProvider } from "@/components/settings/sync-utils"
import { usePromoCode } from "@/hooks/use-promo-code"
type GDriveSyncScope = "scoped" | "full"
@ -309,6 +310,7 @@ interface ConnectContentProps {
export function ConnectContent({ selectedProject }: ConnectContentProps) {
const queryClient = useQueryClient()
const autumn = useCustomer()
const promoCode = usePromoCode()
const { connectorAccess } = useConnectorAccess()
const [connectingProvider, setConnectingProvider] =
useState<ConnectorProvider | null>(null)
@ -330,8 +332,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
try {
const result = await autumn.attach({
planId,
discounts: promoCode.getDiscounts(),
successUrl: window.location.href,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return

View file

@ -21,6 +21,7 @@ import { formatUsageNumber } from "@/lib/billing-utils"
import { SpaceSelector } from "../space-selector"
import { useIsMobile } from "@hooks/use-mobile"
import { addDocumentParam } from "@/lib/search-params"
import { usePromoCode } from "@/hooks/use-promo-code"
type TabType = "note" | "link" | "file" | "connect"
@ -153,6 +154,7 @@ export function AddDocument({
})
const autumn = useCustomer()
const promoCode = usePromoCode()
const {
tokensUsed,
searchesUsed,
@ -342,8 +344,10 @@ export function AddDocument({
try {
const result = await autumn.attach({
planId: "api_pro",
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#account`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
@ -442,8 +446,10 @@ export function AddDocument({
try {
const result = await autumn.attach({
planId: "api_pro",
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#account`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return

View file

@ -8,6 +8,8 @@ import { ArrowRight, Check, FileText, Loader2, UserPlus } from "lucide-react"
import { useQueryState } from "nuqs"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { useBrainTrial } from "@/hooks/use-brain-trial"
import { TrialSetupBanner } from "@/components/trial-setup-banner"
import { useTrialStatus } from "@/hooks/use-trial-status"
import { dmSans125ClassName } from "@/lib/fonts"
import { useViewMode } from "@/lib/view-mode-context"
import {
@ -170,6 +172,7 @@ export function BrainHomeView() {
const o = useBrainOverview()
const trial = useBrainTrial()
const board = useConnectionsBoard()
const { needsSetup } = useTrialStatus()
// Rows with no reported state (older orgs, pre-Slack) don't count or render.
const milestones = [
...(o.researchStatus != null ? [o.researchStatus === "done"] : []),
@ -186,6 +189,7 @@ export function BrainHomeView() {
return (
<div className="mx-auto max-w-[1080px] space-y-6">
<TrialSetupBanner />
<StatsRow
memories={o.memoriesCount}
connected={o.connectedCount}
@ -195,7 +199,7 @@ export function BrainHomeView() {
setupTotal={milestonesTotal}
lastUpdatedAt={o.lastUpdatedAt}
/>
{board.slack && !board.slack.connected && <SlackBanner />}
{board.slack && !board.slack.connected && !needsSetup && <SlackBanner />}
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="min-w-0 space-y-6">
{board.showBoard && <ConnectToolsCard board={board} />}
@ -486,7 +490,7 @@ function BrainTimeline({
canInvite: boolean
toolsCardVisible: boolean
}) {
const trial = useBrainTrial()
const { needsSetup } = useTrialStatus()
const { openSettings } = useSettingsModal()
const { setViewMode } = useViewMode()
const [, setInvite] = useQueryState("invite")
@ -532,12 +536,13 @@ function BrainTimeline({
title: slackConnected ? "Slack connected" : "Connect Slack",
hint: slackConnected
? undefined
: trial.state === "trialing"
? "Ask your brain from any channel."
: "Starts your 14-day free trial. No credit card needed.",
action: slackConnected
? undefined
: { label: "Add", href: `${BACKEND}/brain/slack/oauth/install` },
: needsSetup
? "Starts with your trial."
: "Ask your brain from any channel.",
action:
slackConnected || needsSetup
? undefined
: { label: "Add", href: `${BACKEND}/brain/slack/oauth/install` },
},
...(rollout != null
? [

View file

@ -4,6 +4,7 @@ import { cn } from "@lib/utils"
import { ArrowRight, Loader2 } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { useTrialStatus } from "@/hooks/use-trial-status"
import { dmSans125ClassName } from "@/lib/fonts"
import { useViewMode } from "@/lib/view-mode-context"
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
@ -192,6 +193,7 @@ export const CONNECT_TOOLS_CARD_ID = "connect-tools"
export function ConnectToolsCard({ board }: { board: ConnectionsBoardState }) {
const { setViewMode } = useViewMode()
const { loading, featured, overflow, busy, isConnected, connect } = board
const { needsSetup } = useTrialStatus()
return (
<section
@ -209,11 +211,19 @@ export function ConnectToolsCard({ board }: { board: ConnectionsBoardState }) {
Connect your tools
</p>
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
Give your Slack agent live access to the apps your team already uses.
{needsSetup
? "Starts with your trial."
: "Give your Slack agent live access to the apps your team already uses."}
</p>
</div>
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
<div
className={cn(
"overflow-hidden rounded-[12px] bg-[#14161A]",
needsSetup && "pointer-events-none opacity-40 select-none",
)}
aria-disabled={needsSetup || undefined}
>
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<TileSkeleton key={i} showDivider={i < 2} />
@ -248,6 +258,7 @@ export function ConnectToolsCard({ board }: { board: ConnectionsBoardState }) {
export function AskInSlackCard({ board }: { board: ConnectionsBoardState }) {
const { previewApps, isConnected, connectedCount } = board
const { needsSetup } = useTrialStatus()
const prompts = previewApps
.filter((a) => AGENT_PROMPTS[a.slug])
.slice(0, 6)
@ -275,12 +286,19 @@ export function AskInSlackCard({ board }: { board: ConnectionsBoardState }) {
</p>
</div>
<p className="text-[12px] font-medium leading-[1.5] text-[#737373]">
{connectedCount > 0
? "Things your agent can answer now:"
: "Connect a tool and your agent can answer:"}
{needsSetup
? "Starts with your trial."
: connectedCount > 0
? "Things your agent can answer now:"
: "Connect a tool and your agent can answer:"}
</p>
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
<div
className={cn(
"overflow-hidden rounded-[12px] bg-[#14161A]",
needsSetup && "opacity-40 select-none",
)}
>
{prompts.map((p, i) => (
<div
key={p.slug}
@ -426,6 +444,7 @@ function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) {
}
export function SlackBanner() {
const { needsSetup } = useTrialStatus()
return (
<section
className="relative overflow-hidden rounded-[18px] bg-[#1B1F24] p-3.5 sm:p-5"
@ -472,12 +491,20 @@ export function SlackBanner() {
</div>
<a
href={`${BACKEND}/brain/slack/oauth/install`}
href={
needsSetup ? "/onboarding" : `${BACKEND}/brain/slack/oauth/install`
}
className="inline-flex shrink-0 items-center justify-center rounded-lg bg-white px-3 py-1.5 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02] sm:gap-2 sm:px-4 sm:py-2.5 sm:text-[14px]"
>
<SlackMark className="hidden sm:block sm:size-[18px]" />
<span className="sm:hidden">Add</span>
<span className="hidden sm:inline">Add to Slack</span>
{needsSetup ? (
<span>Start trial</span>
) : (
<>
<SlackMark className="hidden sm:block sm:size-[18px]" />
<span className="sm:hidden">Add</span>
<span className="hidden sm:inline">Add to Slack</span>
</>
)}
</a>
</div>
</section>

View file

@ -36,9 +36,9 @@ export function HomeChatComposer({
const [attachmentDrafts, setAttachmentDrafts] = useState<
ChatAttachmentDraft[]
>([])
const [selectedModel, setSelectedModel] = useState<ModelId>("grok-4.3")
const [selectedModel, setSelectedModel] = useState<ModelId>("grok-4.5")
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
getDefaultReasoningEffort("grok-4.3"),
getDefaultReasoningEffort("grok-4.5"),
)
const { selectedProject } = useProject()
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([

View file

@ -206,11 +206,11 @@ export function ChatSidebar({
>([])
const [isChatDraggingFiles, setIsChatDraggingFiles] = useState(false)
const [selectedModel, setSelectedModel] = useState<ModelId>(
initialSelectedModel ?? "grok-4.3",
initialSelectedModel ?? "grok-4.5",
)
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
initialReasoningEffort ??
getDefaultReasoningEffort(initialSelectedModel ?? "grok-4.3"),
getDefaultReasoningEffort(initialSelectedModel ?? "grok-4.5"),
)
const selectedModelRef = useRef(selectedModel)
selectedModelRef.current = selectedModel

View file

@ -23,8 +23,7 @@ export default function ChatModelSelector({
minimal = false,
dropdownDirection = "up",
}: ChatModelSelectorProps = {}) {
const [internalModel, setInternalModel] =
useState<ModelId>("claude-sonnet-4.6")
const [internalModel, setInternalModel] = useState<ModelId>("claude-sonnet-5")
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)

View file

@ -21,6 +21,7 @@ import {
LifeBuoy,
LayoutGrid,
MenuIcon,
Plus,
SearchIcon,
Settings,
Settings2,
@ -55,6 +56,7 @@ const BACKEND =
type SlackStatus = { connected: boolean; teamName: string | null }
interface CompanyBrainHeaderProps {
onAddMemory?: () => void
onOpenSearch?: () => void
}
@ -108,7 +110,10 @@ function useSlackStatus() {
})
}
export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
export function CompanyBrainHeader({
onAddMemory,
onOpenSearch,
}: CompanyBrainHeaderProps) {
const { user, org, organizations, setActiveOrg } = useAuth()
const autumn = useCustomer()
const { currentPlan } = useTokenUsage(autumn)
@ -412,6 +417,18 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
{onAddMemory && (
<>
<DropdownMenuItem
onClick={onAddMemory}
className={menuItemClass}
>
<Plus className="size-4 text-[#737373]" />
Add memory
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[#2E3033]" />
</>
)}
<DropdownMenuItem
onClick={goOverview}
className={menuItemClass}
@ -498,6 +515,29 @@ export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
) : (
<>
<BrainTrialPill className="h-9 px-3" />
{onAddMemory && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="headers"
className={cn(
"rounded-full! h-9! min-h-9 shrink-0",
"max-lg:w-9 max-lg:min-w-9 max-lg:justify-center max-lg:gap-0 max-lg:px-0",
"lg:min-w-0 lg:gap-1.5 lg:px-3 lg:font-medium",
dmSansClassName(),
)}
onClick={onAddMemory}
aria-label="Add memory"
>
<Plus className="size-3.5 shrink-0 lg:size-4" />
<span className="max-lg:sr-only">Add</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Add memory (C)
</TooltipContent>
</Tooltip>
)}
{canInvite && (
<Tooltip>
<TooltipTrigger asChild>

View file

@ -45,42 +45,44 @@ export function CompanyBrainPromo() {
return (
<div
className={cn(
"flex items-center gap-4 rounded-xl bg-surface-card/60 px-4 py-4 backdrop-blur-md",
"relative flex items-start gap-3 rounded-xl bg-surface-card/60 px-4 py-4 backdrop-blur-md sm:items-center sm:gap-4",
"shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
dmSansClassName(),
)}
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-[#0562ef]">
<div className="mt-1 flex size-10 shrink-0 items-center justify-center rounded-lg bg-[#0562ef] sm:mt-0">
<Logo className="h-4 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[15px] font-semibold text-[#fafafa]">
Give your team a Company Brain
</p>
<p className="text-[13px] text-[#a1a1a1]">
Lives in your Slack. Answers from your team's tools, and brings things
up before you ask.
</p>
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:contents">
<div className="min-w-0 pt-1 pr-7 sm:flex-1 sm:pt-0 sm:pr-0">
<p className="text-[15px] font-semibold text-[#fafafa]">
Give your team a Company Brain
</p>
<p className="text-[11px] leading-snug text-[#a1a1a1] sm:text-[13px] sm:leading-normal">
Lives in your Slack. Answers from your team's tools, and brings
things up before you ask.
</p>
</div>
<Button
className={cn(
"h-9! min-h-9 w-fit shrink-0 self-end gap-1.5 rounded-full! px-3 font-medium sm:self-auto",
dmSansClassName(),
)}
onClick={() => {
analytics.companyBrainPromoClicked({ source: "dashboard_card" })
router.push("/onboarding?new=1&mode=team")
}}
variant="headers"
>
Set it up
<ArrowRight className="size-4 shrink-0" />
</Button>
</div>
<Button
className={cn(
"rounded-full! h-9! min-h-9 shrink-0 gap-1.5 px-3 font-medium",
dmSansClassName(),
)}
onClick={() => {
analytics.companyBrainPromoClicked({ source: "dashboard_card" })
router.push("/onboarding?new=1&mode=team")
}}
variant="headers"
>
Set it up
<ArrowRight className="size-4 shrink-0" />
</Button>
<button
aria-label="Dismiss"
type="button"
onClick={dismiss}
className="shrink-0 rounded-full p-1.5 text-[#737373] transition-colors hover:text-[#fafafa]"
className="absolute right-2.5 top-2.5 shrink-0 rounded-full p-1.5 text-[#737373] transition-colors hover:text-[#fafafa] sm:static"
>
<XIcon className="size-4" />
</button>

View file

@ -162,16 +162,19 @@ export function ConfigureView() {
</nav>
<div className="min-w-0 flex-1">
<header className="mb-5">
<h2
id="configure-section-title"
className="text-[14px] font-semibold tracking-[-0.1px] text-[#FAFAFA]"
>
{active.label}
</h2>
<p className="mt-1 text-[12px] leading-5 text-[#737B87]">
{active.description}
</p>
<header className="mb-5 flex items-start justify-between gap-4">
<div>
<h2
id="configure-section-title"
className="text-[14px] font-semibold tracking-[-0.1px] text-[#FAFAFA]"
>
{active.label}
</h2>
<p className="mt-1 text-[12px] leading-5 text-[#737B87]">
{active.description}
</p>
</div>
<div id="configure-section-actions" className="shrink-0" />
</header>
<ErrorBoundary

View file

@ -32,6 +32,7 @@ import { StaticGraphPreview } from "@/components/memory-graph/graph-card"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
import { ChromeIcon, RaycastIcon } from "@/components/integration-icons"
import { SlackConnectCard } from "@/components/slack-connect-card"
import { TrialSetupBanner } from "@/components/trial-setup-banner"
import { GoogleDrive, Notion, MCPIcon } from "@ui/assets/icons"
import { analytics } from "@/lib/analytics"
import type { IntegrationParamValue } from "@/lib/search-params"
@ -1344,6 +1345,7 @@ export function DashboardView({
)}
>
<div className="mx-auto w-full max-w-4xl space-y-4 md:space-y-5">
<TrialSetupBanner />
<SlackConnectCard />
{headerNotice ? <div className="space-y-2">{headerNotice}</div> : null}

View file

@ -70,7 +70,12 @@ const brainTileClass = (active: boolean) =>
export function Header(props: HeaderProps) {
const hasCompanyBrain = useHasCompanyBrain()
if (hasCompanyBrain) {
return <CompanyBrainHeader onOpenSearch={props.onOpenSearch} />
return (
<CompanyBrainHeader
onAddMemory={props.onAddMemory}
onOpenSearch={props.onOpenSearch}
/>
)
}
return <PersonalBrainHeader {...props} />
}

View file

@ -92,8 +92,8 @@ export function HighlightsCard({
if (isReplyOpen) replyInputRef.current?.focus()
}, [isReplyOpen])
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally re-run when items changes
useEffect(() => {
setActiveIndex((i) => Math.min(i, Math.max(items.length - 1, 0)))
setIsReplyOpen(false)
setReplyText("")
setIsExpanded(false)

View file

@ -80,6 +80,7 @@ import {
import { MCPSteps } from "./mcp-modal/mcp-detail-view"
import { GranolaConnectModal } from "./granola-connect-modal"
import { detectPluginSpace, detectPluginSource } from "@/lib/plugin-space"
import { usePromoCode } from "@/hooks/use-promo-code"
type Connection = z.infer<typeof ConnectionResponseSchema>
@ -536,13 +537,13 @@ const SECTIONS: Array<{
action: { type: "external", href: POKE_RECIPE_URL },
},
{
kind: "client",
id: "shortcuts",
name: "Apple Shortcuts",
tagline: "Add memories from iPhone, iPad or Mac",
simpleTitle: "Save anything from your phone or Mac",
icon: <AppleShortcutsIcon />,
action: { type: "view", viewMode: "shortcuts" as ViewParamValue },
kind: "import",
id: "x-bookmarks",
name: "Import X bookmarks",
tagline: "Turn your X/Twitter bookmarks into memories",
simpleTitle: "Turn your X bookmarks into memory",
icon: <Image src="/onboarding/x.png" alt="X" width={24} height={24} />,
viewMode: "import" as ViewParamValue,
},
{
kind: "client",
@ -555,13 +556,13 @@ const SECTIONS: Array<{
dev: true,
},
{
kind: "import",
id: "x-bookmarks",
name: "Import X bookmarks",
tagline: "Turn your X/Twitter bookmarks into memories",
simpleTitle: "Turn your X bookmarks into memory",
icon: <Image src="/onboarding/x.png" alt="X" width={24} height={24} />,
viewMode: "import" as ViewParamValue,
kind: "client",
id: "shortcuts",
name: "Apple Shortcuts",
tagline: "Add memories from iPhone, iPad or Mac",
simpleTitle: "Save anything from your phone or Mac",
icon: <AppleShortcutsIcon />,
action: { type: "view", viewMode: "shortcuts" as ViewParamValue },
},
],
},
@ -2067,6 +2068,7 @@ function ItemCard({
docsUrl,
leftIndicator,
statusSlot,
layoutClassName,
}: {
actionSlot: ReactNode
infoActionSlot?: ReactNode
@ -2081,6 +2083,7 @@ function ItemCard({
docsUrl?: string
leftIndicator?: ReactNode
statusSlot?: ReactNode
layoutClassName?: string
}) {
const [infoOpen, setInfoOpen] = useState(false)
return (
@ -2098,6 +2101,9 @@ function ItemCard({
className={cn(
"group relative flex h-full cursor-pointer flex-row items-center gap-2.5 rounded-[10px] bg-[#14161A] px-2.5 py-2 transition-colors hover:bg-[#16181D] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/45 sm:flex-col sm:items-stretch sm:gap-4 sm:rounded-[12px] sm:p-4",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
id === "shortcuts" &&
"max-sm:grid max-sm:grid-cols-[auto_minmax(0,1fr)] max-sm:items-center",
layoutClassName,
)}
>
<ItemInfoButton name={name} onClick={() => setInfoOpen(true)} />
@ -2115,7 +2121,12 @@ function ItemCard({
<div className="flex shrink-0 items-start justify-between gap-2">
<IconBox>{icon}</IconBox>
</div>
<div className="flex min-w-0 flex-1 flex-row items-center justify-between gap-2 sm:flex-col sm:items-stretch sm:justify-end sm:gap-3">
<div
className={cn(
"flex min-w-0 flex-1 flex-row items-center justify-between gap-2 sm:flex-col sm:items-stretch sm:justify-end sm:gap-3",
id === "shortcuts" && "max-sm:contents",
)}
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1">
{leftIndicator}
@ -2139,7 +2150,13 @@ function ItemCard({
{tagline}
</p>
</div>
<div className="flex w-auto shrink-0 items-center justify-end gap-2 sm:w-full sm:justify-between">
<div
className={cn(
"flex w-auto shrink-0 items-center justify-end gap-2 sm:w-full sm:justify-between",
id === "shortcuts" &&
"max-sm:col-span-2 max-sm:row-start-2 max-sm:w-full",
)}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the status action. */}
<div
className="hidden min-w-0 flex-1 sm:flex"
@ -2150,7 +2167,11 @@ function ItemCard({
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: stop card click from swallowing the primary action. */}
<div
className="flex shrink-0 justify-end [&>button]:!h-7 [&>button]:!min-w-[82px] [&>button]:!px-3 [&>button]:!text-[11px] sm:[&>button]:!h-9 sm:[&>button]:!min-w-[116px] sm:[&>button]:!px-5 sm:[&>button]:!text-[14px]"
className={cn(
"flex shrink-0 justify-end [&>button]:!h-7 [&>button]:!min-w-[82px] [&>button]:!px-3 [&>button]:!text-[11px] sm:[&>button]:!h-9 sm:[&>button]:!min-w-[116px] sm:[&>button]:!px-5 sm:[&>button]:!text-[14px]",
id === "shortcuts" &&
"max-sm:w-full max-sm:shrink max-sm:[&>div]:w-full",
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
@ -2555,6 +2576,7 @@ export function IntegrationsView({
const { allProjects } = useContainerTags()
const shortcutsConnect = useShortcutsConnect()
const autumn = useCustomer({ queryOptions: { enabled: !publicMode } })
const promoCode = usePromoCode()
// connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins
// stay on hasProProduct. See useConnectorAccess.
const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({
@ -2747,11 +2769,6 @@ export function IntegrationsView({
credentials: "include",
})
if (!res.ok) {
if (res.status === 403) {
throw new Error(
"Plugin access was denied. Check your plan or try again.",
)
}
const errorData = (await res.json().catch(() => ({}))) as {
message?: string
}
@ -2830,8 +2847,10 @@ export function IntegrationsView({
try {
const result = await autumn.attach({
planId: checkoutPlanId,
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/integrations`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
@ -2842,7 +2861,7 @@ export function IntegrationsView({
toast.error("Failed to start checkout. Please try again.")
}
},
[autumn],
[autumn, promoCode],
)
const redirectToLogin = useCallback(() => {
@ -3629,7 +3648,7 @@ export function IntegrationsView({
}
}
const renderItemCard = (item: Item) => (
const renderItemCard = (item: Item, layoutClassName?: string) => (
<ItemCard
key={item.id}
actionSlot={renderRight(item)}
@ -3645,6 +3664,7 @@ export function IntegrationsView({
docsUrl={item.docsUrl}
leftIndicator={renderLeftIndicator(item)}
statusSlot={renderStatus(item)}
layoutClassName={layoutClassName}
/>
)
@ -3754,7 +3774,14 @@ export function IntegrationsView({
</p>
) : q || category !== "all" ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{visibleItems.map((item) => renderItemCard(item))}
{visibleItems.map((item) =>
renderItemCard(
item,
item.id === "shortcuts"
? "sm:w-max sm:min-w-full"
: undefined,
),
)}
</div>
) : (
<div className="flex flex-col gap-6">

View file

@ -30,6 +30,7 @@ import {
type PluginInfo,
} from "@/lib/plugin-catalog"
import { INSET, InstallSteps, PillButton } from "./install-steps"
import { usePromoCode } from "@/hooks/use-promo-code"
interface ConnectedPlugin {
id: string
@ -415,48 +416,11 @@ function PluginRow({
)
}
type TierFilter = "all" | "pro" | "free"
const TIER_FILTERS: { value: TierFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "pro", label: "Pro" },
{ value: "free", label: "Free" },
]
function TierFilterToggle({
value,
onChange,
}: {
value: TierFilter
onChange: (value: TierFilter) => void
}) {
return (
<div className="flex shrink-0 items-center gap-0.5 rounded-full bg-[#0D121A] p-0.5 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]">
{TIER_FILTERS.map((filter) => (
<button
key={filter.value}
type="button"
onClick={() => onChange(filter.value)}
className={cn(
dmSans125ClassName(),
"rounded-full px-3 h-7 text-[12px] font-medium transition-colors",
value === filter.value
? "bg-white/[0.10] text-[#FAFAFA]"
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
)}
>
{filter.label}
</button>
))}
</div>
)
}
export function PluginsDetail() {
const { org } = useAuth()
const autumn = useCustomer()
const promoCode = usePromoCode()
const queryClient = useQueryClient()
const [tierFilter, setTierFilter] = useState<TierFilter>("all")
const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null)
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
null,
@ -572,11 +536,6 @@ export function PluginsDetail() {
credentials: "include",
})
if (!res.ok) {
if (res.status === 403) {
throw new Error(
"Plugin access was denied. Check your plan or try again.",
)
}
const errorData = (await res.json().catch(() => ({}))) as {
message?: string
}
@ -613,8 +572,10 @@ export function PluginsDetail() {
try {
const result = await autumn.attach({
planId: "api_pro",
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/integrations`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
@ -635,17 +596,12 @@ export function PluginsDetail() {
)
const visibleRows = useMemo(() => {
const filtered = catalogRows.filter((id) => {
if (tierFilter === "free") return isFreeTierPlugin(id)
if (tierFilter === "pro") return !isFreeTierPlugin(id)
return true
})
// Connected plugins float to the top (stable within each group).
return [...filtered].sort(
return [...catalogRows].sort(
(a, b) =>
Number(connectedPluginIds.has(b)) - Number(connectedPluginIds.has(a)),
)
}, [catalogRows, tierFilter, connectedPluginIds])
}, [catalogRows, connectedPluginIds])
const dialogPlugin = newKey.pluginId
? PLUGIN_CATALOG[newKey.pluginId]
@ -684,12 +640,7 @@ export function PluginsDetail() {
)}
>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<SectionHeader>Plugins</SectionHeader>
{catalogRows.length > 0 && (
<TierFilterToggle value={tierFilter} onChange={setTierFilter} />
)}
</div>
<SectionHeader>Plugins</SectionHeader>
<div className="flex flex-col">
{visibleRows.map((pluginId) => {
const plugin = PLUGIN_CATALOG[pluginId]

View file

@ -151,7 +151,7 @@ export function ShortcutsConnectButtons({
}) {
const { connect, isPending, pendingType } = controller
return (
<div className="flex flex-col gap-2 sm:flex-row">
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<PillButton
className="h-9 flex-none"
onClick={(e) => {

View file

@ -30,6 +30,9 @@ import {
UserAvatar,
} from "./step-about"
import { ResearchActionRail } from "./research-action-rail"
import { CHECKOUT_RETURN_PARAM, StepTrial } from "./step-trial"
import { useTrialStatus } from "@/hooks/use-trial-status"
import { analytics } from "@/lib/analytics"
import {
type CompanyBrainConfirmResult,
type CompanyBrainOrganizationChoice,
@ -52,7 +55,7 @@ interface CompanyBrainOnboardingProps {
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
type Phase = "confirm" | "research"
type Phase = "confirm" | "trial" | "research"
function normalizeDomain(input: string): string {
const host = input
@ -82,6 +85,23 @@ export function CompanyBrainOnboarding({
onUsePersonal,
}: CompanyBrainOnboardingProps) {
const [phase, setPhase] = useState<Phase>("confirm")
const { needsSetup } = useTrialStatus()
const resumedRef = useRef(false)
useEffect(() => {
if (resumedRef.current) return
const url = new URL(window.location.href)
if (url.searchParams.get(CHECKOUT_RETURN_PARAM) !== "complete") return
resumedRef.current = true
url.searchParams.delete(CHECKOUT_RETURN_PARAM)
window.history.replaceState({}, "", `${url.pathname}${url.search}`)
setPhase("research")
}, [])
useEffect(() => {
if (resumedRef.current || !needsSetup || phase !== "confirm") return
resumedRef.current = true
setPhase("trial")
analytics.brainTrialCardViewed()
}, [needsSetup, phase])
const [domain, setDomain] = useState(initialDomain)
const [organizationChoices, setOrganizationChoices] = useState<
CompanyBrainOrganizationChoice[] | null
@ -107,7 +127,8 @@ export function CompanyBrainOnboarding({
}
setOrganizationChoices(null)
setServerSchedulesResearch(result.serverSchedulesResearch)
setPhase("research")
setPhase("trial")
analytics.brainTrialCardViewed()
}
// New-org signup schedules research after provisioning; if that hook is slow
@ -203,9 +224,9 @@ export function CompanyBrainOnboarding({
<main
className={cn(
"relative z-10 flex-1 flex flex-col min-h-0",
phase === "confirm"
? "justify-center items-center px-4 md:px-10"
: "justify-start items-stretch pt-2 px-4 md:px-8 xl:px-14",
phase === "research"
? "justify-start items-stretch pt-2 px-4 md:px-8 xl:px-14"
: "justify-center items-center px-4 md:px-10",
)}
>
{/* Persistent card: full confirm card, then morphs into a slim docked header. */}
@ -215,13 +236,25 @@ export function CompanyBrainOnboarding({
style={cardSurfaceStyle}
className={cn(
"w-full mx-auto rounded-[22px] bg-[#1B1F24]",
phase === "confirm"
? "max-w-xl p-6 md:p-8"
: "max-w-7xl px-5 py-3 xl:max-w-[1360px]",
phase === "research"
? "max-w-7xl px-5 py-3 xl:max-w-[1360px]"
: phase === "trial"
? "max-w-4xl p-6 md:p-7"
: "max-w-xl p-6 md:p-8",
)}
>
<AnimatePresence mode="wait" initial={false}>
{phase === "confirm" ? (
{phase === "trial" ? (
<motion.div
key="trial"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<StepTrial onActive={() => setPhase("research")} />
</motion.div>
) : phase === "confirm" ? (
<motion.div
key="confirm"
initial={{ opacity: 0 }}

View file

@ -525,6 +525,8 @@ function SlackStepBody({
<div>
<a
href={`${BACKEND}/brain/slack/oauth/install`}
target="_blank"
rel="noopener noreferrer"
className={cn(
"inline-flex w-full items-center justify-center gap-2 rounded-full bg-white px-4 py-2.5 text-[13px] font-semibold text-[#1D1C1D] transition-opacity hover:opacity-90",
dmSans125ClassName(),
@ -534,7 +536,7 @@ function SlackStepBody({
Add to Slack
</a>
<p className="mt-2 text-center text-[11px] font-medium leading-[1.5] text-[#525D6E]">
Starts your 14-day free trial. No credit card needed.
Included in your 14-day trial.
</p>
</div>
)

View file

@ -82,6 +82,7 @@ import { useCustomer } from "autumn-js/react"
import { toast } from "sonner"
import { analytics } from "@/lib/analytics"
import type { BrainMode } from "./types"
import { usePromoCode } from "@/hooks/use-promo-code"
type SourceId =
| "drive"
@ -97,7 +98,7 @@ type SourceId =
| "raycast"
type SourceState = "idle" | "connecting" | "connected" | "waitlist"
type DriveScope = "selective" | "full"
type RequiredPlan = "pro" | "max"
type RequiredPlan = "pro" | "max" | "scale"
const PROVIDER_TO_SOURCE: Record<string, SourceId> = {
"google-drive": "drive",
@ -116,6 +117,7 @@ const SOURCE_LABEL: Partial<Record<SourceId, string>> = {
const PLAN_LABELS: Record<RequiredPlan, string> = {
pro: "Pro",
max: "Max",
scale: "Scale",
}
const BOOK_CALL_HREF = "https://cal.com/maheshthedev/15min"
@ -148,11 +150,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [
credits: "$20",
productId: "api_pro",
description: "For people building with AI memory",
features: [
"Auto top-up when balance runs low",
"All plugins (Claude Code, Cursor, Hermes...)",
"Priority support",
],
features: ["Auto top-up when balance runs low", "Priority support"],
},
{
id: "max",
@ -277,7 +275,12 @@ export function StepSources({
const [granolaOpen, setGranolaOpen] = useState(false)
const [requestedPlan, setRequestedPlan] = useState<RequiredPlan>("pro")
const [requestedConnector, setRequestedConnector] = useState("This connector")
const { hasMax, connectorAccess, loading: planLoading } = useConnectorAccess()
const {
hasMax,
hasScale,
connectorAccess,
loading: planLoading,
} = useConnectorAccess()
const { org, isRestoring } = useAuth()
useEffect(() => {
@ -362,10 +365,12 @@ export function StepSources({
}
}, [connectedParam])
// company_brain unlocks pro connectors; max stays gated
// company_brain unlocks pro connectors; max and scale stay gated, and a
// higher tier satisfies a lower requirement.
const isLocked = (plan?: RequiredPlan) => {
if (!plan || planLoading) return false
if (plan === "max") return !hasMax
if (plan === "scale") return !hasScale
if (plan === "max") return !(hasMax || hasScale)
return !connectorAccess
}
@ -610,6 +615,7 @@ function OnboardingPlansModal({
requestedPlan: RequiredPlan
}) {
const autumn = useCustomer()
const promoCode = usePromoCode()
const { currentPlan, isLoading } = useTokenUsage(autumn)
const [upgradingPlan, setUpgradingPlan] = useState<CheckoutPlanId | null>(
null,
@ -628,8 +634,10 @@ function OnboardingPlansModal({
try {
const result = await autumn.attach({
planId,
discounts: promoCode.getDiscounts(),
successUrl: window.location.href,
})
promoCode.clear()
if ((result as { paymentUrl?: string })?.paymentUrl) {
window.location.href = (result as { paymentUrl: string }).paymentUrl
return
@ -1185,14 +1193,14 @@ function MoreSourcesGrid({
icon={<Github className="size-6 text-[#fafafa]" />}
state={values.connected.github ?? "idle"}
ctaLabel="Connect"
locked={isLocked("max")}
requiredPlan="max"
locked={isLocked("scale")}
requiredPlan="scale"
perks={[
"PRs and issues parsed",
"READMEs and docs indexed",
"Stays in sync with new activity",
]}
onConnect={guard("max", "GitHub", () => requestWaitlist("github"))}
onConnect={guard("scale", "GitHub", () => requestWaitlist("github"))}
/>
{mode === "personal" ? (
<GranolaSourceCard

View file

@ -0,0 +1,239 @@
"use client"
import { Gmail, GoogleDrive, Granola, MCPIcon, Notion } from "@ui/assets/icons"
import { GradientLogo } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { cn } from "@lib/utils"
import { ArrowRight, Loader2, ShieldCheck } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner"
import { SlackMark } from "@/components/brain-connector-icons"
import { analytics } from "@/lib/analytics"
import { dmSans125ClassName } from "@/lib/fonts"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
export const CHECKOUT_RETURN_PARAM = "brainTrial"
const TRIAL_DAYS = 14
/** The only reminder that lands before the charge; 15 and 17 are post-trial. */
const REMINDER_DAY = 12
const MONTHLY_PRICE = "$100"
function checkoutReturnUrl(): string {
const url = new URL(window.location.href)
url.searchParams.set(CHECKOUT_RETURN_PARAM, "complete")
return url.toString()
}
function dayOffset(days: number): string {
const at = new Date(Date.now() + days * 24 * 60 * 60 * 1000)
return at.toLocaleDateString(undefined, { month: "short", day: "numeric" })
}
const ORBIT = [
{ key: "slack", r: 74, deg: 0, node: <SlackMark className="size-4" /> },
{ key: "gmail", r: 74, deg: 128, node: <Gmail className="size-4" /> },
{ key: "notion", r: 74, deg: 236, node: <Notion className="size-4" /> },
{ key: "drive", r: 112, deg: 58, node: <GoogleDrive className="size-4" /> },
{ key: "granola", r: 112, deg: 172, node: <Granola className="size-4" /> },
{ key: "mcp", r: 112, deg: 296, node: <MCPIcon className="size-4" /> },
]
const SPIN = "motion-safe:animate-[spin_44s_linear_infinite]"
const SPIN_BACK = "motion-safe:animate-[spin_44s_linear_infinite_reverse]"
function BrainPanel() {
return (
<div className="relative hidden aspect-[3/2] w-[56%] shrink-0 items-center justify-center overflow-hidden rounded-xl bg-[#0B0E13] ring-1 ring-white/[0.06] md:flex">
<span
aria-hidden="true"
className="pointer-events-none absolute size-44 rounded-full bg-[#4BA0FA]/15 blur-3xl"
/>
<div aria-hidden="true" className="relative size-[248px]">
<span className="absolute left-1/2 top-1/2 size-[148px] -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/[0.07]" />
<span className="absolute left-1/2 top-1/2 size-[224px] -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/[0.05]" />
<div className={cn("absolute inset-0", SPIN)}>
{ORBIT.map(({ key, r, deg, node }) => (
<span
key={key}
style={{
transform: `translate(-50%, -50%) rotate(${deg}deg) translateY(-${r}px)`,
}}
className="absolute left-1/2 top-1/2 flex size-8 items-center justify-center rounded-full bg-[#161B22] ring-1 ring-white/10"
>
<span
className={cn("flex", SPIN_BACK)}
style={{ rotate: `${-deg}deg` }}
>
{node}
</span>
</span>
))}
</div>
<GradientLogo className="absolute left-1/2 top-1/2 h-auto w-[68px] -translate-x-1/2 -translate-y-1/2" />
</div>
<p className="absolute inset-x-0 bottom-5 text-center text-[13px] font-medium text-[#8b8b8b]">
Meet <span className="text-[#4BA0FA]">@supermemory</span>
</p>
</div>
)
}
function TimelineRow({
date,
title,
value,
current,
}: {
date: string
title: string
value?: string
current?: boolean
}) {
return (
<li className="relative flex items-start gap-3 pl-[18px]">
<span
aria-hidden="true"
className={cn(
"absolute left-0 top-[5px] size-[7px] rounded-full",
current
? "bg-[#fafafa] ring-4 ring-[#fafafa]/10"
: "bg-[#2b3138] ring-1 ring-white/15",
)}
/>
<div className="flex min-w-0 flex-1 items-baseline justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-[13px] font-medium text-[#fafafa]">{date}</span>
<span className="text-[12px] leading-snug text-[#8b8b8b]">
{title}
</span>
</div>
{value ? (
<span className="shrink-0 text-[14px] font-medium text-[#fafafa] tabular-nums">
{value}
</span>
) : null}
</div>
</li>
)
}
export function StepTrial({ onActive }: { onActive: () => void }) {
const [starting, setStarting] = useState(false)
const start = async () => {
if (starting) return
setStarting(true)
analytics.brainTrialCheckoutStarted()
try {
const res = await fetch(`${BACKEND}/brain/trial/start`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ successUrl: checkoutReturnUrl() }),
})
const data = (await res.json()) as {
checkoutUrl?: string | null
status?: string
error?: string
}
if (res.status === 409 || data.error === "trial_unavailable") {
throw new Error(
"This workspace has already used its free trial. Upgrade from billing to continue.",
)
}
if (!res.ok) throw new Error(data.error ?? "Couldn't start the trial.")
if (data.checkoutUrl) {
window.location.href = data.checkoutUrl
return
}
if (data.status === "already_active" || data.status === "attached") {
onActive()
return
}
throw new Error("Couldn't start the trial.")
} catch (error) {
console.error("Failed to start trial:", error)
toast.error(
error instanceof Error ? error.message : "Couldn't start the trial.",
)
setStarting(false)
}
}
return (
<div className="flex gap-6">
<div className="flex min-w-0 flex-1 flex-col gap-5">
<div className="flex flex-col gap-1.5">
<h2
className={cn(
dmSans125ClassName(),
"text-[22px] leading-tight font-medium text-[#fafafa]",
)}
>
Start your {TRIAL_DAYS}-day free trial
</h2>
<p className="text-[13px] leading-relaxed text-[#8b8b8b]">
Add a payment method to start. You will not be charged today. We
will email you before your first payment.
</p>
</div>
<ol className="relative flex flex-col gap-5 py-1">
<span
aria-hidden="true"
className="absolute left-[3px] top-2.5 bottom-[22px] w-px bg-white/10"
/>
<TimelineRow
date="Today"
title="Full access to Company Brain"
value="$0"
current
/>
<TimelineRow
date={dayOffset(REMINDER_DAY)}
title="We email you before the charge"
/>
<TimelineRow
date={dayOffset(TRIAL_DAYS)}
title="Trial ends"
value={`${MONTHLY_PRICE}/mo`}
/>
</ol>
<div className="flex flex-col items-center gap-3">
<Button
variant="insideOut"
onClick={start}
disabled={starting}
className="w-full justify-center rounded-full px-5 py-[11px] text-[13px] font-medium text-[#fafafa]"
>
{starting ? (
<>
Opening checkout
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
Start free trial
<ArrowRight className="size-3.5" />
</>
)}
</Button>
<p className="flex items-center gap-1.5 text-[12px] text-[#737373]">
<ShieldCheck className="size-3.5" />
Secured by Stripe · Cancel in one click
</p>
</div>
</div>
<BrainPanel />
</div>
)
}

View file

@ -394,11 +394,6 @@ export function SelectSpacesModal({
credentials: "include",
})
if (!res.ok) {
if (res.status === 403) {
throw new Error(
"Plugin access was denied. Check your plan or try again.",
)
}
const errorData = (await res.json().catch(() => ({}))) as {
message?: string
}

View file

@ -23,6 +23,7 @@ import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { useMutation, useQuery } from "@tanstack/react-query"
import {
Copy,
LoaderIcon,
ChevronDown,
Users,
@ -458,10 +459,26 @@ export default function Account({
<span
className={cn(
dmSans125ClassName(),
"text-[12px] tracking-[-0.12px] text-[#737373]",
"flex items-center gap-1 text-[12px] tracking-[-0.12px] text-[#737373]",
)}
>
Organization
{org?.id ? (
<button
type="button"
aria-label="Copy organization ID"
title={org.id}
onClick={() => {
navigator.clipboard
.writeText(org.id)
.then(() => toast.success("Organization ID copied"))
.catch(() => toast.error("Couldn't copy"))
}}
className="inline-flex size-4 shrink-0 items-center justify-center rounded transition-colors hover:text-[#FAFAFA]"
>
<Copy className="size-2.5" />
</button>
) : null}
</span>
{isEditingOrgName ? (
<form

View file

@ -36,6 +36,7 @@ import {
} from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { usePromoCode } from "@/hooks/use-promo-code"
const API_BASE =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
@ -137,6 +138,7 @@ const PLAN_CARDS: PlanCardDefinition[] = [
features: [
"Pay-as-you-go after $5 runs out",
"Full search and memory access",
"All plugins (Claude Code, Cursor, Hermes...)",
"Email support",
],
},
@ -151,7 +153,6 @@ const PLAN_CARDS: PlanCardDefinition[] = [
features: [
"Auto top-up when balance runs low",
"Google Drive, Notion, OneDrive & Granola connectors",
"All plugins (Claude Code, Cursor, Hermes...)",
"Priority support",
],
},
@ -203,8 +204,24 @@ const ADVANCED_PLAN_CARDS: PlanCardDefinition[] = [
},
]
// Company Brain workspaces only sell Scale / Enterprise (no Free, Pro, Max).
// Company Brain workspaces sell Max / Scale / Enterprise (no Free, Pro).
const COMPANY_BRAIN_PLAN_CARDS: PlanCardDefinition[] = [
{
id: "max",
name: "Max",
price: "$100",
period: "/mo",
credits: "$130",
productId: "api_max",
description: "Company Brain for teams with everyday usage",
mostPopular: true,
features: [
"Company Brain Slack agent & shared memory",
"$130 monthly usage credits",
"Unlimited seats",
"Auto top-up & spend caps",
],
},
{
id: "scale",
name: "Scale",
@ -212,13 +229,13 @@ const COMPANY_BRAIN_PLAN_CARDS: PlanCardDefinition[] = [
period: "/mo",
credits: "$600",
productId: "api_scale",
description: "Company Brain for your team, with production usage",
mostPopular: true,
description: "Company Brain for production workloads",
includesFrom: "Max",
features: [
"Company Brain Slack agent & shared memory",
"$600 monthly usage credits when paid",
"Auto top-up & spend caps",
"Team connectors & dedicated support",
"$600 monthly usage credits",
"GitHub, S3 & Web Crawler connectors",
"Restricted access, container tags & User Insights",
"Dedicated support",
],
},
{
@ -515,6 +532,7 @@ export default function Billing() {
const queryClient = useQueryClient()
const { user, org } = useAuth()
const autumn = useCustomer()
const promoCode = usePromoCode()
const posthog = usePostHog()
const isCompanyBrain = useHasCompanyBrain()
const brainTrial = useMemo(
@ -682,8 +700,10 @@ export default function Billing() {
try {
const result = await autumn.attach({
planId,
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#billing`,
})
promoCode.clear()
if ((result as { paymentUrl?: string })?.paymentUrl) {
window.location.href = (result as { paymentUrl: string }).paymentUrl
return
@ -914,6 +934,27 @@ export default function Billing() {
)
}
// The trial runs on api_scale, so Max ranks below the current plan and would
// otherwise render as a dead "Included with Scale" button. Trial users are
// exactly who we want on Max, so it needs its own actionable path.
if (plan.id === "max" && (isOnTrial || isBrainTrialEnded)) {
return (
<button
type="button"
onClick={() => handleUpgrade("api_max")}
disabled={disabled}
className={cn(
dmSans125ClassName(),
PLAN_CARD_ACTION_CLASS,
"bg-[#0054AD] text-[#FAFAFA] hover:bg-[#0B65C9]",
)}
>
{disabled ? <LoaderIcon className="size-4 animate-spin" /> : null}
Activate Max
</button>
)
}
// Trial Scale: primary CTA is activate paid Scale (not a dead "current" state).
if (plan.id === "scale" && (isOnTrial || isBrainTrialEnded)) {
return (
@ -1471,6 +1512,20 @@ export default function Billing() {
}
/>
))}
<div className="md:col-span-2 space-y-2 rounded-lg border border-white/[0.08] bg-white/[0.02] p-4 text-[13px] leading-relaxed text-[#A3A3A3]">
{isOnTrial ? (
<p>
Your trial runs on Scale. Moving to Max keeps the agent,
shared memory and unlimited seats, and drops the GitHub, S3
and Web Crawler connectors, restricted access and container
tags, and User Insights.
</p>
) : null}
<p>
Using more than about $400 of credits a month? Scale works out
cheaper than Max plus top-ups.
</p>
</div>
</div>
) : (
<>

View file

@ -19,7 +19,8 @@ import {
Radar,
Trash2,
} from "lucide-react"
import { useRef, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { toast } from "sonner"
import {
Select,
@ -35,6 +36,8 @@ import {
TooltipTrigger,
} from "@ui/components/tooltip"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { useOrgMemberRole } from "@/hooks/use-org-member-role"
import { configureSectionToPath } from "@/lib/configure-routes"
import { dmSans125ClassName } from "@/lib/fonts"
const BACKEND =
@ -301,6 +304,9 @@ function AutomationCard({
id,
channels,
ownerLabel,
personalOnlyApps = [],
isAdmin = false,
appCatalog = {},
onDone,
onCancelNew,
onCollapse,
@ -309,6 +315,9 @@ function AutomationCard({
id: string | null
channels: Channel[]
ownerLabel?: string
personalOnlyApps?: string[]
isAdmin?: boolean
appCatalog?: Record<string, { name: string; iconDomain?: string }>
onDone: () => void
onCancelNew?: () => void
onCollapse?: () => void
@ -349,9 +358,18 @@ function AutomationCard({
const b = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(b.error ?? "Couldn't save.")
}
const b = (await res.json().catch(() => ({}))) as {
warnings?: { app: string }[]
}
return b.warnings ?? []
},
onSuccess: () => {
onSuccess: (warnings) => {
toast.success("Automation saved.")
if (warnings.length)
toast.warning(
`Heads up: ${warnings.map((w) => w.app).join(", ")} ${warnings.length === 1 ? "is" : "are"} connected personally and won't be available to this channel automation. ${isAdmin ? "Reconnect it for the workspace in Connections." : "Ask an admin to connect it for the workspace."}`,
{ duration: 10000 },
)
onDone()
},
onError: (err) =>
@ -619,6 +637,51 @@ function AutomationCard({
Cancel
</button>
) : null}
{draft.deliverTo === "channel" && personalOnlyApps.length > 0 && (
<span className="flex min-w-0 items-center gap-2 text-[12px] leading-snug text-amber-300/80">
<TooltipProvider>
<span className="flex shrink-0 -space-x-1">
{personalOnlyApps.map((app) => (
<Tooltip key={app}>
<TooltipTrigger asChild>
{appCatalog[app]?.iconDomain ? (
<img
alt={appCatalog[app]?.name ?? app}
className="size-4 cursor-default select-none rounded-full bg-white/10 ring-1 ring-black/50"
src={`https://www.google.com/s2/favicons?domain=${appCatalog[app]?.iconDomain}&sz=32`}
/>
) : (
<span className="flex size-4 cursor-default select-none items-center justify-center rounded-full bg-amber-500/20 text-[9px] uppercase ring-1 ring-black/50">
{app.slice(0, 1)}
</span>
)}
</TooltipTrigger>
<TooltipContent>
{appCatalog[app]?.name ?? app}
</TooltipContent>
</Tooltip>
))}
</span>
</TooltipProvider>
<span className="truncate">
only connected to you ·{" "}
{isAdmin ? (
<>
<a
className="underline underline-offset-2 hover:text-amber-200"
href={configureSectionToPath("tools")}
>
Connect for workspace
</a>{" "}
to use here
</>
) : (
"ask an admin to connect it for the workspace"
)}
</span>
</span>
)}
</div>
<div className="flex items-center gap-2">
{id ? (
@ -851,8 +914,14 @@ function PresetCard({
export default function CompanyBrainAutomations() {
const isCompanyBrain = useHasCompanyBrain()
const { user, org } = useAuth()
const { isAdmin } = useOrgMemberRole(isCompanyBrain)
const queryClient = useQueryClient()
const [drafts, setDrafts] = useState<{ key: number; draft: Draft }[]>([])
const [showAllTemplates, setShowAllTemplates] = useState(false)
const [actionSlot, setActionSlot] = useState<HTMLElement | null>(null)
useEffect(() => {
setActionSlot(document.getElementById("configure-section-actions"))
}, [])
const [openId, setOpenId] = useState<string | null>(null)
const draftKey = useRef(0)
const addDraft = (draft: Draft) =>
@ -886,11 +955,15 @@ export default function CompanyBrainAutomations() {
const res = await fetch(`${BACKEND}/brain/mcp-connections/`, {
credentials: "include",
})
if (!res.ok) return [] as string[]
if (!res.ok) return [] as { serverSlug: string; userId: string | null }[]
const body = (await res.json()) as {
connections?: { serverSlug: string }[]
connections?: {
serverSlug: string
userId: string | null
status: string
}[]
}
return (body.connections ?? []).map((c) => c.serverSlug)
return (body.connections ?? []).filter((c) => c.status === "active")
},
enabled: isCompanyBrain,
})
@ -899,7 +972,39 @@ export default function CompanyBrainAutomations() {
const channels = channelsQuery.data ?? []
const automations = listQuery.data ?? []
const presets = sortPresets(new Set(appsQuery.data ?? []))
const catalogQuery = useQuery({
queryKey: ["company-brain-automations", "catalog", "v2"],
queryFn: async () => {
const res = await fetch(`${BACKEND}/brain/mcp-connections/catalog`, {
credentials: "include",
})
if (!res.ok)
return {} as Record<string, { name: string; iconDomain?: string }>
const body = (await res.json()) as {
catalog?: { slug: string; name?: string; iconDomain?: string }[]
}
return Object.fromEntries(
(body.catalog ?? []).map((e) => [
e.slug,
{ name: e.name ?? e.slug, iconDomain: e.iconDomain },
]),
)
},
enabled: isCompanyBrain,
})
const connections = appsQuery.data ?? []
const presets = sortPresets(new Set(connections.map((c) => c.serverSlug)))
const sharedApps = new Set(
connections.filter((c) => c.userId === null).map((c) => c.serverSlug),
)
const personalOnlyApps = [
...new Set(
connections
.filter((c) => c.userId !== null && !sharedApps.has(c.serverSlug))
.map((c) => c.serverSlug),
),
]
const nameFor = (userId: string | null): string | undefined => {
if (!userId) return undefined
if (userId === user?.id) return "You"
@ -913,10 +1018,34 @@ export default function CompanyBrainAutomations() {
}
const usedTitles = new Set(automations.map((a) => a.title))
const availablePresets = presets.filter((p) => !usedTitles.has(p.label))
const shownPresets = showAllTemplates
? availablePresets
: availablePresets.slice(0, 3)
const hiddenTemplateCount = availablePresets.length - shownPresets.length
const hasList = automations.length > 0 || drafts.length > 0
const newAutomationButton = (
<button
type="button"
onClick={() => addDraft(emptyDraft())}
className={cn(
dmSans125ClassName(),
"inline-flex h-9 items-center justify-center gap-2 rounded-full bg-[#14161A] px-4 text-[13px] font-semibold text-[#FAFAFA] shadow-inside-out transition-colors hover:bg-[#121820]",
)}
>
<Plus className="size-4" />
New automation
</button>
)
const newAutomationPortal = actionSlot ? (
createPortal(newAutomationButton, actionSlot)
) : (
<div className="flex justify-end">{newAutomationButton}</div>
)
return (
<section className="flex flex-col gap-3 px-1">
{newAutomationPortal}
<div className="flex flex-col gap-3">
{automations.map((a) =>
openId === a.id ? (
@ -925,6 +1054,9 @@ export default function CompanyBrainAutomations() {
id={a.id}
initial={toDraft(a)}
channels={channels}
personalOnlyApps={personalOnlyApps}
isAdmin={isAdmin}
appCatalog={catalogQuery.data ?? {}}
onDone={() => {
setOpenId(null)
refresh()
@ -953,6 +1085,9 @@ export default function CompanyBrainAutomations() {
id={null}
initial={draft}
channels={channels}
personalOnlyApps={personalOnlyApps}
isAdmin={isAdmin}
appCatalog={catalogQuery.data ?? {}}
onDone={() => {
removeDraft(key)
refresh()
@ -961,37 +1096,38 @@ export default function CompanyBrainAutomations() {
/>
))}
{hasList ? (
<p
className={cn(
dmSans125ClassName(),
"pt-2 text-[12px] font-medium text-[#6B6B6B]",
)}
>
Templates
</p>
) : null}
<p
className={cn(
dmSans125ClassName(),
hasList ? "pt-2" : "",
"text-[12px] font-medium text-[#6B6B6B]",
)}
>
{showAllTemplates ? "Templates" : "Ideas for your setup"}
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{availablePresets.map((p) => (
{shownPresets.map((p) => (
<PresetCard
key={p.id}
preset={p}
onPick={() => addDraft(presetToDraft(p))}
/>
))}
</div>
{hiddenTemplateCount > 0 || showAllTemplates ? (
<button
type="button"
onClick={() => addDraft(emptyDraft())}
onClick={() => setShowAllTemplates((v) => !v)}
className={cn(
dmSans125ClassName(),
"flex min-h-[104px] cursor-pointer items-center justify-center gap-2 rounded-xl border border-[#2A313C] border-dashed",
"text-[13px] font-medium text-[#737B87] transition-colors hover:border-[#3A4150] hover:text-[#FAFAFA]",
"self-start text-[12px] font-medium text-[#737B87] transition-colors hover:text-[#FAFAFA]",
)}
>
<Plus className="size-4" />
New automation
{showAllTemplates
? "Show fewer"
: `Show all ${availablePresets.length} templates`}
</button>
</div>
) : null}
</div>
</section>
)

View file

@ -38,6 +38,7 @@ import {
getConnectionSubtitle,
} from "@/components/settings/sync-utils"
import type { ImportProvider } from "@/components/settings/sync-utils"
import { usePromoCode } from "@/hooks/use-promo-code"
type Connection = z.infer<typeof ConnectionResponseSchema>
@ -420,6 +421,7 @@ function FeatureItem({ text }: { text: string }) {
export default function ConnectionsMCP() {
const queryClient = useQueryClient()
const autumn = useCustomer()
const promoCode = usePromoCode()
const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam)
const router = useRouter()
const [removeDialog, setRemoveDialog] = useState<{
@ -552,8 +554,10 @@ export default function ConnectionsMCP() {
try {
const result = await autumn.attach({
planId: "api_pro",
discounts: promoCode.getDiscounts(),
successUrl: `${window.location.origin}/settings#connections`,
})
promoCode.clear()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return

View file

@ -51,6 +51,7 @@ function SlackMark({ className }: { className?: string }) {
export function SlackConnectCard() {
const isCompanyBrain = useHasCompanyBrain()
const [status, setStatus] = useState<SlackStatus | null>(null)
const [trialActive, setTrialActive] = useState(true)
const [loading, setLoading] = useState(true)
useEffect(() => {
@ -58,10 +59,16 @@ export function SlackConnectCard() {
let active = true
;(async () => {
try {
const res = await fetch(`${BACKEND}/brain/slack/status`, {
credentials: "include",
})
if (active && res.ok) setStatus((await res.json()) as SlackStatus)
const [slackRes, trialRes] = await Promise.all([
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
fetch(`${BACKEND}/brain/trial/status`, { credentials: "include" }),
])
if (!active) return
if (slackRes.ok) setStatus((await slackRes.json()) as SlackStatus)
if (trialRes.ok) {
const trial = (await trialRes.json()) as { active?: boolean }
setTrialActive(Boolean(trial.active))
}
} finally {
if (active) setLoading(false)
}
@ -92,7 +99,7 @@ export function SlackConnectCard() {
<span className="size-1.5 rounded-full bg-[#2EB67D]" />
Connected
</span>
) : (
) : trialActive ? (
<a
href={`${BACKEND}/brain/slack/oauth/install`}
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-white px-3.5 py-2 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02]"
@ -100,6 +107,13 @@ export function SlackConnectCard() {
<SlackMark className="size-4" />
Add to Slack
</a>
) : (
<a
href="/onboarding"
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-white/10 px-3.5 py-2 text-[13px] font-semibold text-fg-primary ring-1 ring-surface-border transition-colors hover:bg-white/15"
>
Finish setting up
</a>
)}
</div>
)

View file

@ -0,0 +1,41 @@
"use client"
import { ArrowRight, CreditCard } from "lucide-react"
import Link from "next/link"
import { useTrialStatus } from "@/hooks/use-trial-status"
export function TrialSetupBanner() {
const { needsSetup, data } = useTrialStatus()
if (!needsSetup) return null
const endedTrial = data?.reason === "trial_ended"
return (
<div className="flex items-center justify-between gap-4 rounded-[14px] bg-[#191D24] px-4 py-3 ring-1 ring-[#4BA0FA]/20 sm:px-5">
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-[#4BA0FA]/12">
<CreditCard className="size-4 text-[#4BA0FA]" />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-fg-primary">
{endedTrial
? "Your Company Brain trial has ended"
: "Finish setting up Company Brain"}
</p>
<p className="mt-0.5 truncate text-[12px] text-fg-muted">
{endedTrial
? "Move to Max or Scale to switch the brain back on."
: "Add a card to start your 14-day trial. $0 today."}
</p>
</div>
</div>
<Link
href={endedTrial ? "/?settings=billing" : "/onboarding"}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg bg-white px-3.5 py-2 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02]"
>
{endedTrial ? "Upgrade" : "Add card"}
<ArrowRight className="size-3.5" />
</Link>
</div>
)
}

View file

@ -9,9 +9,12 @@ export function useConnectorAccess(opts?: { enabled?: boolean }) {
const hasCompanyBrain = useHasCompanyBrain()
const hasPro = enabled && hasActivePlan(autumn.data?.subscriptions, "api_pro")
const hasMax = enabled && hasActivePlan(autumn.data?.subscriptions, "api_max")
const hasScale =
enabled && hasActivePlan(autumn.data?.subscriptions, "api_scale")
return {
hasPro,
hasMax,
hasScale,
hasCompanyBrain,
connectorAccess: hasPro || hasCompanyBrain,
loading: enabled && autumn.isLoading,

View file

@ -0,0 +1,82 @@
"use client"
import { useAuth } from "@lib/auth-context"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useMemo } from "react"
import { toast } from "sonner"
const PENDING_PROMO_CODE_KEY = "sm.promoCode.pending"
const PROMO_TOAST_ID = "promo-code"
function promoCodeKey(orgId: string): string {
return `sm.promoCode.org_${orgId}`
}
function readOrgPromoCode(orgId?: string): string | null {
if (!orgId || typeof window === "undefined") return null
return window.localStorage.getItem(promoCodeKey(orgId))
}
export function usePromoCode() {
const { org } = useAuth()
const orgId = org?.id
const getDiscounts = useCallback(() => {
const promotionCode = readOrgPromoCode(orgId)
return promotionCode ? [{ promotionCode }] : undefined
}, [orgId])
const clear = useCallback(() => {
if (!orgId) return
window.localStorage.removeItem(promoCodeKey(orgId))
toast.dismiss(PROMO_TOAST_ID)
}, [orgId])
return useMemo(() => ({ getDiscounts, clear }), [getDiscounts, clear])
}
export function PromoCodeCapture() {
useEffect(() => {
const url = new URL(window.location.href)
const code = url.searchParams.get("discountCode")
if (!code) return
window.localStorage.setItem(PENDING_PROMO_CODE_KEY, code)
url.searchParams.delete("discountCode")
window.history.replaceState({}, "", url.toString())
}, [])
return null
}
export function PromoCodeHost() {
const { org } = useAuth()
const router = useRouter()
useEffect(() => {
if (!org?.id) return
const pending = window.localStorage.getItem(PENDING_PROMO_CODE_KEY)
if (pending) {
window.localStorage.setItem(promoCodeKey(org.id), pending)
window.localStorage.removeItem(PENDING_PROMO_CODE_KEY)
}
const code = readOrgPromoCode(org.id)
if (!code) {
toast.dismiss(PROMO_TOAST_ID)
return
}
toast.success("Discount code active", {
id: PROMO_TOAST_ID,
description: `Code ${code} will apply at checkout.`,
duration: Number.POSITIVE_INFINITY,
action: {
label: "Upgrade",
onClick: () => router.push("/settings#billing"),
},
})
}, [org?.id, router])
return null
}

View file

@ -0,0 +1,34 @@
import { useQuery } from "@tanstack/react-query"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
export type TrialStatus = {
active: boolean
reason: string | null
}
/** Distinguishes a named Company Brain org from one whose trial is actually live. */
export function useTrialStatus() {
const isCompanyBrain = useHasCompanyBrain()
const query = useQuery({
queryKey: ["brain", "trial-status"],
queryFn: async (): Promise<TrialStatus> => {
const res = await fetch(`${BACKEND}/brain/trial/status`, {
credentials: "include",
})
if (!res.ok) throw new Error("Failed to load trial status")
const data = (await res.json()) as { active?: boolean; reason?: string }
return { active: Boolean(data.active), reason: data.reason ?? null }
},
enabled: isCompanyBrain,
staleTime: 30 * 1000,
})
return {
...query,
needsSetup: isCompanyBrain && query.data ? !query.data.active : false,
}
}

View file

@ -271,4 +271,9 @@ export const analytics = {
}) => safeCapture("company_brain_promo_clicked", props),
companyBrainPromoDismissed: () =>
safeCapture("company_brain_promo_dismissed"),
brainTrialCardViewed: () => safeCapture("brain_trial_card_viewed"),
brainTrialCheckoutStarted: () => safeCapture("brain_trial_checkout_started"),
brainTrialCheckoutAbandoned: () =>
safeCapture("brain_trial_checkout_abandoned"),
}

View file

@ -185,18 +185,26 @@ export function getBrainTrialInfo(
}
/**
* Format a number with K/M suffix for display
* Format a number with K/M/B suffix for display
* @example formatUsageNumber(1500000) => "1.5M"
* @example formatUsageNumber(50000) => "50K"
* @example formatUsageNumber(999950) => "1.0M"
*/
export function formatUsageNumber(value: number): string {
const withSuffix = (n: number, suffix: string) =>
n % 1 === 0 ? `${n}${suffix}` : `${n.toFixed(1)}${suffix}`
if (value >= 1_000_000) {
const millions = value / 1_000_000
return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`
return millions >= 999.95
? withSuffix(value / 1_000_000_000, "B")
: withSuffix(millions, "M")
}
if (value >= 1_000) {
const thousands = value / 1_000
return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`
return thousands >= 999.95
? withSuffix(value / 1_000_000, "M")
: withSuffix(thousands, "K")
}
return value.toString()
}

View file

@ -1,9 +1,9 @@
import type { ModelId } from "@/lib/models"
const OTHER_MODELS: ModelId[] = [
"gpt-5.1",
"claude-sonnet-4.6",
"gemini-2.5-pro",
"gpt-5.6-terra",
"claude-sonnet-5",
"gemini-3.1-pro-preview",
]
function flattenError(e: unknown): string {

View file

@ -1,22 +1,22 @@
export const models = [
{
id: "grok-4.3",
name: "Grok 4.3",
id: "grok-4.5",
name: "Grok 4.5",
description: "xAI's latest model",
},
{
id: "gpt-5.1",
name: "GPT 5.1",
id: "gpt-5.6-terra",
name: "GPT 5.6",
description: "OpenAI's latest model",
},
{
id: "claude-sonnet-4.6",
name: "Claude Sonnet 4.6",
id: "claude-sonnet-5",
name: "Claude Sonnet 5",
description: "Anthropic's advanced model",
},
{
id: "gemini-2.5-pro",
name: "Gemini 3 Pro",
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro",
description: "Google's most capable model",
},
] as const
@ -25,10 +25,10 @@ export type ModelId = (typeof models)[number]["id"]
export type ReasoningEffort = "instant" | "thinking"
export const modelNames: Record<ModelId, { name: string; version: string }> = {
"grok-4.3": { name: "Grok", version: "4.3" },
"gpt-5.1": { name: "GPT", version: "5.1" },
"claude-sonnet-4.6": { name: "Claude", version: "4.6" },
"gemini-2.5-pro": { name: "Gemini", version: "3 Pro" },
"grok-4.5": { name: "Grok", version: "4.5" },
"gpt-5.6-terra": { name: "GPT", version: "5.6" },
"claude-sonnet-5": { name: "Claude", version: "Sonnet 5" },
"gemini-3.1-pro-preview": { name: "Gemini", version: "3.1 Pro" },
}
export const reasoningOptions: Array<{

View file

@ -10,6 +10,7 @@
"dev": "portless",
"dev:app": "next dev --port ${PORT:-3000}",
"build": "next build",
"check-types": "tsc --noEmit",
"start": "next start",
"lint": "biome check --write",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",

View file

@ -1,5 +1,8 @@
{
"name": "@repo/hooks",
"version": "0.0.0",
"private": true
"private": true,
"scripts": {
"check-types": "tsc --noEmit"
}
}

View file

@ -3,6 +3,7 @@ import {
anonymousClient,
apiKeyClient,
emailOTPClient,
genericOAuthClient,
magicLinkClient,
organizationClient,
usernameClient,
@ -19,6 +20,7 @@ export const authClient = createAuthClient({
usernameClient(),
magicLinkClient(),
emailOTPClient(),
genericOAuthClient(),
apiKeyClient(),
adminClient(),
organizationClient(),

View file

@ -3,6 +3,9 @@
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"check-types": "tsc --noEmit"
},
"exports": {
"./*": "./*"
},

View file

@ -65,6 +65,13 @@ describe("getMemoryBorderColor", () => {
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memBorderExpiring)
})
it("does not treat an already-elapsed forgetAfter as expiring", () => {
const past = new Date(Date.now() - 60 * 1000).toISOString()
const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
const mem = makeMemory({ forgetAfter: past, createdAt: old })
expect(getMemoryBorderColor(mem, colors)).toBe(colors.memStrokeDefault)
})
it("returns recent color for memories created within 24 hours", () => {
const recent = new Date(Date.now() - 1000).toISOString()
const mem = makeMemory({ createdAt: recent })

View file

@ -467,10 +467,10 @@ export function MemoryGraph({
n.x,
n.y,
containerSize.width,
containerSize.height,
graphFitHeight,
)
},
[nodes, containerSize.width, containerSize.height],
[nodes, containerSize.width, graphFitHeight],
)
const navigateUp = useCallback(() => {

View file

@ -47,7 +47,7 @@ export function getMemoryBorderColor(
if (mem.isForgotten) return colors.memBorderForgotten
if (mem.forgetAfter) {
const msLeft = new Date(mem.forgetAfter).getTime() - Date.now()
if (msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring
if (msLeft > 0 && msLeft < SEVEN_DAYS_MS) return colors.memBorderExpiring
}
const age = Date.now() - new Date(mem.createdAt).getTime()
if (age < ONE_DAY_MS) return colors.memBorderRecent

View file

@ -408,7 +408,7 @@ Optional for testing:
### Required
- `openai>=1.102.0` - Official OpenAI Python SDK
- `supermemory>=3.1.0` - Supermemory client
- `supermemory>=3.50.0` - Supermemory client
- `requests>=2.25.0` - HTTP requests (fallback)
### Optional

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "supermemory-openai-sdk"
version = "1.0.4"
version = "1.0.5"
description = "Memory tools for OpenAI function calling with supermemory"
readme = "README.md"
license = "MIT"

View file

@ -1,4 +1,5 @@
const DEFAULT_BASE_URL = "https://api.supermemory.ai"
const FETCH_TIMEOUT_MS = 30_000
export interface ForgetMemoryParams {
containerTag: string
@ -7,6 +8,10 @@ export interface ForgetMemoryParams {
reason?: string
}
export interface ForgetMemoryRequestOptions {
signal?: AbortSignal
}
/**
* Marks a memory as forgotten via `DELETE /v4/memories`.
*
@ -19,6 +24,7 @@ export async function forgetMemoryRequest(
apiKey: string,
params: ForgetMemoryParams,
baseUrl: string = DEFAULT_BASE_URL,
options?: ForgetMemoryRequestOptions,
): Promise<void> {
const response = await fetch(`${baseUrl}/v4/memories`, {
method: "DELETE",
@ -27,6 +33,7 @@ export async function forgetMemoryRequest(
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(params),
signal: options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS),
})
if (!response.ok) {

View file

@ -109,6 +109,22 @@ describe("memoryForget", () => {
id: "mem_1",
reason: "outdated",
})
expect(init.signal).toBeInstanceOf(AbortSignal)
})
it("uses a caller-provided signal instead of creating a timeout", async () => {
const fetchMock = stubFetch()
const controller = new AbortController()
await forgetMemoryRequest(
API_KEY,
{ containerTag: "user_1", id: "mem_1" },
undefined,
{ signal: controller.signal },
)
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(init.signal).toBe(controller.signal)
})
it("throws a descriptive error on non-2xx responses", async () => {

View file

@ -3,6 +3,9 @@
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"check-types": "tsc --noEmit"
},
"exports": {
"./*": "./*"
},

View file

@ -1,6 +1,11 @@
import { describe, expect, it } from "bun:test"
import { readFileSync } from "node:fs"
import { SearchRequestSchema, Searchv4RequestSchema } from "./api"
import {
DocumentsWithMemoriesQuerySchema,
ListMemoriesQuerySchema,
SearchRequestSchema,
Searchv4RequestSchema,
} from "./api"
describe("search threshold schemas", () => {
it("do not contain redundant number transforms or unreachable range guards", () => {
@ -80,3 +85,70 @@ describe("search threshold schemas", () => {
).toBe(false)
})
})
describe("pagination query schemas", () => {
it("preserve page/limit defaults", () => {
const listed = ListMemoriesQuerySchema.parse({})
expect(listed.page).toBe(1)
expect(listed.limit).toBe(10)
const docs = DocumentsWithMemoriesQuerySchema.parse({})
expect(docs.page).toBe(1)
expect(docs.limit).toBe(10)
})
it.each([
1, 50, 1100,
])("ListMemoriesQuerySchema accepts numeric limit %p", (limit) => {
expect(ListMemoriesQuerySchema.parse({ limit }).limit).toBe(limit)
})
it("ListMemoriesQuerySchema accepts numeric string page/limit", () => {
const parsed = ListMemoriesQuerySchema.parse({ page: "3", limit: "25" })
expect(parsed.page).toBe(3)
expect(parsed.limit).toBe(25)
})
it.each([
0, -5, 2.5,
])("ListMemoriesQuerySchema rejects non-positive or fractional numeric limit %p", (limit) => {
expect(ListMemoriesQuerySchema.safeParse({ limit }).success).toBe(false)
})
it.each([
0, -1, 1.5,
])("ListMemoriesQuerySchema rejects non-positive or fractional numeric page %p", (page) => {
expect(ListMemoriesQuerySchema.safeParse({ page }).success).toBe(false)
})
it("ListMemoriesQuerySchema still caps limit at 1100", () => {
expect(ListMemoriesQuerySchema.safeParse({ limit: 1101 }).success).toBe(
false,
)
})
it.each([
0, -1, 2.5,
])("DocumentsWithMemoriesQuerySchema rejects invalid page %p", (page) => {
expect(DocumentsWithMemoriesQuerySchema.safeParse({ page }).success).toBe(
false,
)
})
it.each([
0, -10, 2.5,
])("DocumentsWithMemoriesQuerySchema rejects invalid limit %p", (limit) => {
expect(DocumentsWithMemoriesQuerySchema.safeParse({ limit }).success).toBe(
false,
)
})
it("DocumentsWithMemoriesQuerySchema accepts a normal request", () => {
const parsed = DocumentsWithMemoriesQuerySchema.parse({
page: 2,
limit: 50,
})
expect(parsed.page).toBe(2)
expect(parsed.limit).toBe(50)
})
})

View file

@ -275,6 +275,9 @@ export const ListMemoriesQuerySchema = z
.regex(/^\d+$/)
.or(z.number())
.transform(Number)
.refine((value) => Number.isInteger(value) && value >= 1, {
message: "Limit must be a positive integer",
})
.refine((value) => value <= 1100, {
message: "Limit cannot be greater than 1100",
})
@ -292,6 +295,9 @@ export const ListMemoriesQuerySchema = z
.regex(/^\d+$/)
.or(z.number())
.transform(Number)
.refine((value) => Number.isInteger(value) && value >= 1, {
message: "Page must be a positive integer",
})
.default("1")
.openapi({ description: "Page number to fetch", example: "1" }),
sort: z
@ -1092,11 +1098,11 @@ export const DocumentsWithMemoriesResponseSchema = z
export const DocumentsWithMemoriesQuerySchema = z
.object({
page: z.number().default(1).openapi({
page: z.number().int().min(1).default(1).openapi({
description: "Page number to fetch",
example: 1,
}),
limit: z.number().default(10).openapi({
limit: z.number().int().min(1).default(10).openapi({
description: "Number of items per page",
example: 10,
}),

View file

@ -2,5 +2,8 @@
"name": "@repo/validation",
"version": "0.0.0",
"private": true,
"type": "module"
"type": "module",
"scripts": {
"check-types": "tsc --noEmit"
}
}