Merge dalat-v1 into mcp-revamp-followup; keep redesigned widget files

Resolves the PR #1189 conflict against its base. Both branches edited
apps/mcp/src/widget/{components/WorkspaceCard.tsx,design/tokens.css};
dalat-v1's dark-mode-readability tweak is superseded by the full widget
redesign, so both are resolved to the redesigned versions (which already
carry the memory/memories wording).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Prasanna A P 2026-07-20 18:29:44 -07:00
commit 967397eabd
109 changed files with 8705 additions and 1462 deletions

View file

@ -3,10 +3,15 @@ name: CI - Type Check, Format & Lint
on:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
quality-checks:
name: Quality Checks
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4

View file

@ -19,6 +19,7 @@ jobs:
github.event.workflow_run.conclusion == 'failure' &&
github.event.workflow_run.pull_requests[0]
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v5
@ -41,18 +42,22 @@ jobs:
- name: Get CI failure details
id: failure_details
uses: actions/github-script@v7
env:
RUN_ID: ${{ github.event.workflow_run.id }}
with:
script: |
const runId = Number(process.env.RUN_ID);
const run = await github.rest.actions.getWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{ github.event.workflow_run.id }}
run_id: runId
});
const jobs = await github.rest.actions.listJobsForWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{ github.event.workflow_run.id }}
run_id: runId
});
const failedJobs = jobs.data.jobs.filter(job => job.conclusion === 'failure');

View file

@ -4,14 +4,23 @@ on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
claude-review:
# Fork PRs run with a read-only token and no secrets/OIDC id-token, so the
# Claude action can never authenticate there and the job always fails. Skip
# it for forks so those PRs report a clean skipped check instead of a red X.
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'graphite-app[bot]' &&
github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
pull-requests: write

View file

@ -13,11 +13,36 @@ on:
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
(
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '@claude') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')
) ||
(
github.event_name == 'pull_request_review_comment' &&
contains(github.event.comment.body, '@claude') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR')
) ||
(
github.event_name == 'pull_request_review' &&
contains(github.event.review.body, '@claude') &&
(github.event.review.author_association == 'OWNER' ||
github.event.review.author_association == 'MEMBER' ||
github.event.review.author_association == 'COLLABORATOR')
) ||
(
github.event_name == 'issues' &&
(contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) &&
(github.event.issue.author_association == 'OWNER' ||
github.event.issue.author_association == 'MEMBER' ||
github.event.issue.author_association == 'COLLABORATOR')
)
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
pull-requests: read

View file

@ -7,9 +7,14 @@ on:
paths:
- "packages/agent-framework-python/pyproject.toml"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write

View file

@ -7,9 +7,14 @@ on:
paths:
- "packages/ai-sdk/package.json"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write

View file

@ -7,9 +7,14 @@ on:
paths:
- "packages/cartesia-sdk-python/pyproject.toml"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write

View file

@ -7,9 +7,14 @@ on:
paths:
- "packages/memory-graph/package.json"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write

View file

@ -7,9 +7,14 @@ on:
paths:
- "packages/openai-sdk-python/pyproject.toml"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write

View file

@ -7,9 +7,14 @@ on:
paths:
- "packages/pipecat-sdk-python/pyproject.toml"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write

View file

@ -7,9 +7,14 @@ on:
paths:
- "packages/tools/package.json"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
id-token: write

View file

@ -36,16 +36,7 @@ Before you begin, ensure you have the following installed:
# You'll need to add your API keys and database URLs
```
4. **Change proxy for local development**
Add this in your `proxy.ts`(apps/web) before retrieving the cookie (`getSessionCookie(request)`):
```ts
if (url.hostname === "localhost") {
return NextResponse.next();
}
5. **Start the Development Server**
4. **Start the Development Server**
```bash
bun run dev:local

View file

@ -341,11 +341,12 @@ const client = new Supermemory({
```
- **Bring any model** — OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint. An interactive wizard walks you through it on first boot.
- **Embeddings** — local `Xenova/bge-base-en-v1.5` by default (no API key); optionally OpenAI, Gemini, or Ollama. Same provider stack as cloud.
- **Fully offline if you want** — point it at Ollama (`gpt-oss:20b` works great) and nothing leaves your machine.
- **Your data, one directory** — everything lives in `./.supermemory`, easy to back up or move.
- **Same API as the platform** — prototype locally, ship on the hosted platform by changing `baseURL`.
Read the [self-hosting docs](https://supermemory.ai/docs/self-hosting/overview) — quickstart, configuration, and [local vs. Enterprise](https://supermemory.ai/docs/self-hosting/local-vs-enterprise).
Read the [self-hosting docs](https://supermemory.ai/docs/self-hosting/overview) — quickstart, [configuration](https://supermemory.ai/docs/self-hosting/configuration), [embeddings](https://supermemory.ai/docs/self-hosting/embeddings), and [local vs. Enterprise](https://supermemory.ai/docs/self-hosting/local-vs-enterprise).
---

View file

@ -148,7 +148,17 @@ export default defineBackground(() => {
eventSource: string,
): Promise<{ success: boolean; data?: unknown; error?: string }> => {
try {
const responseData = await searchMemories(data)
let containerTag: string = CONTAINER_TAGS.DEFAULT_PROJECT
try {
const defaultProject = await getDefaultProject()
if (defaultProject?.containerTag) {
containerTag = defaultProject.containerTag
}
} catch (error) {
console.warn("Failed to get default project, using fallback:", error)
}
const responseData = await searchMemories(data, containerTag)
const response = responseData as {
results?: Array<{ memory?: string }>
}

View file

@ -4,5 +4,6 @@
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"types": ["chrome"]
}
},
"exclude": ["**/*.test.ts"]
}

View file

@ -3,6 +3,7 @@
*/
import { API_ENDPOINTS } from "./constants"
import { bearerToken, defaultProject, userData } from "./storage"
import { buildSearchMemoriesBody } from "./search-request"
import {
AuthenticationError,
type MemoryPayload,
@ -145,14 +146,14 @@ export async function saveMemory(payload: MemoryPayload): Promise<unknown> {
/**
* Search memories using Supermemory API
*/
export async function searchMemories(query: string): Promise<unknown> {
export async function searchMemories(
query: string,
containerTag?: string,
): Promise<unknown> {
try {
const response = await makeAuthenticatedRequest<unknown>("/v4/search", {
method: "POST",
body: JSON.stringify({
q: query,
include: { relatedMemories: true },
}),
body: JSON.stringify(buildSearchMemoriesBody(query, containerTag)),
})
return response
} catch (error) {

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from "bun:test"
import { buildSearchMemoriesBody } from "./search-request"
describe("buildSearchMemoriesBody", () => {
it("builds the default related-memory search body", () => {
expect(buildSearchMemoriesBody("deploy notes")).toEqual({
q: "deploy notes",
include: { relatedMemories: true },
})
})
it("includes the container tag when provided", () => {
expect(buildSearchMemoriesBody("deploy notes", "sm_project_docs")).toEqual({
q: "deploy notes",
include: { relatedMemories: true },
containerTag: "sm_project_docs",
})
})
})

View file

@ -0,0 +1,14 @@
export function buildSearchMemoriesBody(
query: string,
containerTag?: string,
): {
q: string
include: { relatedMemories: boolean }
containerTag?: string
} {
return {
q: query,
include: { relatedMemories: true },
...(containerTag ? { containerTag } : {}),
}
}

View file

@ -77,6 +77,7 @@
"self-hosting/overview",
"self-hosting/quickstart",
"self-hosting/configuration",
"self-hosting/embeddings",
"self-hosting/local-vs-enterprise"
]
},

View file

@ -140,6 +140,18 @@ const model = withSupermemory(openai("gpt-5"), {
})
```
### Persisting Tool Calls (default: off)
By default, saved conversations include only user and assistant text — tool calls and tool results are dropped, since tool payloads are often large and low-signal and would pollute memory extraction. To persist the full tool round trip (tool calls with their arguments, plus tool results, in their original order), set `includeToolCalls: true`:
```typescript
const model = withSupermemory(openai("gpt-5"), {
containerTag: "user-123",
customId: "conv-1",
includeToolCalls: true,
})
```
---
## Memory Tools

View file

@ -5,7 +5,7 @@ description: "Every environment variable the self-hosted server understands."
icon: "settings"
---
The self-hosted server aims for **zero configuration** — the only thing it needs is one model provider key, which the first-boot wizard collects interactively (or set it via env var for non-interactive deployments). Everything else below is opt-in, layered on top as you need it.
The self-hosted server aims for **zero configuration** — the only required input is one LLM provider key, which the first-boot wizard collects interactively (or set via env var for non-interactive deployments). Embeddings default to local English; you can pick another provider in the optional wizard step or via env. Everything else below is opt-in.
The installer writes API keys to `~/.supermemory/env`, which is loaded on every launch. You can also set variables in your shell or a process manager.
@ -18,7 +18,7 @@ The installer writes API keys to `~/.supermemory/env`, which is loaded on every
## LLM providers
In production, Supermemory uses its own proprietary models tuned for long-horizon data understanding. Self-hosted, you bring your own: embeddings are computed locally, and a model of your choice powers the intelligent steps — summaries, contextual chunking, and memory extraction. Configure **at least one**:
In production, Supermemory uses its own proprietary models tuned for long-horizon data understanding. Self-hosted, you bring your own LLM for the intelligent steps — summaries, contextual chunking, and memory extraction. Embeddings default to a local model (no API key) and can optionally use OpenAI, Gemini, or Ollama — see [Embeddings](/self-hosting/embeddings). Configure **at least one** LLM provider:
| Variable | Provider |
|---|---|
@ -61,7 +61,20 @@ OPENAI_MODEL=gpt-oss:20b
Nothing to configure. Uploaded files (PDFs, images) are stored on local disk inside `$SUPERMEMORY_DATA_DIR` and served by the server at `/files/:key`.
## Embedding performance
## Embeddings
By default, vectors are computed locally with `Xenova/bge-base-en-v1.5` (768d) — no embedding API key. On interactive first boot you can pick a different provider after the LLM key step; for Docker/CI set env vars instead.
Full provider table, multilingual guidance, remote examples (OpenAI / Gemini / Ollama), and the re-ingestion / dimension-lock warning: **[Embeddings (self-hosted)](/self-hosting/embeddings)**.
| Variable | Purpose | Default |
|---|---|---|
| `SUPERMEMORY_EMBEDDING_PROVIDER` | `local`, `openai`, `gemini`, or OpenAI-compatible remote | `local` |
| `SUPERMEMORY_EMBEDDING_MODEL` | Model id for the chosen provider | `Xenova/bge-base-en-v1.5` |
| `SUPERMEMORY_EMBEDDING_DIMENSIONS` | Vector size; must match model and stored data | `768` |
| `SUPERMEMORY_EMBEDDING_BASE_URL` | Base URL for OpenAI-compatible embedding APIs | unset |
### Embedding performance
Local embeddings are prewarmed at startup with conservative defaults — one worker, minimal CPU footprint. Turn these up if you're ingesting heavily and prefer throughput over headroom:
@ -128,8 +141,13 @@ Any other environment variables you may find referenced in the codebase are plat
# Persistent data location
SUPERMEMORY_DATA_DIR=/var/lib/supermemory
# One LLM provider
# One LLM provider (required for extraction)
OPENAI_API_KEY=sk-...
# Optional — omit to keep local Xenova/bge-base-en-v1.5 (768d)
# SUPERMEMORY_EMBEDDING_PROVIDER=openai
# SUPERMEMORY_EMBEDDING_MODEL=text-embedding-3-small
# SUPERMEMORY_EMBEDDING_DIMENSIONS=1536
```
That's enough for full ingestion, memory extraction, and hybrid search.
That's enough for full ingestion, memory extraction, and hybrid search with the default local embeddings.

View file

@ -0,0 +1,135 @@
---
title: "Embeddings (self-hosted)"
sidebarTitle: "Embeddings"
description: "Local and remote embedding providers for Supermemory local — defaults, env vars, multilingual options, and dimension lock."
icon: "waypoints"
---
Self-hosted Supermemory uses the **same embedding provider stack** as the hosted platform: local ONNX models, OpenAI, Gemini, or any OpenAI-compatible embeddings endpoint (including Ollama). LLM keys power extraction and summarization; embeddings are configured separately.
## Defaults
| | |
|---|---|
| Provider | `local` |
| Model | `Xenova/bge-base-en-v1.5` |
| Dimensions | `768` |
| API key | None — runs on your machine |
Press Enter at the optional first-boot picker to keep this default. Nothing is sent off-box to embed.
<Warning>
The default local model is **English-only**. Non-English content can ingest successfully while dense semantic recall stays weak. See [Multilingual](#multilingual).
</Warning>
## First-time setup (interactive)
On first boot with a TTY, Supermemory asks for an LLM API key (required), then optionally which embedding model to use.
1. Choose or paste an LLM provider key (OpenAI, Anthropic, Gemini, Groq, or OpenAI-compatible).
2. Optionally pick an embedding provider/model. **Press Enter to keep the local English model.**
3. Choices are saved encrypted under your data directory (`$SUPERMEMORY_DATA_DIR`, typically `./.supermemory` / `~/.supermemory`).
Boot order is intentional: LLM keys load first so remote embedding options can reuse them (for example OpenAI or Gemini embeddings with the same key).
<Tip>
**First boot (terminal):** Supermemory asks for an LLM API key (required), then optionally which embedding model to use. Press Enter to keep the local English model. Choices are saved encrypted under your data directory.
</Tip>
## Configuration (env)
For Docker, CI, or any non-interactive deploy, set env vars — there is **no interactive prompt without a TTY**.
| Variable | Purpose | Default |
|---|---|---|
| `SUPERMEMORY_EMBEDDING_PROVIDER` | Embedding backend: `local`, `openai`, `gemini`, or an OpenAI-compatible remote (`ollama` / custom base URL) | `local` |
| `SUPERMEMORY_EMBEDDING_MODEL` | Model id for the chosen provider | `Xenova/bge-base-en-v1.5` (local) |
| `SUPERMEMORY_EMBEDDING_DIMENSIONS` | Vector size; must match the model and any already-stored data | `768` (local default) |
| `SUPERMEMORY_EMBEDDING_BASE_URL` | Base URL for OpenAI-compatible embedding APIs (Ollama, vLLM, etc.) | unset |
| `OPENAI_API_KEY` | Used when provider is `openai` (or compatible) if not otherwise supplied | unset |
| `GEMINI_API_KEY` | Used when provider is `gemini` | unset |
Local worker tuning (throughput only — does not change model or dimensions):
| Variable | Purpose | Default |
|---|---|---|
| `SUPERMEMORY_LOCAL_EMBEDDING_POOL_SIZE` | Number of embedding workers | `1` |
| `SUPERMEMORY_LOCAL_EMBEDDING_WASM_THREADS` | Compute threads per worker | `1` |
| `SUPERMEMORY_LOCAL_EMBEDDING_BATCH_SIZE` | Texts per worker dispatch | `8` |
| `SUPERMEMORY_LOCAL_EMBEDDING_IDLE_TIMEOUT_MS` | Idle time before workers shut down | `120000` |
| `SUPERMEMORY_SKIP_EMBEDDING_PREWARM` | Skip startup prewarm, load on first use | unset |
Ingestion memory headroom is controlled by `SUPERMEMORY_EMBEDDING_RAM_LIMIT` — see [Memory limits & ingestion queue](/self-hosting/configuration#memory-limits--ingestion-queue).
<Tip>
**Docker / production:** Set at least one LLM key and, if you dont want local embeddings, set `SUPERMEMORY_EMBEDDING_PROVIDER` / `SUPERMEMORY_EMBEDDING_MODEL` / `SUPERMEMORY_EMBEDDING_DIMENSIONS` (and base URL or API key as needed). There is no interactive prompt without a TTY.
</Tip>
## Multilingual
The default `Xenova/bge-base-en-v1.5` model is trained for English. For German, Dutch, and other non-English corpora, dense recall can fail even when hybrid keyword search still finds rare tokens.
For multilingual or non-English deployments, switch **before** large backfills:
```bash
# Example: local multilingual (set dimensions to match the model)
SUPERMEMORY_EMBEDDING_PROVIDER=local
SUPERMEMORY_EMBEDDING_MODEL=Xenova/bge-m3
SUPERMEMORY_EMBEDDING_DIMENSIONS=1024
```
Or use a remote multilingual embedding API (OpenAI, Gemini, or Ollama with a multilingual embed model). Set provider, model, and dimensions together. Changing them later requires a fresh data directory or full re-ingestion — see below.
## Remote providers
### Local (default)
```bash
# Explicit local default — no embedding API key
SUPERMEMORY_EMBEDDING_PROVIDER=local
SUPERMEMORY_EMBEDDING_MODEL=Xenova/bge-base-en-v1.5
SUPERMEMORY_EMBEDDING_DIMENSIONS=768
```
### OpenAI
```bash
OPENAI_API_KEY=sk-...
SUPERMEMORY_EMBEDDING_PROVIDER=openai
SUPERMEMORY_EMBEDDING_MODEL=text-embedding-3-small
SUPERMEMORY_EMBEDDING_DIMENSIONS=1536
```
### Gemini
```bash
GEMINI_API_KEY=...
SUPERMEMORY_EMBEDDING_PROVIDER=gemini
SUPERMEMORY_EMBEDDING_MODEL=text-embedding-004
SUPERMEMORY_EMBEDDING_DIMENSIONS=768
```
### Ollama (OpenAI-compatible)
```bash
SUPERMEMORY_EMBEDDING_PROVIDER=openai
SUPERMEMORY_EMBEDDING_BASE_URL=http://localhost:11434/v1
OPENAI_API_KEY=ollama
SUPERMEMORY_EMBEDDING_MODEL=nomic-embed-text
SUPERMEMORY_EMBEDDING_DIMENSIONS=768
```
Use the dimension published for your chosen model. A mismatch with vectors already in the store fails boot.
## Changing models later
<Warning>
**Not supported in place.** Embeddings from different models (or different dimensions) are not comparable. Start from a fresh data directory or re-ingest all content so vectors stay in one space. If configured dimensions disagree with stored data, the server **refuses to boot**.
</Warning>
**Changing embeddings later:** Not supported in place. Start from a fresh data directory or re-ingest all content so vectors stay comparable.
## Related
- [Configuration](/self-hosting/configuration) — LLM providers, storage, ingestion limits
- [Quickstart](/self-hosting/quickstart) — install and first memory

View file

@ -24,7 +24,7 @@ No Docker. No database to provision. No config files. It boots in seconds with e
Run the binary with nothing set and you get a complete memory system:
- **The Supermemory graph engine, embedded** — created automatically on first boot. No database to stand up, no connection strings.
- **Built-in local embeddings** — vectors are computed on your machine. Nothing is sent anywhere to be embedded.
- **Built-in local embeddings** — default `Xenova/bge-base-en-v1.5` (768d) on your machine, no API key. Same provider stack as cloud if you opt into OpenAI, Gemini, or Ollama — see [Embeddings](/self-hosting/embeddings).
- **An API key, generated for you** — printed on first boot, ready to paste into any SDK.
- **The full Memory API** — `/v3/documents`, `/v4/search`, `/v4/profile`, spaces, the works.
@ -64,7 +64,7 @@ Self-hosted is free, open source, and great for local development, air-gapped en
|---|---|---|
| Full Memory API | ✅ | ✅ |
| Hybrid semantic search | ✅ | ✅ |
| Local embeddings | ✅ | Managed |
| Embeddings | Local default (or OpenAI / Gemini / Ollama) | Same provider stack, managed |
| File ingestion (PDFs, images) | ✅ | ✅ |
| [Connectors](/connectors/overview) (Google Drive, Notion, Gmail, OneDrive) | — | ✅ |
| [Supermemory MCP](/supermemory-mcp/mcp) | — | ✅ |
@ -75,11 +75,14 @@ If you outgrow a single machine — or want connectors, MCP, and the best-tuned
## Next steps
<CardGroup cols={2}>
<CardGroup cols={3}>
<Card title="Quickstart" icon="play" href="/self-hosting/quickstart">
Install, run, and store your first memory in under two minutes
</Card>
<Card title="Configuration" icon="settings" href="/self-hosting/configuration">
Every environment variable: LLM providers, storage, auth, tuning
</Card>
<Card title="Embeddings" icon="waypoints" href="/self-hosting/embeddings">
Local default, remote providers, multilingual, dimension lock
</Card>
</CardGroup>

View file

@ -47,9 +47,13 @@ First boot sets everything up — the embedded Supermemory graph engine, local e
Save that API key — it's your bearer token for every request.
<Note>
In production, Supermemory runs proprietary models tuned for long-horizon data understanding. Self-hosted, you bring any model: if no provider key is set, first boot launches an interactive setup wizard — pick a provider (OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint like Ollama), paste your key, and it's saved encrypted for every future launch. See [all providers](/self-hosting/configuration#llm-providers), including [fully-offline local models](/self-hosting/configuration#fully-offline-with-local-models).
In production, Supermemory runs proprietary models tuned for long-horizon data understanding. Self-hosted, you bring any model: if no provider key is set, first boot launches an interactive setup wizard — pick a provider (OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint like Ollama), paste your key, and it's saved encrypted for every future launch. After the LLM key, you can optionally pick an embedding model (press Enter to keep local `Xenova/bge-base-en-v1.5`). See [all providers](/self-hosting/configuration#llm-providers), [embeddings](/self-hosting/embeddings), and [fully-offline local models](/self-hosting/configuration#fully-offline-with-local-models).
</Note>
<Tip>
**Docker / non-interactive:** set an LLM key via env and, if you dont want local embeddings, set `SUPERMEMORY_EMBEDDING_PROVIDER` / `MODEL` / `DIMENSIONS`. There is no wizard without a TTY.
</Tip>
## Add your first memory
<Tabs>
@ -141,10 +145,13 @@ By default, all state lives in a single directory you can back up or move:
## Next steps
<CardGroup cols={2}>
<CardGroup cols={3}>
<Card title="Configuration" icon="settings" href="/self-hosting/configuration">
LLM providers, local models, performance tuning
</Card>
<Card title="Embeddings" icon="waypoints" href="/self-hosting/embeddings">
Local default, OpenAI / Gemini / Ollama, multilingual
</Card>
<Card title="Memory API" icon="book-open" href="/quickstart">
The full API — it all works against your local server
</Card>

View file

@ -110,6 +110,24 @@ Search memories and get user profile.
| `includeProfile` | boolean | No | Include user profile summary. Default: `true` |
| `containerTag` | string | No | Project tag to scope the search |
### `listMemories`
Enumerate stored memories grouped by their source document, newest first. Returns only the extracted memory facts — never document content — so responses stay small enough for client output limits. Use it to audit what is on file (e.g. before forgetting stale memories); use `recall` for topic-based search.
```json
{
"page": 1,
"limit": 10,
"containerTag": "optional-project-tag"
}
```
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `page` | integer | No | Page number (1-based). Default: `1` |
| `limit` | integer | No | Documents per page, each grouping its extracted memories. Default: `10`, max: `50` |
| `containerTag` | string | No | Project tag to scope the listing |
### `whoAmI`
Get the current logged-in user's information.
@ -190,6 +208,7 @@ bun run test:e2e
| `e2e/oauth.test.ts` | OAuth discovery chain, dynamic client registration, token-endpoint negatives, real refresh→access token round-trip |
| `e2e/discovery.test.ts` | handshake, tools/resources/prompts listing, `whoAmI`, `listProjects` |
| `e2e/memory.test.ts` | save→recall round-trip, profile variants, `forget`, container scoping, bad args |
| `e2e/list-memories.test.ts` | `listMemories` discovery, save→list round-trip, pagination, arg validation |
| `e2e/root-scope.test.ts` | `x-sm-project` header strips the `containerTag` param and scopes the whole connection |
| `e2e/graph.test.ts` | `memory-graph`, `fetch-graph-data`, resource reads, `context` prompt |

View file

@ -0,0 +1,82 @@
import { randomUUID } from "node:crypto"
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import {
API_KEY,
callTool,
connect,
type Session,
sleep,
textOf,
} from "./helpers"
// listMemories reads extracted memory entries, which appear only after the
// async ingestion pipeline finishes — poll like recallUntil does.
async function listUntil(
s: Session,
needle: string,
{ tries = 18, delayMs = 5000 } = {},
): Promise<string | null> {
for (let i = 0; i < tries; i++) {
// The marker document is the newest, so page 1 is enough.
const res = await callTool(s.client, "listMemories", { limit: 20 })
const txt = textOf(res)
if (txt.includes(needle)) return txt
await sleep(delayMs)
}
return null
}
describe.skipIf(!API_KEY)("MCP — listMemories", () => {
let s: Session
const created: string[] = []
beforeAll(async () => {
s = await connect()
})
afterAll(async () => {
for (const content of created) {
await callTool(s.client, "memory", {
content,
action: "forget",
}).catch(() => {})
}
await s?.close()
})
it("appears in tool discovery", async () => {
const tools = await s.client.listTools()
const names = tools.tools.map((t) => t.name)
expect(names).toContain("listMemories")
})
it("lists a saved memory without dumping document content", async () => {
const marker = `lm-${randomUUID()}`
const content = `e2e listMemories. token=${marker}. The list test fruit is rambutan.`
created.push(content)
const save = await callTool(s.client, "memory", { content, action: "save" })
expect(save.isError).toBeFalsy()
const listing = await listUntil(s, marker)
expect(
listing,
`listMemories never returned marker ${marker}`,
).not.toBeNull()
// Header shape: "N memories across M documents (page X of Y, ...)"
expect(listing).toMatch(/memor(y|ies) across \d+ document/)
}, 120_000)
it("paginates with a bounded page size", async () => {
const res = await callTool(s.client, "listMemories", { page: 1, limit: 1 })
expect(res.isError).toBeFalsy()
const txt = textOf(res)
// With the memory saved above there is at least one document.
expect(txt).toMatch(/page 1 of \d+/)
}, 30_000)
it("rejects an out-of-range limit", async () => {
const res = await callTool(s.client, "listMemories", { limit: 500 })
// Zod schema caps limit at 50 — the SDK surfaces this as a tool error.
expect(res.isError).toBeTruthy()
}, 30_000)
})

193
apps/mcp/src/format.test.ts Normal file
View file

@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest"
import type { DocumentsApiResponse } from "./server/client"
import { formatMemoriesList } from "./server/format"
function makeResponse(
overrides: Partial<DocumentsApiResponse> = {},
): DocumentsApiResponse {
return {
documents: [],
pagination: { currentPage: 1, limit: 10, totalItems: 0, totalPages: 1 },
...overrides,
}
}
function makeEntry(memory: string, extra: Record<string, unknown> = {}) {
return {
id: `mem_${memory.slice(0, 8)}`,
memory,
spaceId: "space_1",
createdAt: "2026-06-10T12:00:00Z",
updatedAt: "2026-06-10T12:00:00Z",
...extra,
}
}
describe("formatMemoriesList", () => {
it("reports an empty store", () => {
expect(formatMemoriesList(makeResponse())).toBe("No memories stored yet.")
})
it("reports an out-of-range page distinctly from an empty store", () => {
const result = formatMemoriesList(
makeResponse({
pagination: {
currentPage: 3,
limit: 10,
totalItems: 12,
totalPages: 2,
},
}),
)
expect(result).toBe("No documents on page 3 (2 pages total).")
})
it("groups memories under their source document with title, type, and date", () => {
const result = formatMemoriesList(
makeResponse({
documents: [
{
id: "doc_1",
title: "Preferences",
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [
makeEntry("User prefers dark mode"),
makeEntry("User works in TypeScript"),
],
},
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
}),
)
expect(result).toContain(
"2 memories across 1 document (page 1 of 1, 1 documents total), newest first.",
)
expect(result).toContain('"Preferences" (text, 2026-06-12)')
expect(result).toContain("- User prefers dark mode")
expect(result).toContain("- User works in TypeScript")
expect(result).not.toContain("More available")
})
it("excludes forgotten and superseded memory entries", () => {
const result = formatMemoriesList(
makeResponse({
documents: [
{
id: "doc_1",
title: "Facts",
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [
makeEntry("Current fact"),
makeEntry("Forgotten fact", { isForgotten: true }),
makeEntry("Old version of a fact", { isLatest: false }),
],
},
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
}),
)
expect(result).toContain("- Current fact")
expect(result).not.toContain("Forgotten fact")
expect(result).not.toContain("Old version of a fact")
expect(result).toContain("1 memory across 1 document")
})
it("marks documents whose extraction has not produced memories yet", () => {
const result = formatMemoriesList(
makeResponse({
documents: [
{
id: "doc_1",
title: "Still processing",
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [],
},
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
}),
)
expect(result).toContain(
'"Still processing" (text, 2026-06-12) - no extracted memories yet',
)
})
it("falls back to (untitled) for documents without a title", () => {
const result = formatMemoriesList(
makeResponse({
documents: [
{
id: "doc_1",
title: null,
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [makeEntry("Some fact")],
},
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
}),
)
expect(result).toContain('"(untitled)" (text, 2026-06-12)')
})
it("flattens multi-line memories and truncates oversized ones", () => {
const longMemory = `start ${"x".repeat(600)}`
const result = formatMemoriesList(
makeResponse({
documents: [
{
id: "doc_1",
title: "Big",
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [
makeEntry("line one\nline two\ttabbed"),
makeEntry(longMemory),
],
},
],
pagination: { currentPage: 1, limit: 10, totalItems: 1, totalPages: 1 },
}),
)
expect(result).toContain("- line one line two tabbed")
expect(result).toContain("... [truncated]")
const truncatedLine = result
.split("\n")
.find((line) => line.includes("[truncated]"))
expect(truncatedLine).toBeDefined()
expect((truncatedLine as string).length).toBeLessThan(600)
})
it("points at the next page when more documents exist", () => {
const result = formatMemoriesList(
makeResponse({
documents: [
{
id: "doc_1",
title: "Page one doc",
type: "text",
createdAt: "2026-06-12T08:00:00Z",
updatedAt: "2026-06-12T08:00:00Z",
memoryEntries: [makeEntry("A fact")],
},
],
pagination: { currentPage: 1, limit: 1, totalItems: 3, totalPages: 3 },
}),
)
expect(result).toContain("page 1 of 3, 3 documents total")
expect(result).toContain("More available - call listMemories with page: 2.")
})
})

View file

@ -0,0 +1,51 @@
import type { DocumentsApiResponse } from "./client"
// Listing must stay lightweight: memory entries are extracted facts, not raw
// document content, so responses fit client output limits at the max page size.
const MAX_LIST_MEMORY_CHARS = 500
export function formatMemoriesList(response: DocumentsApiResponse): string {
const { documents, pagination } = response
const day = (s: string | null | undefined) => s?.slice(0, 10) ?? ""
if (documents.length === 0) {
return pagination.currentPage > 1
? `No documents on page ${pagination.currentPage} (${pagination.totalPages} page${pagination.totalPages === 1 ? "" : "s"} total).`
: "No memories stored yet."
}
let memoryCount = 0
const blocks = documents.map((doc) => {
const activeEntries = doc.memoryEntries.filter(
(entry) => entry.isForgotten !== true && entry.isLatest !== false,
)
const title = doc.title?.trim() || "(untitled)"
const header = `"${title}" (${doc.type}, ${day(doc.createdAt)})`
if (activeEntries.length === 0) {
return `${header} - no extracted memories yet`
}
memoryCount += activeEntries.length
const lines = activeEntries.map((entry) => {
const text = entry.memory.replace(/\s+/g, " ").trim()
return `- ${
text.length > MAX_LIST_MEMORY_CHARS
? `${text.slice(0, MAX_LIST_MEMORY_CHARS)} ... [truncated]`
: text
}`
})
return [header, ...lines].join("\n")
})
const header = `${memoryCount} memor${memoryCount === 1 ? "y" : "ies"} across ${documents.length} document${documents.length === 1 ? "" : "s"} (page ${pagination.currentPage} of ${pagination.totalPages}, ${pagination.totalItems} documents total), newest first.`
const parts = [header, "", blocks.join("\n\n")]
if (pagination.currentPage < pagination.totalPages) {
parts.push(
"",
`More available - call listMemories with page: ${pagination.currentPage + 1}.`,
)
}
return parts.join("\n")
}

View file

@ -2,6 +2,7 @@ import * as addMemory from "./add-memory"
import * as fetchGraphData from "./fetch-graph-data"
import * as guidedSave from "./guided-save"
import * as listContainerTags from "./list-container-tags"
import * as listMemories from "./list-memories"
import * as memoryGraph from "./memory-graph"
import * as saveMemory from "./save-memory"
import * as searchMemory from "./search-memory"
@ -15,6 +16,7 @@ import * as whoAmI from "./who-am-i"
export function registerAllTools(deps: ToolDeps) {
// Always available
searchMemory.register(deps)
listMemories.register(deps)
listContainerTags.register(deps)
whoAmI.register(deps)
selectWorkspace.register(deps)

View file

@ -0,0 +1,75 @@
import { z } from "zod"
import { formatMemoriesList } from "../format"
import type { ToolDeps } from "./types"
export function register(deps: ToolDeps) {
const containerTagField: Record<string, z.ZodTypeAny> = deps.rbac
.hasRootContainerTag
? {}
: {
containerTag: z
.string()
.max(128, "Container tag exceeds maximum length")
.optional(),
}
const inputSchema = {
page: z
.number()
.int()
.min(1)
.optional()
.default(1)
.describe("Page number (1-based)"),
limit: z
.number()
.int()
.min(1)
.max(50)
.optional()
.default(10)
.describe(
"Documents per page; each document groups its extracted memories (default 10, max 50)",
),
...containerTagField,
}
deps.server.registerTool(
"listMemories",
{
description:
"Enumerate stored memories grouped by their source document, newest first. Returns only the extracted memory facts (no document content), so use it to audit what is on file. For finding memories relevant to a topic, use search_memory instead.",
inputSchema,
},
async (rawArgs) => {
const args = rawArgs as {
page?: number
limit?: number
containerTag?: string
}
try {
if (args.containerTag && !deps.rbac.canRead(args.containerTag)) {
return deps.errorResult(
new Error(
`No read access to container tag '${args.containerTag}'.`,
),
)
}
const effectiveTag = await deps.resolveContainerTag(args.containerTag)
const client = deps.getClient(effectiveTag)
const containerTags = effectiveTag ? [effectiveTag] : undefined
const data = await client.getDocuments(
containerTags,
args.page ?? 1,
args.limit ?? 10,
)
return {
content: [{ type: "text" as const, text: formatMemoriesList(data) }],
}
} catch (error) {
return deps.errorResult(error)
}
},
)
}

View file

@ -16,7 +16,7 @@ export function useHostContext(): McpUiHostContext | null {
app.onhostcontextchanged = handler
return () => {
if (app.onhostcontextchanged === handler) {
app.onhostcontextchanged = undefined
app.onhostcontextchanged = () => {}
}
}
}, [])

View file

@ -75,10 +75,10 @@ export function useViewState(): {
}
return () => {
app.ontoolinput = undefined
app.ontoolinputpartial = undefined
app.ontoolcancelled = undefined
app.ontoolresult = undefined
app.ontoolinput = () => {}
app.ontoolinputpartial = () => {}
app.ontoolcancelled = () => {}
app.ontoolresult = () => {}
app.onerror = undefined
}
}, [])

View file

@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
include: ["e2e/**/*.test.ts"],
include: ["e2e/**/*.test.ts", "src/**/*.test.ts"],
testTimeout: 90_000,
hookTimeout: 30_000,
},

View file

@ -1,10 +1,12 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { useRouter, useSearchParams } from "next/navigation"
import { toast } from "sonner"
import { useAuth } from "@lib/auth-context"
import { authClient } from "@lib/auth"
import { SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
import { analytics } from "@/lib/analytics"
import { BrainShell } from "@/components/onboarding-brain/shell"
import {
@ -16,6 +18,7 @@ import {
type SourcesValues,
} from "@/components/onboarding-brain/step-sources"
import { StepIngest } from "@/components/onboarding-brain/step-ingest"
import { CompanyBrainOnboarding } from "@/components/onboarding-brain/company-brain-onboarding"
import { useFeatureFlagEnabled } from "posthog-js/react"
import {
StepTeam,
@ -31,10 +34,14 @@ import {
generateOrgSlug,
generateUsername,
workspaceDomainFromEmail,
workspaceNameFromDomain,
workspaceNameFromEmail,
type CompanyBrainConfirmResult,
} from "@/components/onboarding-brain/types"
const STORAGE_KEY = "supermemory-brain-onboarding-v1"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const countsAsConnectedSource = (state: unknown) =>
state === "connected" || state === "waitlist"
@ -52,6 +59,7 @@ const getErrorMessage = (error: unknown, fallback: string) => {
export default function BrainOnboardingPage() {
const router = useRouter()
const params = useSearchParams()
const queryClient = useQueryClient()
const { user, org, organizations, setActiveOrg, refetchOrganizations } =
useAuth()
@ -167,13 +175,17 @@ export default function BrainOnboardingPage() {
[router],
)
// Team/company-brain routes everything into the shared Team Brain that
// provisioning creates (sm_org_shared) — not a workspace-name slug space.
const containerTag = useMemo(
() =>
containerTagFromWorkspace(
about.workspaceName || suggestedWorkspaceName,
mode,
),
[about.workspaceName, suggestedWorkspaceName, mode],
allowTeam && mode === "team"
? SHARED_TEAM_BRAIN_TAG
: containerTagFromWorkspace(
about.workspaceName || suggestedWorkspaceName,
mode,
),
[allowTeam, about.workspaceName, suggestedWorkspaceName, mode],
)
const isScale = useMemo(() => {
@ -230,65 +242,78 @@ export default function BrainOnboardingPage() {
const [creatingOrg, setCreatingOrg] = useState(false)
const creatingOrgRef = useRef(false)
const ensureOrg = useCallback(async () => {
if (!forceCreate && organizations && organizations.length > 0) return
const name = (about.workspaceName || suggestedWorkspaceName).trim()
const slug = generateOrgSlug(name)
const effectiveMode = allowTeam ? mode : "personal"
const metadata: BrainMetadata & { signupSource: string } = {
signupSource: "consumer",
brainOnboardingVersion: "v1",
brainMode: effectiveMode,
brainWorkspaceName: name,
brainWorkspaceDomain:
effectiveMode === "team" ? about.workspaceDomain || domain : null,
brainContainerTag: containerTag,
...(about.about.trim() ? { brainAbout: about.about.trim() } : {}),
}
const result = await authClient.organization.create({
name,
slug,
metadata,
})
if (result.error || !result.data?.slug) {
throw new Error(
getErrorMessage(result.error, "Organization was not created."),
)
}
await setActiveOrg(result.data.slug)
if (about.name.trim()) {
await authClient.updateUser({
name: about.name.trim(),
displayUsername: about.name.trim(),
username: generateUsername(about.name),
const ensureOrg = useCallback(
async (domainOverride?: string): Promise<boolean> => {
if (!forceCreate && organizations && organizations.length > 0)
return false
const name = (
domainOverride
? workspaceNameFromDomain(domainOverride)
: about.workspaceName || suggestedWorkspaceName
).trim()
const slug = generateOrgSlug(name)
const effectiveMode = allowTeam ? mode : "personal"
const metadata: BrainMetadata & { signupSource: string } = {
signupSource: "consumer",
brainOnboardingVersion: "v1",
brainMode: effectiveMode,
brainWorkspaceName: name,
brainWorkspaceDomain:
effectiveMode === "team"
? domainOverride || about.workspaceDomain || domain
: null,
brainContainerTag: containerTag,
...(about.about.trim() ? { brainAbout: about.about.trim() } : {}),
}
const result = await authClient.organization.create({
name,
slug,
metadata,
})
}
await refetchOrganizations()
analytics.onboardingWorkspaceCreated({
if (result.error || !result.data?.slug) {
throw new Error(
getErrorMessage(result.error, "Organization was not created."),
)
}
await setActiveOrg(result.data.slug)
if (about.name.trim()) {
await authClient.updateUser({
name: about.name.trim(),
displayUsername: about.name.trim(),
username: generateUsername(about.name),
})
}
await refetchOrganizations()
analytics.onboardingWorkspaceCreated({
mode,
has_about: Boolean(about.about.trim()),
has_domain: Boolean(
mode === "team" && (about.workspaceDomain || domain),
),
})
// Drop new=1 so a reload or back+Continue reuses this org instead of creating a duplicate.
if (forceCreate) {
const url = new URL(window.location.href)
url.searchParams.delete("new")
url.searchParams.delete("name")
router.replace(url.pathname + url.search, { scroll: false })
}
return true
},
[
organizations,
about,
suggestedWorkspaceName,
mode,
has_about: Boolean(about.about.trim()),
has_domain: Boolean(mode === "team" && (about.workspaceDomain || domain)),
})
// Drop new=1 so a reload or back+Continue reuses this org instead of creating a duplicate.
if (forceCreate) {
const url = new URL(window.location.href)
url.searchParams.delete("new")
url.searchParams.delete("name")
router.replace(url.pathname + url.search, { scroll: false })
}
}, [
organizations,
about,
suggestedWorkspaceName,
mode,
allowTeam,
domain,
containerTag,
setActiveOrg,
refetchOrganizations,
forceCreate,
router,
])
allowTeam,
domain,
containerTag,
setActiveOrg,
refetchOrganizations,
forceCreate,
router,
],
)
const handleAboutContinue = useCallback(async () => {
if (creatingOrgRef.current) return
@ -315,6 +340,73 @@ export default function BrainOnboardingPage() {
}
}, [ensureOrg, goNext, forceCreate, organizations, router])
// Company Brain (team) onboarding is a single research surface, no stepper.
const isCompanyBrain = allowTeam && mode === "team"
const handleBrainConfirm = useCallback(
async (confirmedDomain: string): Promise<CompanyBrainConfirmResult> => {
if (creatingOrgRef.current) return { ok: false }
creatingOrgRef.current = true
setCreatingOrg(true)
try {
const workspaceName = workspaceNameFromDomain(confirmedDomain)
setAbout((a) => ({
...a,
workspaceDomain: confirmedDomain,
workspaceName: workspaceName || a.workspaceName,
}))
const orgCreated = await ensureOrg(confirmedDomain)
// Re-entering onboarding on an existing org ("Try onboarding") must
// kick research from the client. New orgs rely on the signup hook after
// provisioning — a duplicate /start races and can strand the DO task.
if (!orgCreated) {
const started = await fetch(`${BACKEND}/brain/research/start`, {
method: "POST",
credentials: "include",
headers: {
"content-type": "application/json",
"X-App-Source": "nova",
},
body: JSON.stringify({ domain: confirmedDomain }),
})
.then((res) => res.ok)
.catch(() => false)
// Don't advance into the research phase if it never started, else the
// poller sits on empty state until timeout.
if (!started) {
toast.error("Couldn't start research", {
description: "Please try again in a moment.",
})
return { ok: false }
}
}
queryClient.invalidateQueries({ queryKey: ["brain-research-status"] })
// ensureOrg already fires this when it creates the org; only emit here
// for the existing-org path to avoid a duplicate event.
if (!orgCreated) {
analytics.onboardingWorkspaceCreated({
mode: "team",
has_about: false,
has_domain: Boolean(confirmedDomain),
})
}
return { ok: true, serverSchedulesResearch: orgCreated }
} catch (e) {
const message = getErrorMessage(e, "Organization was not created.")
console.error("Failed to create organization:", e)
analytics.onboardingWorkspaceCreateFailed({ error: message })
toast.error("Organization was not created", {
description: "Please try again.",
})
return { ok: false }
} finally {
creatingOrgRef.current = false
setCreatingOrg(false)
}
},
[ensureOrg, queryClient],
)
const [sendingInvites, setSendingInvites] = useState(false)
const sendingInvitesRef = useRef(false)
@ -370,6 +462,19 @@ export default function BrainOnboardingPage() {
const mcpUrl = "https://mcp.supermemory.ai/mcp"
if (isCompanyBrain) {
return (
<CompanyBrainOnboarding
name={about.name || user?.name || ""}
avatarUrl={user?.image ?? null}
domain={about.workspaceDomain || domain || ""}
submitting={creatingOrg}
onConfirm={handleBrainConfirm}
onDone={finish}
/>
)
}
return (
<BrainShell
step={step}
@ -397,7 +502,6 @@ export default function BrainOnboardingPage() {
{step === "sources" && (
<StepSources
containerTag={containerTag}
workspaceName={about.workspaceName || suggestedWorkspaceName}
mode={mode}
values={sources}
onChange={setSources}

View file

@ -18,7 +18,7 @@ import { motion } from "motion/react"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { Logo } from "@ui/assets/Logo"
import { resolveAuthRedirectUrl } from "@/lib/url-helpers"
import { getBackendUrl, resolveAuthRedirectUrl } from "@/lib/url-helpers"
import { Loader2 } from "lucide-react"
function isMcpOAuthAuthorizeContext(sp: Pick<URLSearchParams, "get">): boolean {
@ -276,7 +276,7 @@ export default function LoginPage() {
const token = formData.get("token") as string
const callbackURL = getCallbackURL()
router.push(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
`${getBackendUrl()}/api/auth/magic-link/verify?token=${token}&callbackURL=${encodeURIComponent(callbackURL)}`,
)
}

View file

@ -13,29 +13,137 @@ function isValidUrl(urlString: string): boolean {
}
}
function isPrivateIPv4Octets(a: number, b: number): boolean {
// 0.0.0.0/8, 10/8, 100.64/10 (CGNAT), 127/8 (loopback),
// 169.254/16 (link-local / cloud metadata), 172.16/12, 192.168/16
if (a === 0) return true
if (a === 10) return true
if (a === 127) return true
if (a === 169 && b === 254) return true
if (a === 172 && b >= 16 && b <= 31) return true
if (a === 192 && b === 168) return true
if (a === 100 && b >= 64 && b <= 127) return true
return false
}
// Parse an IPv6 hostname into its eight 16-bit groups. The WHATWG URL parser
// already canonicalizes literals, but it leaves the brackets on (e.g. "[::1]")
// and performs no SSRF filtering. Handles "::" compression and embedded IPv4
// suffixes (e.g. ::ffff:1.2.3.4). Returns null when not an IPv6 literal.
function parseIPv6Groups(input: string): number[] | null {
let host = input
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1)
const zone = host.indexOf("%") // strip scope/zone id, e.g. fe80::1%eth0
if (zone !== -1) host = host.slice(0, zone)
if (!host.includes(":")) return null
// Convert an embedded IPv4 tail (e.g. ::ffff:1.2.3.4) into two hex groups.
const dot = host.indexOf(".")
if (dot !== -1) {
const colon = host.lastIndexOf(":", dot)
if (colon === -1) return null
const octets = host.slice(colon + 1).split(".")
if (octets.length !== 4) return null
const bytes: number[] = []
for (const octet of octets) {
if (!/^\d{1,3}$/.test(octet)) return null
const n = Number(octet)
if (n > 255) return null
bytes.push(n)
}
const hi = (((bytes[0] ?? 0) << 8) | (bytes[1] ?? 0)).toString(16)
const lo = (((bytes[2] ?? 0) << 8) | (bytes[3] ?? 0)).toString(16)
host = `${host.slice(0, colon + 1)}${hi}:${lo}`
}
const parseSide = (side: string): number[] | null => {
if (side === "") return []
const groups: number[] = []
for (const group of side.split(":")) {
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null
groups.push(Number.parseInt(group, 16))
}
return groups
}
const halves = host.split("::")
if (halves.length > 2) return null
if (halves.length === 2) {
const head = parseSide(halves[0] ?? "")
const tail = parseSide(halves[1] ?? "")
if (!head || !tail) return null
const missing = 8 - head.length - tail.length
if (missing < 1) return null
return [...head, ...new Array<number>(missing).fill(0), ...tail]
}
const all = parseSide(host)
if (all?.length !== 8) return null
return all
}
function isPrivateIPv6(input: string): boolean {
const g = parseIPv6Groups(input)
if (!g) return false
// Unspecified (::) and loopback (::1)
if (g.every((x) => x === 0)) return true
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return true
// Link-local fe80::/10
if (((g[0] ?? 0) & 0xffc0) === 0xfe80) return true
// Unique local address fc00::/7 (fc00fdff)
if ((((g[0] ?? 0) >> 8) & 0xfe) === 0xfc) return true
// IPv4-mapped (::ffff:a.b.c.d) and deprecated IPv4-compatible (::a.b.c.d):
// re-check the embedded IPv4 against the private ranges so loopback/metadata
// can't be reached via an IPv6 wrapper.
const firstFiveZero = g.slice(0, 5).every((x) => x === 0)
const ipv4Mapped = firstFiveZero && g[5] === 0xffff
const ipv4Compatible =
firstFiveZero && g[5] === 0 && (g[6] !== 0 || g[7] !== 0)
if (ipv4Mapped || ipv4Compatible) {
const a = ((g[6] ?? 0) >> 8) & 0xff
const b = (g[6] ?? 0) & 0xff
return isPrivateIPv4Octets(a, b)
}
return false
}
// NOTE: This blocks private/loopback/metadata IP *literals* only. It cannot stop
// DNS rebinding (a public hostname that resolves to a private address), which
// would require resolving + pinning the address before connecting — not available
// in the edge fetch runtime here.
function isPrivateHost(hostname: string): boolean {
const lowerHost = hostname.toLowerCase()
if (
lowerHost === "localhost" ||
lowerHost === "127.0.0.1" ||
lowerHost === "0.0.0.0" ||
lowerHost === "::1" ||
lowerHost === "::" ||
lowerHost.startsWith("127.") ||
lowerHost.startsWith("0.0.0.0")
) {
// Hostname-based loopback (RFC 6761 reserves localhost and *.localhost)
if (lowerHost === "localhost" || lowerHost.endsWith(".localhost")) {
return true
}
const privateIpPatterns = [
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[01])\./,
/^192\.168\./,
/^169\.254\./, // Link-local / Metadata service
]
// IPv6 literals arrive bracketed (e.g. "[::1]"), so the previous
// string-equality checks never matched them.
if (lowerHost.includes(":")) {
return isPrivateIPv6(lowerHost)
}
return privateIpPatterns.some((pattern) => pattern.test(hostname))
// IPv4. The WHATWG URL parser canonicalizes alternate encodings
// (decimal/hex/octal, e.g. http://2130706433 -> 127.0.0.1) to dotted-quad,
// so matching the dotted form here also blocks those encodings.
const octets = lowerHost.split(".")
if (
octets.length === 4 &&
octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255)
) {
const [a, b] = octets.map(Number) as [number, number, number, number]
return isPrivateIPv4Octets(a, b)
}
return false
}
// File extensions that are not HTML and can't be scraped for OG data

View file

@ -1,7 +1,7 @@
"use client"
import { $fetch } from "@lib/api"
import { hasActivePlan } from "@lib/queries"
import { useConnectorAccess } from "@/hooks/use-connector-access"
import type { ConnectionResponseSchema } from "@repo/validation/api"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { GoogleDrive, Granola, Notion, OneDrive } from "@ui/assets/icons"
@ -309,7 +309,7 @@ interface ConnectContentProps {
export function ConnectContent({ selectedProject }: ConnectContentProps) {
const queryClient = useQueryClient()
const autumn = useCustomer()
const isProUser = hasActivePlan(autumn.data?.subscriptions, "api_pro")
const { connectorAccess } = useConnectorAccess()
const [connectingProvider, setConnectingProvider] =
useState<ConnectorProvider | null>(null)
const [granolaModalOpen, setGranolaModalOpen] = useState(false)
@ -398,7 +398,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
provider: ConnectorProvider
syncScope?: GDriveSyncScope
}) => {
if (!canAddConnection && !isProUser) {
if (!canAddConnection && !connectorAccess) {
throw new Error(
"Free plan doesn't include connections. Upgrade to Pro for unlimited connections.",
)
@ -557,7 +557,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
type="button"
onClick={() => handleConnect("google-drive")}
disabled={
!isProUser ||
!connectorAccess ||
isConnecting ||
addConnectionMutation.isPending
}
@ -605,14 +605,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
</div>
) : provider === "granola" ? (
<>
{!isProUser && (
{!connectorAccess && (
<span className="bg-[#0054AD] text-[#FAFAFA] text-[10px] font-bold px-1.5 py-[2px] rounded-[3px] uppercase tracking-wide">
Pro
</span>
)}
<Button
onClick={() => {
if (!isProUser) {
if (!connectorAccess) {
handleUpgrade("api_pro")
return
}
@ -621,7 +621,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
disabled={isUpgrading || autumn.isLoading}
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 text-[14px] font-medium px-3 py-1.5 h-8"
>
{!isProUser
{!connectorAccess
? isUpgrading || autumn.isLoading
? "Upgrading..."
: "Upgrade"
@ -634,7 +634,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
handleConnect(provider as ConnectorProvider)
}
disabled={
!isProUser ||
!connectorAccess ||
isConnecting ||
addConnectionMutation.isPending
}
@ -681,7 +681,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={!isProUser || isAnyConnecting}
disabled={!connectorAccess || isAnyConnecting}
className="flex shrink-0 items-center gap-1.5 bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 disabled:opacity-50 disabled:cursor-not-allowed text-[13px] font-medium rounded-full h-8 px-3 transition-colors"
>
{isAnyConnecting ? (
@ -792,7 +792,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
<DropdownMenuItem
disabled={isUpgrading || autumn.isLoading}
onClick={() => {
if (!isProUser) {
if (!connectorAccess) {
handleUpgrade("api_pro")
return
}
@ -804,14 +804,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
<div className="flex flex-col gap-0.5 min-w-0">
<span className="flex items-center gap-1.5 text-[14px] font-medium text-[#FAFAFA] leading-tight">
Granola
{!isProUser && (
{!connectorAccess && (
<span className="bg-[#0054AD] text-[#FAFAFA] text-[9px] font-bold px-1 py-px rounded-[3px] uppercase tracking-wide">
Pro
</span>
)}
</span>
<span className="text-[11px] text-[#737373] leading-tight">
{isProUser
{connectorAccess
? "Meeting notes & transcripts"
: "Upgrade to Pro"}
</span>
@ -866,7 +866,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
className="bg-[#14161A] shadow-inside-out rounded-[12px] px-4 py-6 h-full mb-4 flex flex-col justify-center items-center"
>
<Zap className="size-6 text-[#737373] mb-3" />
{!isProUser ? (
{!connectorAccess ? (
<>
<p className="text-[14px] text-[#737373] mb-4 text-center">
{isUpgrading || autumn.isLoading ? (
@ -945,8 +945,8 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
/>
<GranolaConnectModal
open={isProUser && granolaModalOpen}
onOpenChange={(open) => setGranolaModalOpen(open && isProUser)}
open={connectorAccess && granolaModalOpen}
onOpenChange={(open) => setGranolaModalOpen(open && connectorAccess)}
containerTags={[selectedProject]}
/>
</div>

View file

@ -67,6 +67,7 @@ import {
} from "@/lib/search-params"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
import { getToolDocumentSpace } from "@/lib/plugin-space"
import { getBackendUrl } from "@/lib/url-helpers"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -134,6 +135,7 @@ export function AppExperience() {
const { viewMode, setViewMode } = useViewMode()
useLegacyViewRedirect()
const isCompanyBrain = useHasCompanyBrain()
const backendUrl = getBackendUrl()
// Slack OAuth redirects back here with ?slack=connected — toast then clean up.
useEffect(() => {
@ -331,7 +333,7 @@ export function AppExperience() {
queryFn: async (): Promise<SpaceHighlightsResponse> => {
const spaceId = selectedProject || "sm_project_default"
const forceRefresh = highlightsForceAt > 0
const cacheKey = `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights?spaceId=${spaceId}`
const cacheKey = `${backendUrl}/v3/space-highlights?spaceId=${spaceId}`
if (!forceRefresh) {
const cache = await caches.open(HIGHLIGHTS_CACHE_NAME)
@ -345,22 +347,19 @@ export function AppExperience() {
}
}
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
spaceId,
highlightsCount: 3,
questionsCount: 4,
includeHighlights: true,
includeQuestions: true,
forceRefresh,
}),
},
)
const response = await fetch(`${backendUrl}/v3/space-highlights`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
spaceId,
highlightsCount: 3,
questionsCount: 4,
includeHighlights: true,
includeQuestions: true,
forceRefresh,
}),
})
if (!response.ok) {
throw new Error("Failed to fetch space highlights")
@ -404,10 +403,9 @@ export function AppExperience() {
if (stored) return JSON.parse(stored) as MemoryOfDay
} catch {}
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/memory-of-day`,
{ credentials: "include" },
)
const response = await fetch(`${backendUrl}/v3/memory-of-day`, {
credentials: "include",
})
if (!response.ok) return null
const data = (await response.json()) as MemoryOfDay | null
if (data) {
@ -814,7 +812,7 @@ export function AppExperience() {
{isDashboardShell && showBottomNav && (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-20 h-64 bg-gradient-to-t from-[#05080D] via-[#05080D]/95 to-transparent" />
)}
{isDashboardShell && (
{isDashboardShell && !isCompanyBrain && (
<div
className={cn(
"pointer-events-none fixed inset-x-0 z-30",

View file

@ -0,0 +1,119 @@
import { cn } from "@lib/utils"
import { Granola, Notion } from "@ui/assets/icons"
import { dmSans125ClassName } from "@/lib/fonts"
export function SlackMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
<title>Slack</title>
<path
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
fill="#E01E5A"
/>
<path
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
fill="#E01E5A"
/>
<path
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
fill="#36C5F0"
/>
<path
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
fill="#36C5F0"
/>
<path
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
fill="#2EB67D"
/>
<path
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
fill="#2EB67D"
/>
<path
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
fill="#ECB22E"
/>
<path
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
fill="#ECB22E"
/>
</svg>
)
}
function GithubMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
)
}
function LinearMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<title>Linear</title>
<path d="M3.084 12.866a8.916 8.916 0 0 0 8.05 8.05.27.27 0 0 0 .222-.46l-7.812-7.812a.27.27 0 0 0-.46.222Zm-.044-1.955a.27.27 0 0 0 .078.21l9.76 9.76c.06.06.142.087.21.078a8.87 8.87 0 0 0 1.273-.218.27.27 0 0 0 .127-.453L3.712 9.51a.27.27 0 0 0-.453.127 8.87 8.87 0 0 0-.218 1.273Zm.69-2.706a.27.27 0 0 0 .06.29l11.715 11.716a.27.27 0 0 0 .29.06 8.96 8.96 0 0 0 .837-.384.27.27 0 0 0 .066-.439L4.553 7.302a.27.27 0 0 0-.44.066 8.96 8.96 0 0 0-.383.837Zm1.11-1.798a.27.27 0 0 1-.017-.366A8.948 8.948 0 0 1 18.07 18.69a.27.27 0 0 1-.366-.017L4.94 6.407Z" />
</svg>
)
}
function SentryMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<title>Sentry</title>
<path d="M13.91 2.505c-.873-1.448-2.972-1.448-3.844 0L6.904 7.92a15.478 15.478 0 0 1 8.53 12.811h-2.221A13.301 13.301 0 0 0 5.784 9.814l-2.926 5.06a7.65 7.65 0 0 1 4.435 5.848H2.194a.365.365 0 0 1-.298-.534l1.413-2.402a5.16 5.16 0 0 0-1.614-.913L.296 19.275a2.182 2.182 0 0 0 .812 2.999 2.24 2.24 0 0 0 1.086.288h6.983a9.322 9.322 0 0 0-3.845-8.318l1.11-1.922a11.47 11.47 0 0 1 4.95 10.24h5.915a17.242 17.242 0 0 0-7.885-15.28l2.244-3.845a.37.37 0 0 1 .504-.13c.255.14 9.75 16.708 9.928 16.9a.365.365 0 0 1-.327.543h-2.287c.029.612.029 1.223 0 1.831h2.297a2.206 2.206 0 0 0 1.922-3.31z" />
</svg>
)
}
function PostHogMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<title>PostHog</title>
<path d="M9.854 14.5 5 9.647.854 5.5A.5.5 0 0 0 0 5.854V8.44a.5.5 0 0 0 .146.353L5 13.647l.147.146L9.854 18.5l.146.147v-.049c.065.03.134.049.207.049h2.586a.5.5 0 0 0 .353-.854L9.854 14.5zm0-5-4-4a.487.487 0 0 0-.409-.144.515.515 0 0 0-.356.21.493.493 0 0 0-.089.288V8.44a.5.5 0 0 0 .147.353l9 9a.5.5 0 0 0 .853-.354v-2.585a.5.5 0 0 0-.146-.354l-5-5zm1-4a.5.5 0 0 0-.854.354V8.44a.5.5 0 0 0 .147.353l4 4a.5.5 0 0 0 .853-.354V9.854a.5.5 0 0 0-.146-.354l-4-4zm12.647 11.515a3.863 3.863 0 0 1-2.232-1.1l-4.708-4.707a.5.5 0 0 0-.854.354v6.585a.5.5 0 0 0 .5.5H23.5a.5.5 0 0 0 .5-.5v-.6c0-.276-.225-.497-.499-.532zm-5.394.032a.8.8 0 1 1 0-1.6.8.8 0 0 1 0 1.6zM.854 15.5a.5.5 0 0 0-.854.354v2.293a.5.5 0 0 0 .5.5h2.293c.222 0 .39-.135.462-.309a.493.493 0 0 0-.109-.545L.854 15.501zM5 14.647.854 10.5a.5.5 0 0 0-.854.353v2.586a.5.5 0 0 0 .146.353L4.854 18.5l.146.147h2.793a.5.5 0 0 0 .353-.854L5 14.647z" />
</svg>
)
}
function LetterMark({ label, color }: { label: string; color: string }) {
return (
<span
className={cn(
dmSans125ClassName(),
"flex size-full items-center justify-center rounded-[8px] text-[15px] font-bold text-white",
)}
style={{ background: color }}
>
{label.charAt(0).toUpperCase()}
</span>
)
}
// Real brand logos where we have them, a branded lettermark otherwise.
export function brainConnectorIcon(
slug: string,
name: string,
className = "size-[18px]",
): React.ReactNode {
switch (slug) {
case "github":
return <GithubMark className={cn(className, "text-[#FAFAFA]")} />
case "linear":
return <LinearMark className={cn(className, "text-[#5E6AD2]")} />
case "slack":
return <SlackMark className={className} />
case "notion":
return <Notion className={className} />
case "granola":
return <Granola className={className} />
case "sentry":
return <SentryMark className={cn(className, "text-[#FAFAFA]")} />
case "posthog":
return <PostHogMark className={cn(className, "text-[#F1A82C]")} />
default:
return <LetterMark label={name} color="#1E293B" />
}
}

View file

@ -4,8 +4,10 @@ import { $fetch } from "@lib/api"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import { useQuery } from "@tanstack/react-query"
import { ArrowRight, Check, FileText, Loader2 } from "lucide-react"
import { ArrowRight, Check, FileText, Loader2, UserPlus } from "lucide-react"
import Link from "next/link"
import { useQueryState } from "nuqs"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { dmSans125ClassName } from "@/lib/fonts"
import { ConnectionsBoard } from "./connections-board"
@ -21,6 +23,7 @@ type RecentDoc = {
id?: string
title?: string | null
createdAt?: string | Date | null
updatedAt?: string | Date | null
}
function useBrainOverview() {
@ -50,6 +53,27 @@ function useBrainOverview() {
enabled,
})
const lastUpdated = useQuery({
queryKey: ["brain-last-updated", org?.id],
queryFn: async () => {
const res = await $fetch("@post/documents/documents", {
body: {
page: 1,
limit: 1,
sort: "updatedAt",
order: "desc",
containerTags: [],
},
disableValidation: true,
})
if (res.error) return null
const docs = (res.data as { documents?: RecentDoc[] })?.documents
return docs?.[0]?.updatedAt ?? null
},
staleTime: 60_000,
enabled,
})
const connectors = useQuery({
queryKey: ["brain-home", "connectors"],
queryFn: async () => {
@ -103,11 +127,18 @@ function useBrainOverview() {
(brain.data?.slack ? 1 : 0) +
(connectors.data?.length ?? 0)
const currentRole = org?.members
?.find((m) => m.userId === user?.id)
?.role?.toLowerCase()
return {
loading: docs.isPending,
recentDocs: docs.data?.documents ?? [],
lastUpdatedAt: lastUpdated.data ?? null,
memoriesCount,
connectedCount,
membersCount: org?.members?.length ?? 0,
canInvite: currentRole === "owner" || currentRole === "admin",
hasSource: connectedCount > 0,
hasAgent: mcp.data ?? false,
hasMemory: memoriesCount > 0,
@ -119,22 +150,33 @@ export function BrainHomeView() {
const stepsDone = [o.hasSource, o.hasAgent, o.hasMemory].filter(
Boolean,
).length
const showGettingStarted = !o.loading && stepsDone < 3
return (
<div className="mx-auto max-w-[1080px] space-y-6">
<StatsRow
memories={o.memoriesCount}
connected={o.connectedCount}
members={o.membersCount}
canInvite={o.canInvite}
setupDone={stepsDone}
lastUpdatedAt={o.lastUpdatedAt}
/>
<ConnectionsBoard />
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
<div
className={cn(
"grid gap-6",
showGettingStarted && "lg:grid-cols-[minmax(0,1fr)_340px]",
)}
>
<RecentMemories docs={o.recentDocs} loading={o.loading} />
<GettingStarted
hasSource={o.hasSource}
hasAgent={o.hasAgent}
hasMemory={o.hasMemory}
/>
{showGettingStarted && (
<GettingStarted
hasSource={o.hasSource}
hasAgent={o.hasAgent}
hasMemory={o.hasMemory}
/>
)}
</div>
</div>
)
@ -143,27 +185,67 @@ export function BrainHomeView() {
function StatsRow({
memories,
connected,
members,
canInvite,
setupDone,
lastUpdatedAt,
}: {
memories: number
connected: number
members: number
canInvite: boolean
setupDone: number
lastUpdatedAt: string | Date | null
}) {
const tiles = [
const { openSettings } = useSettingsModal()
const [, setInvite] = useQueryState("invite")
const onInvite = () => {
setInvite("1")
openSettings("account")
}
const tiles: {
label: string
value: string
action?: React.ReactNode
}[] = [
{ label: "Memories", value: memories.toLocaleString() },
{ label: "Connected sources", value: String(connected) },
{ label: "Setup", value: `${setupDone}/3` },
{
label: "Active members",
value: String(members),
action: canInvite ? (
<button
type="button"
onClick={onInvite}
className="inline-flex items-center gap-1 text-[11px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
>
<UserPlus className="size-3" />
Invite
</button>
) : undefined,
},
setupDone < 3
? { label: "Setup", value: `${setupDone}/3` }
: {
label: "Last updated",
value: formatWhen(lastUpdatedAt) || "—",
},
]
return (
<section
className="grid grid-cols-3 divide-x divide-white/[0.04] rounded-[16px] bg-[#1B1F24]"
className="grid grid-cols-2 divide-white/[0.04] rounded-[16px] bg-[#1B1F24] sm:grid-cols-4 sm:divide-x"
style={cardStyle}
>
{tiles.map((t) => (
<div key={t.label} className="px-5 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#737373]">
{t.label}
</p>
<div className="flex items-start justify-between gap-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#737373]">
{t.label}
</p>
{t.action}
</div>
<p
className={cn(
"mt-1.5 text-[22px] font-semibold leading-none tabular-nums text-[#fafafa]",

View file

@ -1,17 +1,21 @@
"use client"
import { $fetch } from "@lib/api"
import { cn } from "@lib/utils"
import { useQuery } from "@tanstack/react-query"
import { GoogleDrive, Notion } from "@ui/assets/icons"
import { Cloud, ExternalLink, Loader2 } from "lucide-react"
import Link from "next/link"
import { ArrowRight, Loader2 } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { dmSans125ClassName } from "@/lib/fonts"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
// Apps surfaced on the dashboard; the rest live behind "More" in settings.
const FEATURED_SLUGS = ["linear", "granola", "sentry"] as const
// Example prompts on the right card — can include apps not shown on the left.
const PREVIEW_PROMPT_SLUGS = ["linear", "granola", "github", "sentry"] as const
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const MCP_BASE = `${BACKEND}/brain/mcp-connections`
const cardStyle = {
boxShadow:
@ -22,64 +26,128 @@ const tileStyle = {
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
}
type ConnRow = { toolkit: string; org: boolean; user: boolean }
type AuthType = "oauth" | "static" | "none"
type CatalogEntry = {
slug: string
name: string
category: string
authType: AuthType
tokenHint?: string
}
type ConnRow = {
serverSlug: string
status: "active" | "pending" | "error"
userId: string | null
}
// Example asks that connecting each app unlocks for the Slack agent.
const AGENT_PROMPTS: Record<string, string> = {
linear: "What's blocking the sprint?",
github: "Summarize the open PRs on auth",
sentry: "Any new errors since the deploy?",
notion: "Find our launch checklist",
posthog: "How's activation trending this week?",
plain: "What are customers asking about?",
granola: "Recap yesterday's standup",
}
function titleCase(s: string) {
return s.replace(/\b\w/g, (c) => c.toUpperCase())
}
export function ConnectionsBoard() {
const [brainRows, setBrainRows] = useState<ConnRow[] | null>(null)
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
const [rows, setRows] = useState<ConnRow[]>([])
const [slack, setSlack] = useState<{
connected: boolean
teamName: string | null
} | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const loadBrain = useCallback(async () => {
const load = useCallback(async () => {
try {
const [c, s] = await Promise.all([
fetch(`${BACKEND}/brain/connections`, { credentials: "include" }),
const [cat, conn, s] = await Promise.all([
fetch(`${MCP_BASE}/catalog`, { credentials: "include" }),
fetch(`${MCP_BASE}/`, { credentials: "include" }),
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
])
if (c.ok)
setBrainRows(((await c.json()) as { toolkits: ConnRow[] }).toolkits)
if (s.ok) setSlack(await s.json())
} catch {}
// Parse each response independently so one bad payload can't strand the others.
try {
if (cat.ok) {
const data: { catalog?: CatalogEntry[] } = await cat.json()
setCatalog(data.catalog ?? [])
} else setCatalog([])
} catch {
setCatalog([])
}
try {
if (conn.ok) {
const data: { connections?: ConnRow[] } = await conn.json()
setRows(data.connections ?? [])
} else setRows([])
} catch {
setRows([])
}
try {
if (s.ok) setSlack(await s.json())
} catch {}
} catch {
setCatalog([])
setRows([])
}
}, [])
useEffect(() => {
void loadBrain()
const onFocus = () => void loadBrain()
void load()
const onFocus = () => void load()
window.addEventListener("focus", onFocus)
return () => window.removeEventListener("focus", onFocus)
}, [loadBrain])
}, [load])
const { data: connectors } = useQuery({
queryKey: ["brain-home", "connectors"],
queryFn: async () => {
const res = await $fetch("@post/connections/list", {
body: { containerTags: [] },
})
if (res.error) return [] as Array<{ provider?: string }>
return (res.data ?? []) as Array<{ provider?: string }>
},
staleTime: 30_000,
})
const isConnected = (slug: string) =>
rows.some((r) => r.serverSlug === slug && r.status === "active")
const connectorConnected = (provider: string) =>
Boolean(connectors?.some((c) => c.provider === provider))
const connectBrain = async (toolkit: string) => {
setBusy(`brain:${toolkit}`)
const connect = async (entry: CatalogEntry) => {
setBusy(entry.slug)
try {
const res = await fetch(
`${BACKEND}/brain/connections/${toolkit}/link?scope=user`,
{ method: "POST", credentials: "include" },
)
if (entry.authType === "static") {
const token = window.prompt(
`Paste a token for ${entry.name}.${entry.tokenHint ? `\n${entry.tokenHint}` : ""}`,
)
if (!token) return
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect-static`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token, shared: false }),
})
if (!res.ok) {
toast.error("Couldn't connect.")
return
}
toast.success(`${entry.name} connected.`)
await load()
return
}
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
shared: false,
redirectUrl: window.location.href,
}),
})
if (!res.ok) {
toast.error("Couldn't start the connection.")
return
}
const data = (await res.json()) as { url?: string }
if (data.url) window.open(data.url, "_blank", "noopener")
else toast.error("Couldn't start the connection.")
const data: { authUrl?: string; ok?: boolean } = await res.json()
if (data.authUrl) window.open(data.authUrl, "_blank", "noopener")
else if (data.ok) {
toast.success(`${entry.name} connected.`)
await load()
} else toast.error("Couldn't start the connection.")
} catch {
toast.error("Couldn't start the connection.")
} finally {
@ -87,99 +155,259 @@ export function ConnectionsBoard() {
}
}
const connectConnector = async (
provider: "google-drive" | "notion" | "onedrive",
) => {
setBusy(`conn:${provider}`)
try {
const res = await $fetch("@post/connections/:provider", {
params: { provider },
body: { redirectUrl: window.location.href, containerTags: [] },
})
const data = res.data as { authLink?: string } | undefined
if (data?.authLink) window.location.href = data.authLink
else toast.error("Couldn't start the connection.")
} catch {
toast.error("Couldn't start the connection.")
} finally {
setBusy(null)
}
}
const brainConnected = (toolkit: string) =>
Boolean(brainRows?.find((r) => r.toolkit === toolkit)?.org) ||
Boolean(brainRows?.find((r) => r.toolkit === toolkit)?.user)
const { openSettings } = useSettingsModal()
const apps = catalog ?? []
const loading = catalog === null
const featured = FEATURED_SLUGS.map((slug) =>
apps.find((a) => a.slug === slug),
).filter((a): a is CatalogEntry => Boolean(a))
const previewApps = PREVIEW_PROMPT_SLUGS.map((slug) =>
apps.find((a) => a.slug === slug),
).filter((a): a is CatalogEntry => Boolean(a))
const remainingCount = Math.max(apps.length - featured.length, 0)
const connectedCount = apps.filter((a) => isConnected(a.slug)).length
return (
<div className="space-y-4">
{slack && !slack.connected && <SlackBanner />}
<div className="grid gap-4 lg:grid-cols-2">
<Group
title="Tool integrations"
subtitle="Apps your agents can act on."
<div className="grid items-start gap-4 lg:grid-cols-5">
<section
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5 lg:col-span-3"
style={cardStyle}
>
<AppCard
icon={<GithubMark className="size-5 text-[#fafafa]" />}
name="GitHub"
subtitle="Repos, pull requests and issues."
connected={brainConnected("github")}
busy={busy === "brain:github"}
onConnect={() => connectBrain("github")}
/>
<AppCard
icon={<LinearMark className="size-5 text-[#5E6AD2]" />}
name="Linear"
subtitle="Issues, projects and cycles."
connected={brainConnected("linear")}
busy={busy === "brain:linear"}
onConnect={() => connectBrain("linear")}
/>
</Group>
<Group
title="Connectors"
subtitle="Sync documents into your brain."
cta={
<Link
href="/settings/integrations"
className="inline-flex items-center gap-1 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
<div>
<p
className={cn(
"text-[15px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
All connectors
<ExternalLink className="size-3" aria-hidden />
</Link>
}
>
<AppCard
icon={<GoogleDrive className="size-5" />}
name="Google Drive"
subtitle="Docs, sheets and slides."
connected={connectorConnected("google-drive")}
busy={busy === "conn:google-drive"}
onConnect={() => connectConnector("google-drive")}
/>
<AppCard
icon={<Notion className="size-5" />}
name="Notion"
subtitle="Pages, databases and blocks."
connected={connectorConnected("notion")}
busy={busy === "conn:notion"}
onConnect={() => connectConnector("notion")}
/>
<AppCard
icon={<Cloud className="size-5 text-[#0F6CBD]" />}
name="OneDrive"
subtitle="Files from Microsoft 365."
connected={connectorConnected("onedrive")}
busy={busy === "conn:onedrive"}
onConnect={() => connectConnector("onedrive")}
/>
</Group>
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.
</p>
</div>
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<TileSkeleton key={i} showDivider={i < 2} />
))
) : (
<>
{featured.map((entry, i) => (
<AppTile
key={entry.slug}
icon={brainConnectorIcon(entry.slug, entry.name, "size-5")}
name={entry.name}
subtitle={titleCase(entry.category)}
connected={isConnected(entry.slug)}
busy={busy === entry.slug}
onConnect={() => connect(entry)}
showDivider={i < featured.length - 1 || remainingCount > 0}
/>
))}
{remainingCount > 0 && (
<MoreTile
count={remainingCount}
onClick={() => openSettings("company-brain")}
/>
)}
</>
)}
</div>
</section>
<AgentPreview
apps={previewApps}
isConnected={isConnected}
connectedCount={connectedCount}
/>
</div>
</div>
)
}
function AgentPreview({
apps,
isConnected,
connectedCount,
}: {
apps: CatalogEntry[]
isConnected: (slug: string) => boolean
connectedCount: number
}) {
const prompts = apps
.filter((a) => AGENT_PROMPTS[a.slug])
.slice(0, 6)
.map((a) => ({
slug: a.slug,
name: a.name,
prompt: AGENT_PROMPTS[a.slug],
connected: isConnected(a.slug),
}))
return (
<section
className="relative flex h-fit min-w-0 flex-col gap-2 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5 lg:col-span-2"
style={cardStyle}
>
<div className="flex items-center gap-2">
<SlackMark className="size-4" />
<p
className={cn(
"text-[15px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Ask in Slack
</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:"}
</p>
<div className="overflow-hidden rounded-[12px] bg-[#14161A]">
{prompts.map((p, i) => (
<div
key={p.slug}
className={cn(
"grid min-h-11 grid-cols-[28px_1fr] items-center gap-x-3 px-3 py-2.5 transition-opacity",
i < prompts.length - 1 && "border-b border-[#1B1F24]",
p.connected ? "opacity-100" : "opacity-60",
)}
>
<div
className="flex size-7 items-center justify-center overflow-hidden rounded-[8px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
style={tileStyle}
>
{brainConnectorIcon(p.slug, p.name, "size-4")}
</div>
<p className="text-[12.5px] font-medium leading-snug text-[#d4d4d8]">
"{p.prompt}"
</p>
</div>
))}
</div>
</section>
)
}
function AppTile({
icon,
name,
subtitle,
connected,
busy,
onConnect,
showDivider = false,
}: {
icon: React.ReactNode
name: string
subtitle: string
connected: boolean
busy: boolean
onConnect: () => void
showDivider?: boolean
}) {
return (
<div
className={cn(
"flex min-h-[52px] items-center gap-3 px-3 py-2.5",
showDivider && "border-b border-[#1B1F24]",
)}
>
<div
className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-[10px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
style={tileStyle}
>
{icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-semibold leading-none text-[#fafafa]">
{name}
</p>
<p className="mt-1 truncate text-[11px] font-medium leading-none text-[#737373]">
{subtitle}
</p>
</div>
{connected ? (
<span className="flex w-[88px] shrink-0 items-center justify-end gap-1.5 text-[12px] font-medium text-[#fafafa]">
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
Connected
</span>
) : (
<button
type="button"
onClick={onConnect}
disabled={busy}
className={cn(
dmSans125ClassName(),
"flex w-[88px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 py-1.5 text-[12px] font-medium text-[#fafafa] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:opacity-50",
)}
>
{busy && <Loader2 className="size-3.5 animate-spin" />}
Connect
</button>
)}
</div>
)
}
function MoreTile({ count, onClick }: { count: number; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={cn(
dmSans125ClassName(),
"flex min-h-[52px] w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-[#171A1F]",
)}
>
<div
className="flex size-9 shrink-0 items-center justify-center rounded-[10px] border border-dashed border-[#2A3140] bg-[#080B0F] text-[11px] font-semibold text-[#737373]"
style={tileStyle}
>
+{count}
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-semibold leading-none text-[#fafafa]">
{count} more {count === 1 ? "app" : "apps"}
</p>
<p className="mt-1 truncate text-[11px] font-medium leading-none text-[#737373]">
Notion, PostHog, Plain and more
</p>
</div>
<div className="flex w-[88px] shrink-0 justify-end">
<ArrowRight className="size-4 text-[#52525B]" />
</div>
</button>
)
}
function TileSkeleton({ showDivider = false }: { showDivider?: boolean }) {
return (
<div
className={cn(
"flex min-h-[52px] items-center gap-3 px-3 py-2.5",
showDivider && "border-b border-[#1B1F24]",
)}
>
<div className="size-9 animate-pulse rounded-[10px] bg-[#1c1f24]" />
<div className="flex-1 space-y-2">
<div className="h-3 w-20 animate-pulse rounded bg-[#1c1f24]" />
<div className="h-2.5 w-28 animate-pulse rounded bg-[#1c1f24]" />
</div>
<div className="h-7 w-[88px] animate-pulse rounded-full bg-[#1c1f24]" />
</div>
)
}
function SlackBanner() {
return (
<section
@ -230,164 +458,3 @@ function SlackBanner() {
</section>
)
}
function Group({
title,
subtitle,
accent,
cta,
children,
}: {
title: string
subtitle: string
accent?: boolean
cta?: React.ReactNode
children: React.ReactNode
}) {
return (
<section
className="relative flex min-w-0 flex-col gap-2.5 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5"
style={cardStyle}
>
{accent && (
<div
aria-hidden
className="absolute -top-px right-8 left-8 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.45), transparent)",
}}
/>
)}
<div className="mb-1 flex items-start justify-between gap-3">
<div>
<p
className={cn(
"text-[15px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
{title}
</p>
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
{subtitle}
</p>
</div>
{cta}
</div>
{children}
</section>
)
}
function AppCard({
icon,
name,
subtitle,
connected,
busy,
onConnect,
}: {
icon: React.ReactNode
name: string
subtitle: string
connected: boolean
busy: boolean
onConnect: () => void
}) {
return (
<div className="flex items-center gap-3 rounded-[12px] bg-[#14161A] p-3">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-[10px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
style={tileStyle}
>
{icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-[14px] font-semibold leading-tight text-[#fafafa]">
{name}
</p>
<p className="mt-0.5 truncate text-[12px] font-medium text-[#737373]">
{subtitle}
</p>
</div>
{connected ? (
<span className="flex shrink-0 items-center gap-1.5 text-[12px] font-medium text-[#fafafa]">
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
Connected
</span>
) : (
<button
type="button"
onClick={onConnect}
disabled={busy}
className={cn(
dmSans125ClassName(),
"flex shrink-0 items-center gap-1.5 rounded-full bg-[#0D121A] px-3.5 py-2 text-[13px] font-medium text-[#fafafa] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:opacity-50",
)}
>
{busy && <Loader2 className="size-3.5 animate-spin" />}
Connect
</button>
)}
</div>
)
}
function GithubMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
)
}
function LinearMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<title>Linear</title>
<path d="M3.084 12.866a8.916 8.916 0 0 0 8.05 8.05.27.27 0 0 0 .222-.46l-7.812-7.812a.27.27 0 0 0-.46.222Zm-.044-1.955a.27.27 0 0 0 .078.21l9.76 9.76c.06.06.142.087.21.078a8.87 8.87 0 0 0 1.273-.218.27.27 0 0 0 .127-.453L3.712 9.51a.27.27 0 0 0-.453.127 8.87 8.87 0 0 0-.218 1.273Zm.69-2.706a.27.27 0 0 0 .06.29l11.715 11.716a.27.27 0 0 0 .29.06 8.96 8.96 0 0 0 .837-.384.27.27 0 0 0 .066-.439L4.553 7.302a.27.27 0 0 0-.44.066 8.96 8.96 0 0 0-.383.837Zm1.11-1.798a.27.27 0 0 1-.017-.366A8.948 8.948 0 0 1 18.07 18.69a.27.27 0 0 1-.366-.017L4.94 6.407Z" />
</svg>
)
}
function SlackMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
<title>Slack</title>
<path
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
fill="#E01E5A"
/>
<path
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
fill="#E01E5A"
/>
<path
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
fill="#36C5F0"
/>
<path
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
fill="#36C5F0"
/>
<path
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
fill="#2EB67D"
/>
<path
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
fill="#2EB67D"
/>
<path
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
fill="#ECB22E"
/>
<path
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
fill="#ECB22E"
/>
</svg>
)
}

View file

@ -1111,7 +1111,7 @@ export function ChatSidebar({
const params = new URLSearchParams({ projectId: chatProject })
if (historyScope === "all") params.set("scope", "all")
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads?${params.toString()}`,
`${chatApiBase}/chat/threads?${params.toString()}`,
{ credentials: "include" },
)
if (response.ok) {
@ -1123,7 +1123,7 @@ export function ChatSidebar({
} finally {
setIsLoadingThreads(false)
}
}, [chatProject, historyScope])
}, [chatApiBase, chatProject, historyScope])
useEffect(() => {
if (!isHistoryOpen) return
@ -1134,10 +1134,9 @@ export function ChatSidebar({
const loadThread = useCallback(
async (id: string) => {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${id}`,
{ credentials: "include" },
)
const response = await fetch(`${chatApiBase}/chat/threads/${id}`, {
credentials: "include",
})
if (response.ok) {
const data = await response.json()
const uiMessages = data.messages.map(
@ -1179,7 +1178,7 @@ export function ChatSidebar({
console.error("Failed to load thread:", error)
}
},
[setThreadId],
[chatApiBase, setThreadId],
)
// Auto-restore thread from URL on mount (e.g. reload or direct link)
@ -1197,7 +1196,7 @@ export function ChatSidebar({
async (threadId: string) => {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/threads/${threadId}`,
`${chatApiBase}/chat/threads/${threadId}`,
{ method: "DELETE", credentials: "include" },
)
if (response.ok) {
@ -1213,7 +1212,7 @@ export function ChatSidebar({
setConfirmingDeleteId(null)
}
},
[currentChatId, handleNewChat],
[chatApiBase, currentChatId, handleNewChat],
)
const formatRelativeTime = (isoString: string): string => {

View file

@ -0,0 +1,604 @@
"use client"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
import { useIsMobile } from "@hooks/use-mobile"
import { useQuery } from "@tanstack/react-query"
import {
Building2,
Code2,
ExternalLink,
Home,
LifeBuoy,
Link2,
LayoutGrid,
MenuIcon,
SearchIcon,
Settings,
UserPlus,
ChevronRight,
Sun,
} from "lucide-react"
import Link from "next/link"
import { useQueryState } from "nuqs"
import { useCallback } from "react"
import { DomainLogo } from "@/components/onboarding-brain/step-about"
import { FeedbackModal } from "@/components/feedback-modal"
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
import { SlackMark } from "@/components/brain-connector-icons"
import { GraphIcon, IntegrationsIcon } from "@/components/integration-icons"
import { SpaceSelector } from "@/components/space-selector"
import { UserProfileMenu } from "@/components/user-profile-menu"
import { useTokenUsage } from "@/hooks/use-token-usage"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
import { getBrainWorkspaceDomain } from "@/lib/billing-utils"
import { dmSansClassName } from "@/lib/fonts"
import { useViewMode } from "@/lib/view-mode-context"
import { feedbackParam } from "@/lib/search-params"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { useProject } from "@/stores"
import { useCustomer } from "autumn-js/react"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
type SlackStatus = { connected: boolean; teamName: string | null }
interface CompanyBrainHeaderProps {
onOpenSearch?: () => void
}
const brainItemClass = (active: boolean) =>
cn(
"gap-2.5 rounded-lg px-2 py-1.5 text-sm font-medium cursor-pointer transition-colors",
"hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white",
active ? "bg-white/[0.06] text-white" : "text-white/85",
)
const brainTileClass = (active: boolean) =>
cn(
"flex size-7 shrink-0 items-center justify-center rounded-lg border text-[11px] font-semibold",
active
? "border-[#2261CA66] bg-[#0B2A57] text-[#7EB0FF]"
: "border-white/[0.08] bg-white/[0.04] text-white/70",
)
const menuItemClass =
"gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
const circleNavClass = (active: boolean) =>
cn(
"flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
active
? "border-[#2261CA33] bg-[#00173C] text-white"
: "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5",
dmSansClassName(),
)
const tabClass = (active: boolean) =>
cn(
"inline-flex h-[calc(100%-1px)] min-h-0 cursor-pointer snap-start items-center justify-center gap-1 rounded-full border border-transparent px-2.5 text-xs font-medium whitespace-nowrap transition-colors sm:gap-1.5 sm:px-3 sm:text-sm",
active
? "border-[#2261CA33] bg-[#00173C] text-white"
: "text-foreground hover:bg-white/5",
dmSansClassName(),
)
function useSlackStatus() {
return useQuery({
queryKey: ["brain-slack-status"],
queryFn: async (): Promise<SlackStatus> => {
const res = await fetch(`${BACKEND}/brain/slack/status`, {
credentials: "include",
})
if (!res.ok) return { connected: false, teamName: null }
return (await res.json()) as SlackStatus
},
staleTime: 30_000,
})
}
export function CompanyBrainHeader({ onOpenSearch }: CompanyBrainHeaderProps) {
const { user, org, organizations, setActiveOrg } = useAuth()
const autumn = useCustomer()
const { currentPlan } = useTokenUsage(autumn)
const { data: orgSummaries } = useOrgSummaries()
const { viewMode, setViewMode } = useViewMode()
const { selectedProjects, setSelectedProjects } = useProject()
const { openSettings } = useSettingsModal()
const isMobile = useIsMobile()
const [feedbackOpen, setFeedbackOpen] = useQueryState(
"feedback",
feedbackParam,
)
const [, setInvite] = useQueryState("invite")
const [settingsTab] = useQueryState("settings")
const { data: slackStatus } = useSlackStatus()
const planByOrgId = new Map(
(orgSummaries ?? []).map((s) => [s.orgId, s.plan] as const),
)
const orgLabel = org?.name.replace(/\s*organizations?\s*$/i, "").trim()
const brandLabel = orgLabel || "Workspace"
const domain = getBrainWorkspaceDomain(
org?.metadata as Record<string, unknown> | string | null | undefined,
)
const hasOrgs = (organizations?.length ?? 0) > 0
const memberRole = org?.members
?.find((m) => m.userId === user?.id)
?.role?.toLowerCase()
const canInvite = memberRole === "owner" || memberRole === "admin"
const isOverview = viewMode === "dashboard" && settingsTab !== "company-brain"
const isGraph = viewMode === "graph"
const isMemories = viewMode === "list"
const isConnections = settingsTab === "company-brain"
const slackConnected = slackStatus?.connected ?? false
const selectOrg = useCallback(
(slug: string, isActive: boolean) => {
if (isActive) return
void setActiveOrg(slug).then(() => window.location.reload())
},
[setActiveOrg],
)
const goOverview = useCallback(() => {
void setViewMode("dashboard")
}, [setViewMode])
const goGraph = useCallback(() => {
void setViewMode("graph")
}, [setViewMode])
const goMemories = useCallback(() => {
void setViewMode("list")
}, [setViewMode])
const goConnections = useCallback(() => {
openSettings("company-brain")
}, [openSettings])
const goIntegrations = useCallback(() => {
void setViewMode("integrations")
}, [setViewMode])
const handleInvite = useCallback(() => {
setInvite("1")
openSettings("account")
}, [openSettings, setInvite])
const handleFeedback = useCallback(() => {
void setFeedbackOpen(true)
}, [setFeedbackOpen])
return (
<div className="relative z-10 flex shrink-0 items-center justify-between gap-1.5 p-2.5 md:gap-2 md:p-3">
<div className="z-10! flex min-w-0 shrink items-center justify-center gap-1.5 md:gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="relative flex max-w-[min(52vw,240px)] shrink-0 cursor-pointer items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/5 outline-none focus-visible:outline-none md:-ml-2 before:absolute before:-inset-x-2 before:-inset-y-2.5 before:content-['']"
>
<div
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[8px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
style={{
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08)",
}}
>
{domain ? (
<DomainLogo domain={domain} />
) : (
<Building2 className="size-4 text-[#737373]" />
)}
</div>
<div className="ml-2 min-w-0 flex flex-col items-start justify-center">
<p className="max-w-full truncate text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
Company Brain
</p>
<p className="-mt-0.5 max-w-full truncate text-sm leading-none font-semibold text-white/90 sm:text-[15px]">
{brandLabel}
</p>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
alignOffset={12}
className={cn(
"min-w-[244px] p-1.5 rounded-xl border border-white/[0.08] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
dmSansClassName(),
)}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
{hasOrgs && (
<>
<p className="px-2 pt-1 pb-1.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-[#5B6675]">
Switch brain
</p>
<div className="flex max-h-[40vh] flex-col gap-0.5 overflow-y-auto overscroll-contain">
{organizations?.map((o) => {
const active = org?.id === o.id
const plan = resolveOrgPlan(
o.id,
active,
currentPlan,
planByOrgId,
)
return (
<DropdownMenuItem
key={o.id}
onClick={() => selectOrg(o.slug, active)}
className={cn(brainItemClass(active))}
>
<span className={cn(brainTileClass(active))}>
{o.name?.trim().charAt(0).toUpperCase() || "?"}
</span>
<span className="flex-1 truncate">{o.name}</span>
<OrgPlanBadge plan={plan} />
</DropdownMenuItem>
)
})}
</div>
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
</>
)}
<DropdownMenuItem asChild className={menuItemClass}>
<Link href="/">
<Home className="size-4 text-[#737373]" />
Home
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={goConnections} className={menuItemClass}>
<Link2 className="size-4 text-[#737373]" />
Connections
</DropdownMenuItem>
<DropdownMenuItem
onClick={goIntegrations}
className={menuItemClass}
>
<Sun className="size-4 text-[#737373]" />
Integrations
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openSettings()}
className={menuItemClass}
>
<Settings className="size-4 text-[#737373]" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
<DropdownMenuItem asChild className={menuItemClass}>
<a
href="https://console.supermemory.ai"
target="_blank"
rel="noreferrer"
>
<Code2 className="size-4 text-[#737373]" />
Developer console
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild className={menuItemClass}>
<a href="https://supermemory.ai" target="_blank" rel="noreferrer">
<ExternalLink className="size-4 text-[#737373]" />
supermemory.ai
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{!isMobile && (
<>
<ChevronRight
className="size-4 shrink-0 text-[#3F4853]"
aria-hidden
/>
<SpaceSelector
selectedProjects={selectedProjects}
onValueChange={setSelectedProjects}
enableDelete={false}
enableEdit={false}
/>
</>
)}
</div>
{!isMobile && (
<div className="z-10! flex min-w-0 max-w-full flex-1 items-center justify-center gap-1.5 overflow-hidden px-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Overview"
aria-current={isOverview ? "page" : undefined}
onClick={goOverview}
className={circleNavClass(isOverview)}
>
<Home className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Overview
</TooltipContent>
</Tooltip>
<div
role="tablist"
aria-label="Content"
aria-orientation="horizontal"
className="text-muted-foreground z-10! inline-flex h-10 w-fit min-w-0 max-w-full items-center justify-center gap-0.5 overflow-x-auto snap-x snap-mandatory scroll-fade-x rounded-full border border-[#161F2C] bg-muted p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<button
type="button"
role="tab"
aria-selected={isGraph}
onClick={goGraph}
className={tabClass(isGraph)}
>
<GraphIcon className="size-3.5 shrink-0 sm:size-4" />
Graph
</button>
<button
type="button"
role="tab"
aria-selected={isMemories}
onClick={goMemories}
className={tabClass(isMemories)}
>
<LayoutGrid className="size-3.5 shrink-0 sm:size-4" />
Memories
</button>
<button
type="button"
role="tab"
aria-selected={isConnections}
onClick={goConnections}
className={tabClass(isConnections)}
>
<IntegrationsIcon className="size-3.5 shrink-0 sm:size-4" />
Connections
</button>
</div>
<SlackNavButton
connected={slackConnected}
teamName={slackStatus?.teamName ?? null}
active={isConnections && slackConnected}
onManage={goConnections}
/>
</div>
)}
<div className="z-10! flex shrink-0 items-center gap-1.5">
{isMobile ? (
<>
<SpaceSelector
selectedProjects={selectedProjects}
onValueChange={setSelectedProjects}
enableDelete={false}
compact
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
>
<MenuIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={cn(
"min-w-[200px] p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
dmSansClassName(),
)}
style={{
background:
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<DropdownMenuItem
onClick={goOverview}
className={menuItemClass}
>
<Home className="size-4 text-[#737373]" />
Overview
</DropdownMenuItem>
<DropdownMenuItem onClick={goGraph} className={menuItemClass}>
<GraphIcon className="size-4 text-[#737373]" />
Graph
</DropdownMenuItem>
<DropdownMenuItem
onClick={goMemories}
className={menuItemClass}
>
<LayoutGrid className="size-4 text-[#737373]" />
Memories
</DropdownMenuItem>
<DropdownMenuItem
onClick={goConnections}
className={menuItemClass}
>
<IntegrationsIcon className="size-4 text-[#737373]" />
Connections
</DropdownMenuItem>
<DropdownMenuItem
onClick={goIntegrations}
className={menuItemClass}
>
<Sun className="size-4 text-[#737373]" />
Integrations
</DropdownMenuItem>
{slackConnected ? (
<DropdownMenuItem
onClick={goConnections}
className={menuItemClass}
>
<SlackMark className="size-4" />
Slack connected
</DropdownMenuItem>
) : (
<DropdownMenuItem asChild className={menuItemClass}>
<a href={`${BACKEND}/brain/slack/oauth/install`}>
<SlackMark className="size-4" />
Add to Slack
</a>
</DropdownMenuItem>
)}
<DropdownMenuSeparator className="bg-[#2E3033]" />
{canInvite && (
<DropdownMenuItem
onClick={handleInvite}
className={menuItemClass}
>
<UserPlus className="size-4 text-[#737373]" />
Invite teammates
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={onOpenSearch}
className={menuItemClass}
>
<SearchIcon className="size-4 text-[#737373]" />
Search
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[#2E3033]" />
<DropdownMenuItem
onClick={handleFeedback}
className={menuItemClass}
>
<LifeBuoy className="size-4 text-[#737373]" />
Feedback
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openSettings()}
className={menuItemClass}
>
<Settings className="size-4 text-[#737373]" />
Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
{canInvite && (
<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={handleInvite}
aria-label="Invite teammates"
>
<UserPlus className="size-3.5 shrink-0 lg:size-4" />
<span className="max-lg:sr-only">Invite</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Invite teammates
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="headers"
className={cn(
"size-9! min-h-9 min-w-9 shrink-0 rounded-full! border-[#161F2C]/90 px-0! text-muted-foreground hover:text-foreground",
dmSansClassName(),
)}
onClick={onOpenSearch}
aria-label="Search"
>
<SearchIcon className="size-4 shrink-0" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Search (K)
</TooltipContent>
</Tooltip>
</>
)}
<UserProfileMenu onOpenFeedback={handleFeedback} />
</div>
<FeedbackModal
isOpen={feedbackOpen}
onClose={() => setFeedbackOpen(false)}
/>
</div>
)
}
function SlackNavButton({
connected,
teamName,
active,
onManage,
}: {
connected: boolean
teamName: string | null
active: boolean
onManage: () => void
}) {
const label = connected
? `Slack${teamName ? ` · ${teamName}` : ""}`
: "Add to Slack"
if (!connected) {
return (
<Tooltip>
<TooltipTrigger asChild>
<a
href={`${BACKEND}/brain/slack/oauth/install`}
aria-label="Add to Slack"
className={circleNavClass(false)}
>
<SlackMark className="size-4" />
</a>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Add to Slack
</TooltipContent>
</Tooltip>
)
}
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
onClick={onManage}
className={cn(circleNavClass(active), "relative")}
>
<SlackMark className="size-4" />
<span className="absolute top-1.5 right-1.5 size-2 rounded-full bg-[#2EB67D] ring-2 ring-[#00173C]" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
{label}
</TooltipContent>
</Tooltip>
)
}

View file

@ -43,6 +43,7 @@ import {
import { normalizePluginClientId } from "@/lib/plugin-catalog"
import { detectPluginSpace } from "@/lib/plugin-space"
import { useDigests } from "@/hooks/use-digests"
import { ReviewMemoriesCard } from "@/components/review-memories-card"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -1181,6 +1182,7 @@ export function DashboardView({
}) {
const { user, org } = useAuth()
const { effectiveContainerTags } = useProject()
const primaryContainerTag = effectiveContainerTags?.[0]
const _router = useRouter()
const { data: recentsData, isPending: isRecentsLoading } = useQuery({
queryKey: ["dashboard-recents", effectiveContainerTags],
@ -1600,7 +1602,8 @@ export function DashboardView({
)}
</div>
<div className="flex-[2] min-w-0 hidden sm:block">
<div className="flex-[2] min-w-0 hidden sm:block space-y-2">
<ReviewMemoriesCard containerTag={primaryContainerTag} />
<RecommendedPluginsCard
profession={profession}
setProfession={setProfession}
@ -1617,7 +1620,8 @@ export function DashboardView({
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Suggested for you
</p>
<div className="max-w-sm">
<div className="max-w-sm space-y-2">
<ReviewMemoriesCard containerTag={primaryContainerTag} />
<RecommendedPluginsCard
profession={profession}
setProfession={setProfession}

View file

@ -17,6 +17,7 @@ import {
} from "@ui/assets/icons"
import { Globe, FileText, FileCode, Image } from "lucide-react"
import { cn } from "@lib/utils"
import { isYouTubeUrl } from "@/lib/url-helpers"
function MCPIcon({ className }: { className?: string }) {
return (
@ -206,7 +207,7 @@ export function DocumentIcon({
return <MCPIcon className={iconClassName} />
}
if (url?.includes("youtube.com") || url?.includes("youtu.be")) {
if (isYouTubeUrl(url)) {
return <YouTubeIcon className={iconClassName} />
}

View file

@ -3,7 +3,7 @@
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import dynamic from "next/dynamic"
import { isTwitterUrl } from "@/lib/url-helpers"
import { isTwitterUrl, isYouTubeUrl } from "@/lib/url-helpers"
import { ImagePreview } from "./image-preview"
import { TweetContent } from "./tweet"
import { NotionDoc } from "./notion-doc"
@ -67,7 +67,7 @@ function getContentType(document: DocumentWithMemories | null): ContentType {
if (document.type === "google_doc") return "google_doc"
if (document.type === "google_sheet") return "google_sheet"
if (document.type === "google_slide") return "google_slide"
if (document.url?.includes("youtube.com")) return "youtube"
if (isYouTubeUrl(document.url)) return "youtube"
if (document.type === "webpage") return "webpage"
return null

View file

@ -44,6 +44,8 @@ import { useTokenUsage } from "@/hooks/use-token-usage"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { CompanyBrainHeader } from "@/components/company-brain-header"
interface HeaderProps {
onAddMemory?: () => void
@ -65,7 +67,15 @@ const brainTileClass = (active: boolean) =>
: "border-white/[0.08] bg-white/[0.04] text-white/70",
)
export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
export function Header(props: HeaderProps) {
const hasCompanyBrain = useHasCompanyBrain()
if (hasCompanyBrain) {
return <CompanyBrainHeader onOpenSearch={props.onOpenSearch} />
}
return <PersonalBrainHeader {...props} />
}
function PersonalBrainHeader({ onAddMemory, onOpenSearch }: HeaderProps) {
const { user, isRestoring, org, organizations, setActiveOrg } = useAuth()
const autumn = useCustomer()
const { currentPlan } = useTokenUsage(autumn)

View file

@ -4,7 +4,6 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useCustomer } from "autumn-js/react"
import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import { hasActivePlan } from "@lib/queries"
import { $fetch } from "@lib/api"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
@ -44,6 +43,7 @@ import {
Zap,
} from "lucide-react"
import { formatRelativeTime } from "@/components/settings/sync-utils"
import { useConnectorAccess } from "@/hooks/use-connector-access"
import { useConnectionHealth } from "@/hooks/use-connection-health"
import { useContainerTags } from "@/hooks/use-container-tags"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
@ -2555,8 +2555,11 @@ export function IntegrationsView({
const { allProjects } = useContainerTags()
const shortcutsConnect = useShortcutsConnect()
const autumn = useCustomer({ queryOptions: { enabled: !publicMode } })
const hasProProduct =
!publicMode && hasActivePlan(autumn.data?.subscriptions, "api_pro")
// connectorAccess covers pro-tier connectors (incl. company_brain orgs); plugins
// stay on hasProProduct. See useConnectorAccess.
const { hasPro: hasProProduct, connectorAccess } = useConnectorAccess({
enabled: !publicMode,
})
const isAutumnLoading = !publicMode && autumn.isLoading
const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null)
@ -2601,7 +2604,7 @@ export function IntegrationsView({
return response.data as Connection[]
},
staleTime: 30 * 1000,
enabled: !publicMode && hasProProduct,
enabled: !publicMode && connectorAccess,
})
const {
@ -2915,7 +2918,7 @@ export function IntegrationsView({
}
if (target === "granola") {
if (!hasProProduct) {
if (!connectorAccess) {
void setConnectTarget(null)
handleUpgrade("api_pro")
} else {
@ -2939,6 +2942,7 @@ export function IntegrationsView({
connectTarget,
isAutumnLoading,
hasProProduct,
connectorAccess,
publicMode,
redirectToLogin,
setConnectTarget,
@ -3397,7 +3401,7 @@ export function IntegrationsView({
case "connector": {
const count = connectionsByProvider[item.provider].length
const isGranola = item.provider === "granola"
const needsPlanUpgrade = !isAutumnLoading && !hasProProduct
const needsPlanUpgrade = !isAutumnLoading && !connectorAccess
if (count > 0) {
return (
<div className="flex w-full items-center justify-between gap-2">
@ -3408,7 +3412,7 @@ export function IntegrationsView({
onClick={() => {
trackCard(item)
if (isGranola) {
if (!hasProProduct) {
if (!connectorAccess) {
handleUpgrade("api_pro")
return
}
@ -3440,7 +3444,7 @@ export function IntegrationsView({
onClick={() => {
trackCard(item)
if (isGranola) {
if (!hasProProduct) {
if (!connectorAccess) {
handleUpgrade("api_pro")
return
}
@ -4260,9 +4264,9 @@ export function IntegrationsView({
</Dialog>
<GranolaConnectModal
open={hasProProduct && granolaModalOpen}
open={connectorAccess && granolaModalOpen}
onOpenChange={(open) => {
setGranolaModalOpen(open && hasProProduct)
setGranolaModalOpen(open && connectorAccess)
if (!open) void setConnectTarget(null)
}}
/>

View file

@ -15,14 +15,16 @@ export function PillButton({
children,
onClick,
disabled,
type = "button",
}: {
children: ReactNode
onClick?: () => void
disabled?: boolean
type?: "button" | "submit"
}) {
return (
<button
type="button"
type={type}
onClick={onClick}
disabled={disabled}
className={cn(

View file

@ -0,0 +1,697 @@
"use client"
import { LogoFull } from "@ui/assets/Logo"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import { cn } from "@lib/utils"
import { ArrowRight, Check, Globe, Loader2 } from "lucide-react"
import { useQueryClient } from "@tanstack/react-query"
import { AnimatePresence, motion } from "motion/react"
import { type ReactNode, useEffect, useRef, useState } from "react"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import {
type ResearchEvent,
type ResearchStat,
useResearchStatus,
} from "@/hooks/use-research-status"
import {
cardSurfaceStyle,
DomainLogo,
fieldLabel,
inputBevelStyle,
inputClass,
UserAvatar,
} from "./step-about"
import { ResearchActionRail } from "./research-action-rail"
import {
type CompanyBrainConfirmResult,
workspaceNameFromDomain,
} from "./types"
interface CompanyBrainOnboardingProps {
name: string
avatarUrl: string | null
domain: string
submitting: boolean
onConfirm: (domain: string) => Promise<CompanyBrainConfirmResult>
onDone: () => void
}
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
type Phase = "confirm" | "research"
function normalizeDomain(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.replace(/\/.*$/, "")
}
export function CompanyBrainOnboarding({
name,
avatarUrl,
domain: initialDomain,
submitting,
onConfirm,
onDone,
}: CompanyBrainOnboardingProps) {
const [phase, setPhase] = useState<Phase>("confirm")
const [domain, setDomain] = useState(initialDomain)
const [serverSchedulesResearch, setServerSchedulesResearch] = useState(false)
const firstName = name.trim().split(/\s+/)[0] ?? ""
const clean = normalizeDomain(domain)
const queryClient = useQueryClient()
const { status: researchStatus } = useResearchStatus(phase === "research")
const researchDone = researchStatus === "done"
const handleConfirm = async () => {
if (!clean || submitting) return
const result = await onConfirm(clean)
if (!result.ok) return
setServerSchedulesResearch(result.serverSchedulesResearch)
setPhase("research")
}
// New-org signup schedules research after provisioning; if that hook is slow
// or fails, force-start from the client so onboarding doesn't stall.
useEffect(() => {
if (phase !== "research" || !serverSchedulesResearch || !clean) return
const timer = window.setTimeout(() => {
void (async () => {
try {
const res = await fetch(`${BACKEND}/brain/research/status`, {
credentials: "include",
headers: { "X-App-Source": "nova" },
})
if (!res.ok) return
const state = (await res.json()) as {
status?: string | null
events?: { aspect: string }[]
}
if (state.status === "done") return
// Real research aspects use ord >= 2; bail if one is already underway.
if ((state.events?.length ?? 0) >= 3) return
await fetch(`${BACKEND}/brain/research/start`, {
method: "POST",
credentials: "include",
headers: {
"content-type": "application/json",
"X-App-Source": "nova",
},
body: JSON.stringify({ domain: clean }),
})
queryClient.invalidateQueries({ queryKey: ["brain-research-status"] })
} catch {}
})()
}, 40_000)
return () => window.clearTimeout(timer)
}, [phase, serverSchedulesResearch, clean, queryClient])
return (
<div
className={cn(
"relative h-dvh bg-[#05080D] text-[#FAFAFA] flex flex-col overflow-hidden",
dmSansClassName(),
)}
>
<Backdrop />
<header className="relative z-10 flex items-center px-6 md:px-10 py-4">
<LogoFull className="h-5 md:h-6 text-[#fafafa]" />
</header>
<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",
)}
>
{/* Persistent card: full confirm card, then morphs into a slim docked header. */}
<motion.div
layout
transition={{ type: "spring", stiffness: 260, damping: 30 }}
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]",
)}
>
<AnimatePresence mode="wait" initial={false}>
{phase === "confirm" ? (
<motion.div
key="confirm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<ConfirmBody
firstName={firstName}
name={name}
avatarUrl={avatarUrl}
domain={domain}
onDomainChange={setDomain}
onConfirm={handleConfirm}
submitting={submitting}
/>
</motion.div>
) : (
<motion.div
key="docked"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.25, delay: 0.1 }}
>
<DockedHeader
domain={clean}
done={researchDone}
onContinue={onDone}
/>
</motion.div>
)}
</AnimatePresence>
</motion.div>
{phase === "confirm" && (
<div className="w-full max-w-xl mx-auto mt-5 flex items-center justify-end px-1">
<Button
variant="insideOut"
onClick={handleConfirm}
disabled={!clean || submitting}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
{submitting ? (
<>
Starting
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
Confirm
<ArrowRight className="size-3.5" />
</>
)}
</Button>
</div>
)}
<AnimatePresence>
{phase === "research" && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15, duration: 0.3 }}
className="relative w-full max-w-7xl xl:max-w-[1360px] mx-auto flex flex-col flex-1 min-h-0 mt-4 mb-8 gap-4"
>
<div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row lg:items-stretch lg:gap-4">
<div className="flex min-h-[280px] min-w-0 flex-[1.15] flex-col lg:min-w-0">
<ResearchTranscript />
</div>
<motion.div
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.35, duration: 0.4 }}
className="flex min-h-[200px] min-w-0 flex-1 flex-col lg:min-w-[340px] lg:max-w-[420px]"
>
<ResearchActionRail domain={clean} />
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
</main>
</div>
)
}
function ConfirmBody({
firstName,
name,
avatarUrl,
domain,
onDomainChange,
onConfirm,
submitting,
}: {
firstName: string
name: string
avatarUrl: string | null
domain: string
onDomainChange: (v: string) => void
onConfirm: () => void
submitting: boolean
}) {
return (
<>
<div className="flex items-center gap-4">
<UserAvatar url={avatarUrl} name={name} className="size-12 shrink-0" />
<div className="min-w-0">
<p
className={cn(
"font-semibold text-[#fafafa] text-[20px]",
dmSans125ClassName(),
)}
>
{firstName ? `Hey ${firstName} 👋` : "Hey there 👋"}
</p>
<p className="text-[#737373] font-medium text-[14px] leading-[1.4] mt-0.5">
I'll research your company and set up its Brain.
</p>
</div>
</div>
<div className="mt-6">
<p className={fieldLabel}>Company domain</p>
<div className="relative">
<div
className="absolute left-1.5 top-1/2 -translate-y-1/2 size-9 rounded-[8px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden"
style={inputBevelStyle}
>
<DomainLogo domain={normalizeDomain(domain) || "supermemory.ai"} />
</div>
<Input
value={domain}
onChange={(e) => onDomainChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !submitting) onConfirm()
}}
placeholder="your-team.com"
spellCheck={false}
autoComplete="off"
className={cn(inputClass, "pl-14")}
style={inputBevelStyle}
/>
</div>
</div>
</>
)
}
function DockedHeader({
domain,
done,
onContinue,
}: {
domain: string
done: boolean
onContinue: () => void
}) {
const brandName = workspaceNameFromDomain(domain) || domain
return (
<div className="flex items-center gap-3">
<div
className="size-8 rounded-[8px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden shrink-0"
style={inputBevelStyle}
>
<DomainLogo domain={domain} />
</div>
<span className="text-[14px] font-semibold text-[#fafafa]">
{brandName}
</span>
<span
className={cn(
"text-[12px] font-medium",
done ? "text-[#5CD68A]" : "text-[#737373]",
)}
>
{done ? "Company Brain ready" : "Building your Company Brain…"}
</span>
{done ? (
<Button
type="button"
onClick={onContinue}
className={cn(
"ml-auto rounded-full bg-white px-4 py-2 text-[13px] font-semibold text-[#1D1C1D] shadow-[0_4px_24px_rgba(75,160,250,0.25)] hover:bg-white/95",
dmSans125ClassName(),
)}
>
Continue
<ArrowRight className="size-3.5" />
</Button>
) : (
<Loader2 className="size-3.5 animate-spin text-[#4BA0FA] ml-auto" />
)}
</div>
)
}
function ResearchTranscript() {
const { status, events } = useResearchStatus()
const running = status !== "done"
const scrollRef = useRef<HTMLDivElement>(null)
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new events
useEffect(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
})
}, [events.length])
return (
<div className="flex flex-1 min-h-0 flex-col gap-4">
<div
style={cardSurfaceStyle}
className="relative flex min-h-0 flex-col overflow-hidden rounded-[22px] bg-[#1B1F24]"
>
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto px-6 pt-8 pb-8"
>
<Timeline events={events} running={running} />
</div>
{/* Top-only overlay in the card color: text slides up under a solid edge. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-9 rounded-t-[22px]"
style={{
background:
"linear-gradient(to bottom, #1B1F24 0%, rgba(27,31,36,0) 100%)",
}}
/>
</div>
</div>
)
}
function Timeline({
events,
running,
}: {
events: ResearchEvent[]
running: boolean
}) {
if (events.length === 0) {
return (
<div className="flex items-center gap-3 text-[13px] font-medium text-[#737373]">
<Loader2 className="size-4 animate-spin text-[#4BA0FA]" />
Starting deep research
</div>
)
}
return (
<ol className="flex flex-col gap-0">
<AnimatePresence initial={false}>
{events.map((e, i) => {
const isLast = i === events.length - 1
const active = running && e.status === "in_progress"
return (
<motion.li
key={e.aspect}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className="relative flex gap-3.5 pb-6 last:pb-0"
>
<div className="relative flex w-3.5 shrink-0 justify-center">
<EventDot status={e.status} active={active} />
{!isLast && (
<span className="absolute left-1/2 top-[19px] -bottom-[18px] w-px -translate-x-1/2 bg-[#2E353D]" />
)}
</div>
<div className="flex flex-1 flex-col gap-2 -mt-px">
<span
className={cn(
"text-[14px] font-medium leading-[14px]",
e.status === "error"
? "text-[#E5735A]"
: active
? "text-[#fafafa]"
: "text-[#A1A1AA]",
)}
>
{e.label}
</span>
{e.detail && e.status !== "in_progress" && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-[12.5px] leading-[1.6] text-[#737373]"
>
<CitedText text={e.detail} />
</motion.p>
)}
{e.status === "complete" && e.stats.length > 0 && (
<StatStrip stats={e.stats} />
)}
{e.status === "complete" && e.highlights.length > 0 && (
<Chips items={e.highlights} />
)}
{e.status === "complete" && e.sources.length > 0 && (
<SourceChips urls={e.sources} />
)}
{active && <ThinkingLine />}
</div>
</motion.li>
)
})}
</AnimatePresence>
</ol>
)
}
// grok inlines citations as `[[1]](url)` (sometimes consecutive). Render them
// as compact superscript links and collapse any leftover `[n]` bare markers.
const CITE_RE = /\[\[(\d+)\]\]\((https?:\/\/[^)\s]+)\)/g
function CitedText({ text }: { text: string }) {
const nodes: ReactNode[] = []
let last = 0
let m: RegExpExecArray | null
CITE_RE.lastIndex = 0
// biome-ignore lint/suspicious/noAssignInExpressions: regex walk
while ((m = CITE_RE.exec(text)) !== null) {
if (m.index > last) {
nodes.push(cleanBareCites(text.slice(last, m.index)))
}
nodes.push(
<a
key={`${m.index}-${m[1]}`}
href={m[2]}
target="_blank"
rel="noreferrer"
className="mx-px align-super text-[9px] font-semibold text-[#4BA0FA] hover:underline"
>
{m[1]}
</a>,
)
last = m.index + m[0].length
}
if (last < text.length) nodes.push(cleanBareCites(text.slice(last)))
return <>{nodes}</>
}
function cleanBareCites(s: string): string {
return s.replace(/\[\[?\d+\]\]?/g, "").replace(/\s{2,}/g, " ")
}
const THINKING_PHRASES = [
"Searching the web…",
"Reading sources…",
"Cross-checking facts…",
"Summarizing findings…",
]
function ThinkingLine() {
const [i, setI] = useState(0)
useEffect(() => {
const t = setInterval(
() => setI((v) => (v + 1) % THINKING_PHRASES.length),
1600,
)
return () => clearInterval(t)
}, [])
return (
<motion.p
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-[12px] leading-[1.5] text-[#525D6E]"
>
{THINKING_PHRASES[i]}
</motion.p>
)
}
// Boxless stat strip: values over tiny labels, split by hairline dividers.
function StatStrip({ stats }: { stats: ResearchStat[] }) {
const shown = stats.slice(0, 3)
if (!shown.length) return null
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-2.5 flex flex-wrap items-stretch gap-x-5 gap-y-3"
>
{shown.map((s, i) => (
<div
key={`${s.label}-${s.value}`}
className={cn(
"flex flex-col gap-1",
i > 0 && "border-l border-[rgba(82,89,102,0.2)] pl-5",
)}
>
<span className="text-[15px] font-semibold leading-none text-[#fafafa]">
{s.value}
</span>
<span className="text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
{s.label}
</span>
</div>
))}
</motion.div>
)
}
// Quiet outline chips, short entities only (no sentences).
function Chips({ items }: { items: string[] }) {
const shown = items.filter((t) => t.length <= 42).slice(0, 4)
if (!shown.length) return null
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-2.5 flex flex-wrap gap-1.5"
>
{shown.map((t) => (
<span
key={t}
className="rounded-md border border-[rgba(82,89,102,0.22)] px-2 py-[3px] text-[11px] font-medium text-[#8B8B8B]"
>
{t}
</span>
))}
</motion.div>
)
}
function hostname(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "")
} catch {
return url.replace(/^https?:\/\//, "").replace(/\/.*$/, "")
}
}
function SourceFavicon({ host }: { host: string }) {
const sources = [
`https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${host}&size=32`,
`https://icons.duckduckgo.com/ip3/${host}.ico`,
]
const [idx, setIdx] = useState(0)
if (idx >= sources.length) {
return <Globe className="size-3 text-[#525D6E]" />
}
return (
<img
src={sources[idx]}
alt=""
className="size-4 rounded-[3px] object-contain"
onError={() => setIdx((i) => i + 1)}
/>
)
}
// Minimal, deduped source row: favicon + muted domain, no boxes.
function SourceChips({ urls }: { urls: string[] }) {
const seen = new Set<string>()
const hosts: { host: string; url: string }[] = []
for (const url of urls) {
const host = hostname(url)
if (seen.has(host)) continue
seen.add(host)
hosts.push({ host, url })
}
const shown = hosts.slice(0, 4)
const extra = hosts.length - shown.length
if (!shown.length) return null
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-3 flex flex-wrap items-center gap-x-3.5 gap-y-1.5"
>
<span className="text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
Sources
</span>
{shown.map(({ host, url }) => (
<a
key={url}
href={url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[11px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
>
<span className="flex size-4 items-center justify-center">
<SourceFavicon host={host} />
</span>
{host}
</a>
))}
{extra > 0 && (
<span className="text-[11px] font-medium text-[#525D6E]">+{extra}</span>
)}
</motion.div>
)
}
// All dots render in a 14px box so their centers align regardless of shape.
function EventDot({ status, active }: { status: string; active: boolean }) {
if (active) {
return <Loader2 className="size-3.5 shrink-0 animate-spin text-[#4BA0FA]" />
}
if (status === "complete") {
return (
<span className="flex size-3.5 shrink-0 items-center justify-center rounded-full bg-[#4BA0FA]">
<Check className="size-2.5 text-[#05080D]" />
</span>
)
}
return (
<span className="flex size-3.5 shrink-0 items-center justify-center">
<span
className={cn(
"size-2.5 rounded-full",
status === "error" ? "bg-[#E5735A]" : "bg-[#4BA0FA]/60",
)}
/>
</span>
)
}
function Backdrop() {
return (
<>
<div
aria-hidden
className="pointer-events-none absolute inset-0 select-none"
style={{
background:
"radial-gradient(ellipse 80% 60% at 50% 40%, rgba(75,160,250,0.08) 0%, rgba(34,97,202,0.04) 35%, transparent 70%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 select-none"
style={{
backgroundImage:
"radial-gradient(circle at center, rgba(105,167,240,0.22) 1px, transparent 1px)",
backgroundSize: "28px 28px",
maskImage:
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
WebkitMaskImage:
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
}}
/>
</>
)
}

View file

@ -0,0 +1,777 @@
"use client"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import { AnimatePresence, motion } from "motion/react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { ArrowRight, Check, Loader2, Mail, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import {
brainConnectorIcon,
SlackMark,
} from "@/components/brain-connector-icons"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { useResearchStatus } from "@/hooks/use-research-status"
import { dmSans125ClassName } from "@/lib/fonts"
import { cardSurfaceStyle, inputBevelStyle, inputClass } from "./step-about"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const MCP_BASE = `${BACKEND}/brain/mcp-connections`
const FEATURED_SLUGS = ["linear", "granola", "sentry"] as const
const SETUP_STEPS = ["slack", "apps", "invite"] as const
type SetupStepId = (typeof SETUP_STEPS)[number]
const ROTATE_MS = 35_000
const REVEAL_DELAY_MS = 1_800
const tileStyle = {
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
}
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi
type AuthType = "oauth" | "static" | "none"
type CatalogEntry = {
slug: string
name: string
category: string
authType: AuthType
tokenHint?: string
}
type ConnRow = {
serverSlug: string
status: "active" | "pending" | "error"
userId: string | null
}
export type ParallelSetupStats = {
slackConnected: boolean
appsConnected: number
invitesSent: number
}
const STEP_META: Record<SetupStepId, { label: string; hint: string }> = {
slack: {
label: "Add to Slack",
hint: "Ask your company brain in-channel.",
},
apps: {
label: "Connect apps",
hint: "",
},
invite: {
label: "Invite teammates",
hint: "Multiply what the brain remembers.",
},
}
function titleCase(s: string) {
return s.replace(/\b\w/g, (c) => c.toUpperCase())
}
export function ResearchActionRail({
domain,
onStatsChange,
}: {
domain: string
onStatsChange?: (stats: ParallelSetupStats) => void
}) {
const { org } = useAuth()
const { openSettings } = useSettingsModal()
const { events } = useResearchStatus()
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
const [rows, setRows] = useState<ConnRow[]>([])
const [slack, setSlack] = useState<{
connected: boolean
teamName: string | null
} | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const [invites, setInvites] = useState<{ email: string }[]>([])
const [draft, setDraft] = useState("")
const [invitesSent, setInvitesSent] = useState(0)
const [sendingInvites, setSendingInvites] = useState(false)
const [spotlight, setSpotlight] = useState<SetupStepId>("slack")
const [rotationPaused, setRotationPaused] = useState(false)
const [minDelayPassed, setMinDelayPassed] = useState(false)
const pauseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const domainOrFallback = (domain || "your-team.com").trim().toLowerCase()
const load = useCallback(async () => {
try {
const [cat, conn, s] = await Promise.all([
fetch(`${MCP_BASE}/catalog`, { credentials: "include" }),
fetch(`${MCP_BASE}/`, { credentials: "include" }),
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
])
try {
if (cat.ok) {
const data: { catalog?: CatalogEntry[] } = await cat.json()
setCatalog(data.catalog ?? [])
} else setCatalog([])
} catch {
setCatalog([])
}
try {
if (conn.ok) {
const data: { connections?: ConnRow[] } = await conn.json()
setRows(data.connections ?? [])
} else setRows([])
} catch {
setRows([])
}
try {
if (s.ok) setSlack(await s.json())
} catch {}
} catch {
setCatalog([])
setRows([])
}
}, [])
useEffect(() => {
void load()
const onFocus = () => void load()
window.addEventListener("focus", onFocus)
return () => window.removeEventListener("focus", onFocus)
}, [load])
useEffect(() => {
const t = window.setTimeout(() => setMinDelayPassed(true), REVEAL_DELAY_MS)
return () => window.clearTimeout(t)
}, [])
const isConnected = useCallback(
(slug: string) =>
rows.some((r) => r.serverSlug === slug && r.status === "active"),
[rows],
)
const apps = catalog ?? []
const featured = FEATURED_SLUGS.map((slug) =>
apps.find((a) => a.slug === slug),
).filter((a): a is CatalogEntry => Boolean(a))
const appsConnected = apps.filter((a) => isConnected(a.slug)).length
const slackConnected = slack?.connected ?? false
const stepDone = useCallback(
(id: SetupStepId) => {
if (id === "slack") return slackConnected
if (id === "apps") return appsConnected > 0
return invitesSent > 0
},
[slackConnected, appsConnected, invitesSent],
)
const incompleteSteps = useMemo(
() => SETUP_STEPS.filter((id) => !stepDone(id)),
[stepDone],
)
const pauseRotation = useCallback((ms = 60_000) => {
setRotationPaused(true)
if (pauseTimerRef.current) clearTimeout(pauseTimerRef.current)
pauseTimerRef.current = setTimeout(() => setRotationPaused(false), ms)
}, [])
useEffect(() => {
return () => {
if (pauseTimerRef.current) clearTimeout(pauseTimerRef.current)
}
}, [])
useEffect(() => {
if (rotationPaused || incompleteSteps.length === 0) return
if (!incompleteSteps.includes(spotlight)) {
setSpotlight(incompleteSteps[0] ?? "slack")
}
const timer = window.setInterval(() => {
setSpotlight((prev) => {
const pool = SETUP_STEPS.filter((id) => !stepDone(id))
if (pool.length === 0) return prev
const idx = pool.indexOf(prev)
return pool[(idx + 1) % pool.length] ?? pool[0] ?? "slack"
})
}, ROTATE_MS)
return () => window.clearInterval(timer)
}, [rotationPaused, incompleteSteps, spotlight, stepDone])
useEffect(() => {
onStatsChange?.({
slackConnected,
appsConnected,
invitesSent,
})
}, [slackConnected, appsConnected, invitesSent, onStatsChange])
const setupBeatDone = events.some(
(e) => e.aspect === "prepare" && e.status === "complete",
)
const revealed = minDelayPassed && (setupBeatDone || events.length >= 2)
const focusStep = (id: SetupStepId) => {
setSpotlight(id)
pauseRotation()
}
const connect = async (entry: CatalogEntry) => {
pauseRotation()
setBusy(entry.slug)
try {
if (entry.authType === "static") {
const token = window.prompt(
`Paste a token for ${entry.name}.${entry.tokenHint ? `\n${entry.tokenHint}` : ""}`,
)
if (!token) return
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect-static`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token, shared: false }),
})
if (!res.ok) {
toast.error("Couldn't connect.")
return
}
toast.success(`${entry.name} connected.`)
await load()
return
}
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
shared: false,
redirectUrl: window.location.href,
}),
})
if (!res.ok) {
toast.error("Couldn't start the connection.")
return
}
const data: { authUrl?: string; ok?: boolean } = await res.json()
if (data.authUrl) window.open(data.authUrl, "_blank", "noopener")
else if (data.ok) {
toast.success(`${entry.name} connected.`)
await load()
} else toast.error("Couldn't start the connection.")
} catch {
toast.error("Couldn't start the connection.")
} finally {
setBusy(null)
}
}
const addInvites = (text: string) => {
const found = text.match(EMAIL_RE) ?? []
if (found.length === 0) return
const existing = new Set(invites.map((i) => i.email.toLowerCase()))
const next: { email: string }[] = []
for (const raw of found) {
const email = raw.trim().toLowerCase()
if (!email || existing.has(email)) continue
existing.add(email)
next.push({ email })
}
if (next.length === 0) {
setDraft("")
return
}
setInvites((prev) => [...prev, ...next])
setDraft("")
}
const sendInvites = async () => {
if (sendingInvites || invites.length === 0) return
if (!org?.id) {
toast.error("Organization isn't ready yet.")
return
}
setSendingInvites(true)
try {
const results = await Promise.allSettled(
invites.map((inv) =>
authClient.organization.inviteMember({
email: inv.email,
role: "member",
organizationId: org.id,
resend: true,
}),
),
)
const failed = results.filter(
(r) =>
r.status === "rejected" ||
(r.status === "fulfilled" && Boolean(r.value?.error)),
).length
const sent = invites.length - failed
if (sent > 0) {
setInvitesSent((n) => n + sent)
setInvites([])
toast.success(
`Sent ${sent} invite${sent === 1 ? "" : "s"} to your team.`,
)
}
if (failed > 0) {
toast.error(
`${failed} invite${failed === 1 ? "" : "s"} couldn't be sent.`,
)
}
} catch {
toast.error("Couldn't send invites. Try again in a moment.")
} finally {
setSendingInvites(false)
}
}
const doneSummary = (id: SetupStepId) => {
if (id === "slack") return slack?.teamName ?? "Connected"
if (id === "apps") return `${appsConnected} connected`
return `${invitesSent} sent`
}
return (
<div
style={cardSurfaceStyle}
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-[20px] bg-[#161A20]/95"
>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-6">
<p
className={cn(
"text-[13px] font-medium text-[#737373]",
dmSans125ClassName(),
)}
>
While we research
</p>
{!revealed ? (
<ol className="mt-5 flex flex-col opacity-40">
{SETUP_STEPS.map((id, i) => {
const isLast = i === SETUP_STEPS.length - 1
const meta = STEP_META[id]
return (
<li key={id} className="relative flex gap-3.5 pb-5 last:pb-0">
<div className="relative flex w-3.5 shrink-0 justify-center">
<span className="flex size-3.5 shrink-0 items-center justify-center">
<span className="size-2 rounded-full bg-[#4BA0FA]/35" />
</span>
{!isLast && (
<span className="absolute left-1/2 top-[18px] -bottom-[14px] w-px -translate-x-1/2 bg-[#2E353D]/70" />
)}
</div>
<span className="text-[13px] font-medium text-[#525D6E]">
{meta.label}
</span>
</li>
)
})}
</ol>
) : (
<ol className="mt-5 flex flex-col">
{SETUP_STEPS.map((id, i) => {
const done = stepDone(id)
const active = !done && spotlight === id
const isLast = i === SETUP_STEPS.length - 1
const meta = STEP_META[id]
return (
<li key={id} className="relative flex gap-3.5 pb-5 last:pb-0">
<div className="relative flex w-3.5 shrink-0 justify-center">
<SetupDot done={done} active={active} />
{!isLast && (
<span className="absolute left-1/2 top-[18px] -bottom-[14px] w-px -translate-x-1/2 bg-[#2E353D]/70" />
)}
</div>
<div className="min-w-0 flex-1 -mt-px">
<button
type="button"
onClick={() => focusStep(id)}
className="w-full text-left"
>
<span
className={cn(
"text-[13px] font-medium leading-[14px] transition-colors",
active && "text-[#FAFAFA]",
done && "text-[#525D6E]",
!active &&
!done &&
"text-[#737373] hover:text-[#A1A1AA]",
)}
>
{meta.label}
</span>
{done && (
<p className="mt-1 text-[12px] font-medium text-[#525D6E]">
{doneSummary(id)}
</p>
)}
</button>
<AnimatePresence initial={false} mode="popLayout">
{active && (
<motion.div
key={`${id}-body`}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -2 }}
transition={{ duration: 0.18 }}
className="mt-3"
>
{meta.hint ? (
<p className="text-[12px] font-medium leading-[1.5] text-[#525D6E]">
{meta.hint}
</p>
) : null}
<div className={cn(meta.hint ? "mt-3" : "mt-2")}>
{id === "slack" && (
<SlackStepBody
connected={slackConnected}
teamName={slack?.teamName ?? null}
/>
)}
{id === "apps" && (
<AppsStepBody
catalog={catalog}
featured={featured}
isConnected={isConnected}
busy={busy}
onConnect={connect}
onBrowse={() => {
pauseRotation()
openSettings("company-brain")
}}
/>
)}
{id === "invite" && (
<InviteStepBody
domain={domainOrFallback}
draft={draft}
invites={invites}
invitesSent={invitesSent}
sendingInvites={sendingInvites}
onDraftChange={setDraft}
onAddInvites={addInvites}
onRemoveInvite={(email) =>
setInvites((prev) =>
prev.filter((i) => i.email !== email),
)
}
onSendInvites={sendInvites}
onFocus={() => pauseRotation()}
/>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</li>
)
})}
</ol>
)}
</div>
</div>
)
}
function SetupDot({ done, active }: { done: boolean; active: boolean }) {
if (done) {
return (
<span className="flex size-3.5 shrink-0 items-center justify-center rounded-full bg-[#4BA0FA]">
<Check className="size-2.5 text-[#05080D]" strokeWidth={3} />
</span>
)
}
if (active) {
return (
<span className="flex size-3.5 shrink-0 items-center justify-center">
<span className="size-2.5 rounded-full bg-[#4BA0FA]" />
</span>
)
}
return (
<span className="flex size-3.5 shrink-0 items-center justify-center">
<span className="size-2 rounded-full bg-[#4BA0FA]/35" />
</span>
)
}
function SlackStepBody({
connected,
teamName,
}: {
connected: boolean
teamName: string | null
}) {
if (connected) {
return (
<p className="text-[12px] font-medium text-[#525D6E]">
Live in {teamName ?? "your workspace"}
</p>
)
}
return (
<a
href={`${BACKEND}/brain/slack/oauth/install`}
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(),
)}
>
<SlackMark className="size-4" />
Add to Slack
</a>
)
}
function AppsStepBody({
catalog,
featured,
isConnected,
busy,
onConnect,
onBrowse,
}: {
catalog: CatalogEntry[] | null
featured: CatalogEntry[]
isConnected: (slug: string) => boolean
busy: string | null
onConnect: (entry: CatalogEntry) => void
onBrowse: () => void
}) {
if (catalog === null) {
return (
<div className="overflow-hidden rounded-[12px] border border-white/[0.06] bg-[#14161A]/80">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className={cn(
"flex h-12 items-center gap-3 px-3",
i < 2 && "border-b border-white/[0.04]",
)}
>
<div className="size-8 animate-pulse rounded-[8px] bg-[#1c2128]" />
<div className="h-2.5 flex-1 animate-pulse rounded bg-[#1c2128]" />
</div>
))}
</div>
)
}
if (featured.length === 0) {
return (
<button
type="button"
onClick={onBrowse}
className="text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A1A1AA]"
>
Browse connections
</button>
)
}
const allConnected = featured.every((e) => isConnected(e.slug))
return (
<div className="space-y-2.5">
<div
className="overflow-hidden rounded-[12px] border border-white/[0.06] bg-[#14161A]/80"
style={inputBevelStyle}
>
{featured.map((entry, i) => (
<AppTile
key={entry.slug}
icon={brainConnectorIcon(entry.slug, entry.name, "size-4")}
name={entry.name}
subtitle={titleCase(entry.category)}
connected={isConnected(entry.slug)}
busy={busy === entry.slug}
onConnect={() => onConnect(entry)}
showDivider={i < featured.length - 1}
/>
))}
</div>
<button
type="button"
onClick={onBrowse}
className="text-[12px] font-medium text-[#525D6E] transition-colors hover:text-[#A1A1AA]"
>
{allConnected ? "Browse more connections →" : "More connections →"}
</button>
</div>
)
}
function InviteStepBody({
domain,
draft,
invites,
invitesSent,
sendingInvites,
onDraftChange,
onAddInvites,
onRemoveInvite,
onSendInvites,
onFocus,
}: {
domain: string
draft: string
invites: { email: string }[]
invitesSent: number
sendingInvites: boolean
onDraftChange: (v: string) => void
onAddInvites: (text: string) => void
onRemoveInvite: (email: string) => void
onSendInvites: () => void
onFocus: () => void
}) {
return (
<div className="space-y-2.5">
<form
onSubmit={(e) => {
e.preventDefault()
onAddInvites(draft)
}}
className="flex gap-2"
>
<div className="relative flex-1">
<Mail className="size-4 absolute left-3 top-1/2 -translate-y-1/2 text-[#525D6E]" />
<Input
value={draft}
onChange={(e) => onDraftChange(e.target.value)}
onFocus={onFocus}
onPaste={(e) => {
const pasted = e.clipboardData.getData("text")
if (pasted && EMAIL_RE.test(pasted)) {
e.preventDefault()
onAddInvites(pasted)
}
}}
placeholder={`teammate@${domain}`}
className={cn(inputClass, "h-10 pl-9 text-[13px]")}
style={inputBevelStyle}
/>
</div>
<Button
type="submit"
variant="insideOut"
disabled={!draft.trim()}
className="rounded-full size-10 p-0 text-[#fafafa] shrink-0"
>
<Plus className="size-4" />
</Button>
</form>
{invites.length > 0 && (
<div className="space-y-1.5">
{invites.map((inv) => (
<div key={inv.email} className="flex items-center gap-2 py-1">
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[#737373]">
{inv.email}
</span>
<button
type="button"
onClick={() => onRemoveInvite(inv.email)}
className="text-[#525D6E] hover:text-[#A1A1AA] p-0.5"
aria-label={`Remove ${inv.email}`}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
<Button
variant="insideOut"
onClick={onSendInvites}
disabled={sendingInvites}
className="w-full rounded-full h-9 text-[13px] font-medium text-[#fafafa]"
>
{sendingInvites ? (
<>
Sending
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
Send {invites.length} invite
{invites.length === 1 ? "" : "s"}
<ArrowRight className="size-3.5" />
</>
)}
</Button>
</div>
)}
{invitesSent > 0 && invites.length === 0 && (
<p className="text-[12px] font-medium text-[#525D6E]">
{invitesSent} invite{invitesSent === 1 ? "" : "s"} sent
</p>
)}
</div>
)
}
function AppTile({
icon,
name,
subtitle,
connected,
busy,
onConnect,
showDivider = false,
}: {
icon: React.ReactNode
name: string
subtitle: string
connected: boolean
busy: boolean
onConnect: () => void
showDivider?: boolean
}) {
return (
<div
className={cn(
"flex min-h-[48px] items-center gap-2.5 px-3 py-2",
showDivider && "border-b border-white/[0.04]",
)}
>
<div
className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-[8px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
style={tileStyle}
>
{icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-[12px] font-semibold leading-none text-[#E4E4E7]">
{name}
</p>
<p className="mt-0.5 truncate text-[11px] font-medium text-[#525D6E]">
{subtitle}
</p>
</div>
{connected ? (
<Check className="size-3.5 shrink-0 text-[#4BA0FA]" strokeWidth={2.5} />
) : (
<button
type="button"
onClick={onConnect}
disabled={busy}
className={cn(
dmSans125ClassName(),
"flex shrink-0 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.06] px-3 py-1 text-[11px] font-medium text-[#FAFAFA] transition-opacity hover:bg-white/[0.1] disabled:opacity-50",
)}
>
{busy ? <Loader2 className="size-3 animate-spin" /> : "Connect"}
</button>
)}
</div>
)
}

View file

@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { motion } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import { Textarea } from "@ui/components/textarea"
@ -14,14 +14,17 @@ import {
Mail,
Plug,
Terminal,
User2,
UserPlus,
Users2,
Wand2,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import type { BrainMode } from "./types"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
export interface AboutValues {
name: string
about: string
@ -43,18 +46,18 @@ interface Props {
submitting?: boolean
}
const cardSurfaceStyle = {
export const cardSurfaceStyle = {
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}
const inputBevelStyle = {
export const inputBevelStyle = {
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
}
const fieldLabel = "pl-2 pb-2 font-semibold text-[14px] text-[#737373]"
const inputClass =
export const fieldLabel = "pl-2 pb-2 font-semibold text-[14px] text-[#737373]"
export const inputClass =
"bg-[#0F1217] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#525D6E] h-12 px-4 shadow-none focus-visible:ring-0 focus-visible:border-[rgba(115,115,115,0.3)] transition-colors"
export function StepAbout({
@ -84,36 +87,115 @@ export function StepAbout({
}, [defaultName, suggestedWorkspaceName, domain])
const teamGated = mode === "team" && !allowTeam
const isTeam = mode === "team" && !teamGated
// Default the workspace name per mode: name-derived for Personal (field hidden),
// email-derived for Team unless the user has typed their own.
const workspaceTouched = useRef(false)
// biome-ignore lint/correctness/useExhaustiveDependencies: derive on mode/name changes only
useEffect(() => {
if (mode === "personal") {
const first = values.name.trim().split(/\s+/)[0]
const derived = first
? `${first}'s Brain`
: suggestedWorkspaceName || "My brain"
if (values.workspaceName !== derived) {
onChange({ ...values, workspaceName: derived })
}
} else if (!workspaceTouched.current) {
const derived = suggestedWorkspaceName || ""
if (values.workspaceName !== derived) {
onChange({ ...values, workspaceName: derived })
}
}
}, [mode, values.name, suggestedWorkspaceName])
// Auto-draft the "what does your company do" blurb from the domain.
const valuesRef = useRef(values)
valuesRef.current = values
const aboutTouched = useRef(false)
const summarizedDomain = useRef<string | null>(null)
const latestDraftDomain = useRef<string | null>(null)
const [drafting, setDrafting] = useState(false)
const [drafted, setDrafted] = useState(false)
const draftCompany = async (rawDomain: string) => {
const d = rawDomain.trim().toLowerCase()
if (!d || summarizedDomain.current === d) return
if (aboutTouched.current && valuesRef.current.about.trim()) return
summarizedDomain.current = d
latestDraftDomain.current = d
setDrafting(true)
try {
const res = await fetch(`${BACKEND}/brain/company-summary`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ domain: d }),
})
// Ignore a stale response if the domain changed mid-flight.
if (latestDraftDomain.current !== d) return
if (!res.ok) return
const data = (await res.json()) as { summary?: string | null }
if (
data.summary &&
!aboutTouched.current &&
latestDraftDomain.current === d
) {
onChange({ ...valuesRef.current, about: data.summary })
setDrafted(true)
}
} catch {
if (latestDraftDomain.current === d) summarizedDomain.current = null
} finally {
if (latestDraftDomain.current === d) setDrafting(false)
}
}
// Draft once when entering Team with a domain already filled (e.g. from email).
// biome-ignore lint/correctness/useExhaustiveDependencies: run on team-entry only
useEffect(() => {
if (!isTeam) return
const d = (values.workspaceDomain || domain || "").trim()
if (d && !values.about.trim()) draftCompany(d)
}, [isTeam])
const canContinue =
!teamGated &&
values.name.trim().length > 0 &&
values.workspaceName.trim().length > 0
return (
<div className="space-y-5">
<div className="grid md:grid-cols-[1.6fr_1fr] gap-4 items-stretch">
<section
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-7"
style={cardSurfaceStyle}
>
<div className="max-w-xl mx-auto space-y-5">
<section
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-8"
style={cardSurfaceStyle}
>
<ModeToggle mode={mode} onChange={onModeChange} />
<div className="mt-7 flex items-center gap-4">
<UserAvatar
url={avatarUrl}
name={values.name || defaultName}
className="size-14 mb-5"
className="size-12 shrink-0"
/>
<p
className={cn(
"font-semibold text-[#fafafa] text-[20px]",
dmSans125ClassName(),
)}
>
Tell us about you
</p>
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
So your brain sounds like yours, not the docs.
</p>
<div className="min-w-0">
<p
className={cn(
"font-semibold text-[#fafafa] text-[20px]",
dmSans125ClassName(),
)}
>
Tell us about you
</p>
<p className="text-[#737373] font-medium text-[14px] leading-[1.4] mt-0.5">
So your brain sounds like yours, not the docs.
</p>
</div>
</div>
<div className="mt-6 space-y-5">
<div className="mt-6 space-y-4">
<div className={cn("grid gap-4", isTeam && "sm:grid-cols-2")}>
<div>
<p className={fieldLabel}>Your name</p>
<Input
@ -125,54 +207,147 @@ export function StepAbout({
/>
</div>
{isTeam && (
<div>
<p className={fieldLabel}>Workspace name</p>
<Input
value={values.workspaceName}
onChange={(e) => {
workspaceTouched.current = true
onChange({ ...values, workspaceName: e.target.value })
}}
placeholder={suggestedWorkspaceName || "Acme"}
className={inputClass}
style={inputBevelStyle}
/>
</div>
)}
</div>
{/* Company domain slides in only for Team mode. */}
<AnimatePresence initial={false}>
{isTeam && (
<motion.div
key="domain"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="overflow-hidden"
>
<p className={fieldLabel}>Company domain</p>
<div className="relative">
<div
className="absolute left-1.5 top-1/2 -translate-y-1/2 size-9 rounded-[8px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden"
style={inputBevelStyle}
>
{values.workspaceDomain || domain ? (
<DomainLogo
domain={values.workspaceDomain || domain || ""}
/>
) : (
<Building2 className="size-4 text-[#737373]" />
)}
</div>
<Input
value={values.workspaceDomain}
onChange={(e) =>
onChange({ ...values, workspaceDomain: e.target.value })
}
onBlur={(e) => {
const d = e.target.value.trim()
if (d !== values.workspaceDomain) {
onChange({ ...values, workspaceDomain: d })
}
draftCompany(d)
}}
placeholder="your-team.com"
className={cn(inputClass, "pl-14")}
style={inputBevelStyle}
/>
</div>
</motion.div>
)}
</AnimatePresence>
{!teamGated && (
<div>
<p className={fieldLabel}>
What are you here for?{" "}
<span className="text-[#525D6E] font-medium">(optional)</span>
</p>
<div className="mb-2 flex items-center justify-between gap-2 pl-2">
<p className="font-semibold text-[14px] text-[#737373]">
{isTeam ? (
"What does your company do?"
) : (
<>
What are you here for?{" "}
<span className="text-[#525D6E] font-medium">
(optional)
</span>
</>
)}
</p>
{isTeam &&
(drafting ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-medium text-[#737373]">
<Loader2 className="size-3 animate-spin" />
Drafting from your site
</span>
) : drafted ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-medium text-[#525D6E]">
<Wand2 className="size-3" />
Drafted from your site · edit anything
</span>
) : null)}
</div>
<Textarea
value={values.about}
onChange={(e) => onChange({ ...values, about: e.target.value })}
placeholder="A sentence or two — what you do, what you're hoping the brain helps with."
rows={4}
value={drafting ? "" : values.about}
onChange={(e) => {
aboutTouched.current = true
setDrafted(false)
onChange({ ...values, about: e.target.value })
}}
placeholder={
drafting
? ""
: isTeam
? "A sentence or two — what your company builds, who you serve, how the team works."
: "A sentence or two — what you do, what you're hoping the brain helps with."
}
rows={3}
disabled={drafting}
className={cn(
inputClass,
"h-auto resize-none py-3 leading-[1.5]",
drafting && "opacity-60",
)}
style={inputBevelStyle}
/>
</div>
</div>
</section>
)}
</div>
<section
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-7 flex flex-col"
style={cardSurfaceStyle}
>
<ModeToggle mode={mode} onChange={onModeChange} />
<div className="mt-6">
{teamGated ? (
<TeamBetaGate onUsePersonal={() => onModeChange("personal")} />
) : mode === "team" ? (
<TeamWorkspaceCard
domain={values.workspaceDomain || domain || ""}
onDomainChange={(d) =>
onChange({ ...values, workspaceDomain: d })
}
value={values.workspaceName}
onChange={(w) => onChange({ ...values, workspaceName: w })}
suggested={suggestedWorkspaceName}
/>
) : (
<PersonalWorkspaceCard
value={values.workspaceName}
onChange={(w) => onChange({ ...values, workspaceName: w })}
/>
)}
</div>
</section>
</div>
{teamGated ? (
<motion.div
key="gate"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="mt-6"
>
<TeamBetaGate onUsePersonal={() => onModeChange("personal")} />
</motion.div>
) : (
<motion.div
key={`perks-${mode}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
<PerksFooter
perks={mode === "team" ? TEAM_PERKS : PERSONAL_PERKS}
/>
</motion.div>
)}
</section>
<div className="flex items-center justify-end gap-[22px] px-1">
<Button
@ -198,90 +373,43 @@ export function StepAbout({
)
}
function TeamWorkspaceCard({
domain,
onDomainChange,
value,
onChange,
suggested,
}: {
domain: string
onDomainChange: (d: string) => void
value: string
onChange: (v: string) => void
suggested: string
}) {
return (
<>
<div className="flex items-center gap-3">
<div
className="size-12 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center overflow-hidden shrink-0"
style={inputBevelStyle}
>
{domain ? (
<DomainLogo domain={domain} />
) : (
<Building2 className="size-5 text-[#737373]" />
)}
</div>
<div className="min-w-0 flex-1">
<input
value={domain}
onChange={(e) => onDomainChange(e.target.value.trim())}
placeholder="your-team.com"
className="w-full bg-transparent text-[18px] text-[#fafafa] font-semibold leading-tight outline-none border-b border-transparent hover:border-[rgba(115,115,115,0.2)] focus:border-[rgba(115,115,115,0.4)] transition-colors px-0 py-0.5"
/>
<div className="text-[12px] text-[#737373] font-medium mt-0.5">
Team workspace
</div>
</div>
</div>
const PERSONAL_PERKS: Perk[] = [
{
icon: <Brain className="size-4 text-[#8B8B8B]" />,
title: "Your own brain",
blurb: "Notes, docs, bookmarks — all searchable in one place.",
},
{
icon: <Plug className="size-4 text-[#8B8B8B]" />,
title: "Plug into your AI tools",
blurb: "Claude, Cursor, ChatGPT — your context, everywhere.",
},
{
icon: <Users2 className="size-4 text-[#8B8B8B]" />,
title: "Switch to a team anytime",
blurb: "Invite teammates whenever you're ready.",
},
]
<div className="mt-6">
<p className={fieldLabel}>
Workspace name{" "}
<span className="text-[#525D6E] font-medium">
(rename if you'd like)
</span>
</p>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={suggested || "Acme"}
className={inputClass}
style={inputBevelStyle}
/>
</div>
const TEAM_PERKS: Perk[] = [
{
icon: <UserPlus className="size-4 text-[#8B8B8B]" />,
title: "Invite teammates",
blurb: "Everyone contributes to the same brain.",
},
{
icon: <Terminal className="size-4 text-[#8B8B8B]" />,
title: "Shared coding agent context",
blurb: "Claude, Cursor, MCP — same brain across the team.",
},
{
icon: <LayoutGrid className="size-4 text-[#8B8B8B]" />,
title: "Org-wide spaces",
blurb: "Carve out sales, eng, design with their own access.",
},
]
<PerksList
heading="What this unlocks"
perks={[
{
icon: <UserPlus className="size-4 text-[#8B8B8B]" />,
title: "Invite teammates",
blurb: "Everyone contributes to the same brain.",
},
{
icon: <Terminal className="size-4 text-[#8B8B8B]" />,
title: "Shared coding agent context",
blurb: "Claude, Cursor, MCP — same brain across the team.",
},
{
icon: <LayoutGrid className="size-4 text-[#8B8B8B]" />,
title: "Org-wide spaces",
blurb: "Carve out sales, eng, design with their own access.",
},
]}
/>
<p className="text-[12px] text-[#525D6E] mt-auto pt-5 leading-[1.5] font-medium">
Not your team? Switch above.
</p>
</>
)
}
function DomainLogo({ domain }: { domain: string }) {
export function DomainLogo({ domain }: { domain: string }) {
const sources = [
`https://logo.clearbit.com/${domain}`,
`https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${domain}&size=64`,
@ -301,71 +429,6 @@ function DomainLogo({ domain }: { domain: string }) {
)
}
function PersonalWorkspaceCard({
value,
onChange,
}: {
value: string
onChange: (v: string) => void
}) {
return (
<>
<div className="flex items-center gap-3">
<div
className="size-12 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center"
style={inputBevelStyle}
>
<User2 className="size-5 text-[#737373]" />
</div>
<div>
<div className="text-[18px] text-[#fafafa] font-semibold">
Just you, for now
</div>
<div className="text-[12px] text-[#737373] font-medium mt-0.5">
Personal workspace
</div>
</div>
</div>
<div className="mt-6">
<p className={fieldLabel}>Workspace nickname</p>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="My brain"
className={inputClass}
style={inputBevelStyle}
/>
</div>
<PerksList
heading="What this unlocks"
perks={[
{
icon: <Brain className="size-4 text-[#8B8B8B]" />,
title: "Your own brain",
blurb: "Notes, docs, bookmarks — all searchable in one place.",
},
{
icon: <Plug className="size-4 text-[#8B8B8B]" />,
title: "Plug into your AI tools",
blurb: "Claude, Cursor, ChatGPT — your context, everywhere.",
},
{
icon: <Users2 className="size-4 text-[#8B8B8B]" />,
title: "Switch to a team anytime",
blurb: "Invite teammates whenever you're ready.",
},
]}
/>
<p className="text-[12px] text-[#525D6E] mt-auto pt-5 leading-[1.5] font-medium">
Working with a team? Switch above.
</p>
</>
)
}
function TeamBetaGate({ onUsePersonal }: { onUsePersonal: () => void }) {
return (
<div
@ -435,6 +498,13 @@ function ModeToggle({
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<motion.span
aria-hidden
className="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full"
style={{ background: "#00173C", border: "1px solid #2261CA66" }}
animate={{ x: mode === "team" ? "100%" : "0%" }}
transition={{ type: "spring", stiffness: 420, damping: 38, mass: 0.8 }}
/>
{items.map((item) => {
const isActive = mode === item.id
return (
@ -443,28 +513,13 @@ function ModeToggle({
type="button"
onClick={() => onChange(item.id)}
className={cn(
"relative h-8 rounded-full text-center transition-colors",
"relative z-10 h-8 rounded-full text-center transition-colors duration-200",
isActive
? "text-[#fafafa]"
: "text-[#737373] hover:text-[#fafafa]",
)}
>
{isActive && (
<motion.span
layoutId="brain-mode-pill"
className="absolute inset-0 rounded-full"
style={{
background: "#00173C",
border: "1px solid #2261CA66",
}}
transition={{
type: "spring",
stiffness: 380,
damping: 34,
}}
/>
)}
<span className="relative z-10">{item.label}</span>
{item.label}
</button>
)
})}
@ -472,7 +527,7 @@ function ModeToggle({
)
}
function UserAvatar({
export function UserAvatar({
url,
name,
className,
@ -511,22 +566,18 @@ function UserAvatar({
type Perk = { icon: React.ReactNode; title: string; blurb: string }
function PerksList({ heading, perks }: { heading: string; perks: Perk[] }) {
function PerksFooter({ perks }: { perks: Perk[] }) {
return (
<div className="mt-6">
<p className="text-[10px] uppercase tracking-[0.08em] text-[#737373] font-semibold mb-3 pl-1">
{heading}
</p>
<ul className="space-y-2.5">
{perks.map((p) => (
<li key={p.title} className="flex items-center gap-2.5 pl-1">
<span className="shrink-0">{p.icon}</span>
<span className="text-[13px] text-[#fafafa] font-medium">
{p.title}
</span>
</li>
))}
</ul>
<div className="mt-7 flex flex-wrap items-center justify-center gap-x-4 gap-y-2">
{perks.map((p) => (
<span
key={p.title}
className="inline-flex items-center gap-1.5 text-[12px] text-[#A1A1AA] font-medium"
>
<span className="shrink-0">{p.icon}</span>
{p.title}
</span>
))}
</div>
)
}

View file

@ -38,6 +38,8 @@ type FlowTool = {
}
const TOOL_OPTIONS: Record<BrainMode, [FlowTool, ...FlowTool[]]> = {
// Team/company-brain onboarding is focused: just get Supermemory into Slack.
// Coding agents live under "More tools (full catalog)".
team: [
{
id: "slack",
@ -46,20 +48,6 @@ const TOOL_OPTIONS: Record<BrainMode, [FlowTool, ...FlowTool[]]> = {
kind: "slack",
recommended: true,
},
{
id: "claude-code",
label: "Claude Code",
blurb: "Shared context in your terminal.",
kind: "plugin",
pluginId: "claude_code",
},
{
id: "codex",
label: "Codex",
blurb: "OpenAI's coding agent.",
kind: "plugin",
pluginId: "codex",
},
],
personal: [
{
@ -126,8 +114,8 @@ export function StepIngest({ mode, mcpUrl, onContinue }: Props) {
}
return (
<div className="mx-auto w-full max-w-[900px] pb-10">
<section className="relative py-4">
<div className="mx-auto w-full max-w-[680px] pb-10">
<section className="py-4">
<div className="mb-6 px-1">
<p
className={cn(
@ -139,80 +127,76 @@ export function StepIngest({ mode, mcpUrl, onContinue }: Props) {
</p>
<p className="mt-1.5 text-[15px] font-medium leading-[1.4] text-[#737373]">
{mode === "team"
? "Pick where your team asks questions, then set it up."
? "Add Supermemory to Slack so your team can ask in any channel."
: "Pick the tool you open every day — about 60 seconds."}
</p>
</div>
<div className="grid items-start gap-4 lg:grid-cols-[250px_minmax(0,1fr)]">
{/* Left rail: pick a tool */}
<div className="flex flex-col gap-2">
{tools.map((tool) => (
<FlowToolRow
key={tool.id}
tool={tool}
active={selected === tool.id}
onSelect={() => {
analytics.onboardingAgentSelected({ agent: tool.id })
setSelected(tool.id)
<div
className="rounded-[22px] bg-[#1B1F24] p-6 md:p-7"
style={modalCardStyle}
>
{tools.length > 1 ? (
<>
<FlowTabs
tools={tools}
selected={selected}
onSelect={(id) => {
analytics.onboardingAgentSelected({ agent: id })
setSelected(id)
}}
/>
))}
<Link
href="/settings/integrations"
className="mt-1 inline-flex items-center gap-1.5 px-2 py-1.5 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
>
More tools
<span className="text-[#525D6E]">(full catalog)</span>
<ExternalLink className="size-3.5" aria-hidden />
</Link>
</div>
{/* Right pane: setup detail */}
<div
className="relative flex min-h-[300px] flex-col overflow-hidden rounded-[22px] bg-[#1B1F24] p-6 md:p-7"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px right-10 left-10 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.4), transparent)",
}}
/>
<div className="flex flex-1 flex-col">
<div className="mb-5 flex items-center gap-3">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
style={inputBevelStyle}
>
<ToolIcon id={activeTool.id} />
</div>
<div>
<p className="text-[18px] font-semibold text-[#fafafa]">
Set up {activeTool.label}
</p>
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
{activeTool.blurb}
</p>
</div>
<p className="mt-5 mb-4 px-1 text-[13px] font-medium text-[#737373]">
{activeTool.blurb}
</p>
</>
) : (
<div className="mb-5 flex items-center gap-3 px-1">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
style={inputBevelStyle}
>
<ToolIcon id={activeTool.id} />
</div>
<div>
<p className="text-[16px] font-semibold text-[#fafafa]">
Set up {activeTool.label}
</p>
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
{activeTool.blurb}
</p>
</div>
{activeTool.kind === "slack" ? (
<SlackSetupPanel />
) : activeTool.kind === "mcp" ? (
<McpGenericSetup mcpUrl={mcpUrl} />
) : activeTool.pluginId ? (
<PluginSetup
key={activeTool.pluginId}
pluginId={activeTool.pluginId}
/>
) : null}
</div>
)}
<div className="mt-6 flex items-center justify-end gap-[22px] border-t border-white/[0.06] pt-5">
{activeTool.kind === "slack" ? (
<SlackSetupPanel />
) : activeTool.kind === "mcp" ? (
<McpGenericSetup mcpUrl={mcpUrl} />
) : activeTool.pluginId ? (
<PluginSetup
key={activeTool.pluginId}
pluginId={activeTool.pluginId}
/>
) : null}
<div
className={cn(
"mt-6 flex items-center gap-3 border-t border-white/[0.06] pt-5",
tools.length > 1 ? "justify-between" : "justify-end",
)}
>
{tools.length > 1 && (
<Link
href="/settings/integrations"
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
>
More tools
<span className="text-[#525D6E]">(full catalog)</span>
<ExternalLink className="size-3.5" aria-hidden />
</Link>
)}
<div className="flex items-center gap-[22px]">
<button
type="button"
onClick={handleSkip}
@ -253,77 +237,47 @@ function ToolIcon({ id, className }: { id: FlowToolId; className?: string }) {
)
}
function FlowToolRow({
tool,
active,
function FlowTabs({
tools,
selected,
onSelect,
}: {
tool: FlowTool
active: boolean
onSelect: () => void
tools: readonly FlowTool[]
selected: FlowToolId
onSelect: (id: FlowToolId) => void
}) {
return (
<button
type="button"
onClick={onSelect}
aria-pressed={active}
className={cn(
"group relative flex w-full items-center gap-3 overflow-hidden rounded-[14px] p-3 text-left transition-all duration-150",
active
? "bg-[#10151D] ring-2 ring-[#4BA0FA]/45"
: "bg-[#1B1F24] ring-1 ring-white/[0.05] hover:ring-white/[0.12]",
)}
style={modalCardStyle}
<div
className="grid gap-1 rounded-[14px] border border-[rgba(115,115,115,0.2)] bg-[#0D121A] p-1"
style={{
gridTemplateColumns: `repeat(${tools.length}, minmax(0,1fr))`,
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
{active && (
<div
aria-hidden
className="absolute -top-px right-5 left-5 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.55), transparent)",
}}
/>
)}
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-[10px] border bg-[#14161A] transition-colors",
active ? "border-[#2261CA]/45" : "border-[rgba(82,89,102,0.2)]",
)}
style={inputBevelStyle}
>
<ToolIcon id={tool.id} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-[14px] font-semibold leading-tight text-[#fafafa]">
{tool.label}
</p>
{tool.recommended && (
<span className="shrink-0 rounded-full bg-[#4BA0FA]/12 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[#4BA0FA]">
Recommended
</span>
)}
</div>
<p className="mt-0.5 truncate text-[12px] font-medium text-[#737373]">
{tool.blurb}
</p>
</div>
<span
aria-hidden
className={cn(
"flex size-[18px] shrink-0 items-center justify-center rounded-full border transition-colors",
active
? "border-[#4BA0FA] bg-[#4BA0FA]"
: "border-[rgba(82,89,102,0.4)] group-hover:border-[rgba(115,115,115,0.5)]",
)}
>
{active && <Check className="size-3 text-white" />}
</span>
</button>
{tools.map((tool) => {
const active = tool.id === selected
return (
<button
key={tool.id}
type="button"
onClick={() => onSelect(tool.id)}
aria-pressed={active}
className={cn(
"relative flex h-10 items-center justify-center gap-2 rounded-[10px] border border-transparent px-2 text-[13px] font-medium transition-colors",
active ? "text-[#fafafa]" : "text-[#737373] hover:text-[#fafafa]",
)}
style={
active
? { background: "#00173C", borderColor: "#2261CA66" }
: undefined
}
>
<ToolIcon id={tool.id} className="size-4 shrink-0" />
<span className="truncate">{tool.label}</span>
</button>
)
})}
</div>
)
}
@ -332,26 +286,36 @@ function StepRow({
title,
done,
children,
last,
}: {
index: number
title: ReactNode
done?: boolean
children?: ReactNode
last?: boolean
}) {
return (
<div className="flex gap-3">
<span
aria-hidden
className={cn(
"mt-0.5 flex size-[22px] shrink-0 items-center justify-center rounded-full text-[12px] font-semibold transition-colors",
done
? "bg-[#4BA0FA] text-white"
: "border border-[rgba(82,89,102,0.3)] bg-[#14161A] text-[#737373]",
<div className="flex flex-col items-center">
<span
aria-hidden
className={cn(
"flex size-[22px] shrink-0 items-center justify-center rounded-full text-[12px] font-semibold transition-colors",
done
? "bg-[#4BA0FA] text-white"
: "border border-[rgba(82,89,102,0.3)] bg-[#14161A] text-[#737373]",
)}
>
{done ? <Check className="size-3" /> : index}
</span>
{!last && (
<span
aria-hidden
className="mt-1.5 w-px flex-1 bg-[rgba(82,89,102,0.3)]"
/>
)}
>
{done ? <Check className="size-3" /> : index}
</span>
<div className="min-w-0 flex-1 pt-0.5">
</div>
<div className={cn("min-w-0 flex-1 pt-0.5", last ? "pb-0" : "pb-6")}>
<div className="text-[13px] font-medium leading-[1.5] text-[#fafafa]">
{title}
</div>
@ -370,7 +334,7 @@ function PluginSetup({ pluginId }: { pluginId: string }) {
)
return (
<div className="space-y-4">
<div>
{steps.map((step, i) => (
<StepRow key={step.title} index={i + 1} title={step.title}>
{step.description ? (
@ -381,7 +345,7 @@ function PluginSetup({ pluginId }: { pluginId: string }) {
{step.code ? <CopyCodeBlock code={step.code} /> : null}
</StepRow>
))}
<StepRow index={steps.length + 1} title="Ask your brain to test it">
<StepRow index={steps.length + 1} last title="Ask your brain to test it">
<CopyCodeBlock code={TEST_PROMPT} />
</StepRow>
</div>
@ -390,7 +354,7 @@ function PluginSetup({ pluginId }: { pluginId: string }) {
function McpGenericSetup({ mcpUrl }: { mcpUrl: string }) {
return (
<div className="space-y-4">
<div>
<StepRow index={1} title="Copy your universal MCP URL">
<McpUrlRow url={mcpUrl} />
</StepRow>
@ -403,7 +367,7 @@ function McpGenericSetup({ mcpUrl }: { mcpUrl: string }) {
<ExternalLink className="size-3.5" aria-hidden />
</Link>
</StepRow>
<StepRow index={3} title="Ask your brain to test it">
<StepRow index={3} last title="Ask your brain to test it">
<CopyCodeBlock code={TEST_PROMPT} />
</StepRow>
</div>
@ -444,35 +408,36 @@ function SlackSetupPanel() {
const connected = status?.connected ?? false
return (
<div className="space-y-4">
<div>
<StepRow
index={1}
done={connected}
title={
connected
? `Connected to ${status?.teamName ?? "your workspace"}`
: "Add Supermemory to your Slack workspace"
}
>
{!connected &&
(loading ? (
<span className="inline-flex items-center gap-2 text-[12px] font-medium text-[#737373]">
<Loader2 className="size-3.5 animate-spin" />
Checking
</span>
connected ? (
`Connected to ${status?.teamName ?? "your workspace"}`
) : (
<Button
variant="insideOut"
asChild
className="h-9 gap-2 rounded-full px-4 text-[13px] font-medium text-[#fafafa]"
>
<a href={`${BACKEND}/brain/slack/oauth/install`}>
<SlackMark className="size-4" />
Add to Slack
</a>
</Button>
))}
</StepRow>
<div className="flex items-start justify-between gap-3">
<span className="pt-1">
Add Supermemory to your Slack workspace
</span>
{loading ? (
<span className="inline-flex shrink-0 items-center gap-2 text-[12px] font-medium text-[#737373]">
<Loader2 className="size-3.5 animate-spin" />
Checking
</span>
) : (
<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]"
>
<SlackMark className="size-4" />
Add to Slack
</a>
)}
</div>
)
}
/>
<StepRow
index={2}
title={
@ -482,7 +447,7 @@ function SlackSetupPanel() {
</>
}
/>
<StepRow index={3} title="Ask your brain to test it">
<StepRow index={3} last title="Ask your brain to test it">
<CopyCodeBlock code="@supermemory what do we know about [topic]?" />
</StepRow>
</div>

View file

@ -1,11 +1,12 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { useQueryState } from "nuqs"
import Image from "next/image"
import { Button } from "@ui/components/button"
import { Dialog, DialogClose, DialogContent } from "@ui/components/dialog"
import { GoogleDrive, Granola, Notion, OneDrive } from "@ui/assets/icons"
import { Logo } from "@ui/assets/Logo"
import {
Select,
SelectContent,
@ -98,9 +99,11 @@ import {
type PlanType,
useTokenUsage,
} from "@/hooks/use-token-usage"
import { GranolaConnectModal } from "@/components/granola-connect-modal"
import { dmSans125ClassName } from "@/lib/fonts"
import { $fetch } from "@lib/api"
import { hasActivePlan } from "@lib/queries"
import { useAuth } from "@lib/auth-context"
import { useConnectorAccess } from "@/hooks/use-connector-access"
import {
ADD_MEMORY_SHORTCUT_URL,
CHROME_EXTENSION_URL,
@ -117,6 +120,7 @@ type SourceId =
| "gmail"
| "github"
| "onedrive"
| "granola"
| "bookmarks"
| "chatapps"
| "chrome"
@ -126,6 +130,20 @@ type SourceState = "idle" | "connecting" | "connected" | "waitlist"
type DriveScope = "selective" | "full"
type RequiredPlan = "pro" | "max"
const PROVIDER_TO_SOURCE: Record<string, SourceId> = {
"google-drive": "drive",
notion: "notion",
onedrive: "onedrive",
granola: "granola",
}
const SOURCE_LABEL: Partial<Record<SourceId, string>> = {
drive: "Google Drive",
notion: "Notion",
onedrive: "OneDrive",
granola: "Granola",
}
const PLAN_LABELS: Record<RequiredPlan, string> = {
pro: "Pro",
max: "Max",
@ -224,7 +242,6 @@ export interface SourcesValues {
interface Props {
containerTag: string
workspaceName: string
mode: BrainMode
values: SourcesValues
onChange: (next: SourcesValues) => void
@ -281,7 +298,6 @@ function ChatAppsIconCluster() {
export function StepSources({
containerTag,
workspaceName,
mode,
values,
onChange,
@ -289,20 +305,99 @@ export function StepSources({
}: Props) {
const [moreOpen, setMoreOpen] = useState(false)
const [plansOpen, setPlansOpen] = useState(false)
const [granolaOpen, setGranolaOpen] = useState(false)
const [requestedPlan, setRequestedPlan] = useState<RequiredPlan>("pro")
const [requestedConnector, setRequestedConnector] = useState("This connector")
const autumn = useCustomer()
const hasPro = hasActivePlan(autumn.data?.subscriptions, "api_pro")
const hasMax = hasActivePlan(autumn.data?.subscriptions, "api_max")
const planLoading = autumn.isLoading
const { hasMax, connectorAccess, loading: planLoading } = useConnectorAccess()
const { org, isRestoring } = useAuth()
useEffect(() => {
setMoreOpen(false)
}, [])
const valuesRef = useRef(values)
valuesRef.current = values
// dedupe toasts across the param + reconcile paths
const announced = useRef(new Set<SourceId>())
const seeded = useRef(false)
const markConnected = (ids: SourceId[]) => {
const current = valuesRef.current
const updates: Partial<Record<SourceId, SourceState>> = {}
for (const id of ids) {
if (current.connected[id] !== "connected") updates[id] = "connected"
}
if (Object.keys(updates).length > 0) {
onChange({
...current,
connected: { ...current.connected, ...updates },
})
}
}
// keyed by org so it re-runs once the active org restores on reload
const { data: liveConnections, refetch: refetchConnections } = useQuery({
queryKey: ["onboarding-connections", org?.id],
queryFn: async () => {
const res = await $fetch("@post/connections/list", {
body: { containerTags: [] },
})
if (res.error) return [] as Array<{ provider?: string }>
return (res.data ?? []) as Array<{ provider?: string }>
},
enabled: !isRestoring && !!org?.id,
staleTime: 10_000,
refetchOnWindowFocus: true,
})
// biome-ignore lint/correctness/useExhaustiveDependencies: reconcile on fetched connections only
useEffect(() => {
if (!liveConnections) return
const ids = liveConnections
.map((c) => (c.provider ? PROVIDER_TO_SOURCE[c.provider] : undefined))
.filter((id): id is SourceId => Boolean(id))
markConnected(ids)
// first load: seed without toasting pre-existing connections
if (!seeded.current) {
seeded.current = true
for (const id of ids) announced.current.add(id)
return
}
for (const id of ids) {
if (!announced.current.has(id)) {
announced.current.add(id)
toast.success(`${SOURCE_LABEL[id] ?? "Source"} connected`)
}
}
}, [liveConnections])
// Post-OAuth redirect lands with ?connected=<provider> — confirm it instantly.
const [connectedParam, setConnectedParam] = useQueryState("connected")
// biome-ignore lint/correctness/useExhaustiveDependencies: run when the param arrives
useEffect(() => {
if (!connectedParam) return
const id = PROVIDER_TO_SOURCE[connectedParam]
if (id) {
markConnected([id])
if (!announced.current.has(id)) {
announced.current.add(id)
toast.success(`${SOURCE_LABEL[id] ?? "Source"} connected`)
}
}
setConnectedParam(null)
const t1 = setTimeout(() => void refetchConnections(), 1500)
const t2 = setTimeout(() => void refetchConnections(), 4000)
return () => {
clearTimeout(t1)
clearTimeout(t2)
}
}, [connectedParam])
// company_brain unlocks pro connectors; max stays gated
const isLocked = (plan?: RequiredPlan) => {
if (!plan || planLoading) return false
return plan === "max" ? !hasMax : !hasPro
if (plan === "max") return !hasMax
return !connectorAccess
}
const setState = (id: SourceId, state: SourceState) => {
@ -382,71 +477,91 @@ export function StepSources({
<div className="mx-auto w-full max-w-[1400px] pb-10">
<section className="relative min-h-[calc(100dvh-136px)] py-4">
<div className="absolute inset-x-0 top-[46%] -translate-y-1/2">
<div className="flex flex-wrap items-end justify-between gap-3 mb-6 px-1">
<div>
<p
className={cn(
"font-semibold text-[#fafafa] text-[22px]",
dmSans125ClassName(),
)}
>
{mode === "personal"
? "Bring your context together"
: "Connect your team's signals"}
</p>
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
Start with the sources that carry the most context. Add more
anytime.
</p>
</div>
<RoutingChip workspaceName={workspaceName} />
<div className="mb-6 px-1">
<p
className={cn(
"font-semibold text-[#fafafa] text-[22px]",
dmSans125ClassName(),
)}
>
{mode === "personal"
? "Bring your context together"
: "Connect your team's signals"}
</p>
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
Start with the sources that carry the most context. Add more
anytime.
</p>
</div>
<div className="grid md:grid-cols-3 gap-4">
<SourceCard
title="Import bookmarks"
blurb="One-shot import of your saved tweets."
icon={<XBookmarksIcon className="size-6 text-[#fafafa]" />}
state={values.connected.bookmarks ?? "idle"}
ctaLabel="Connect"
doneLabel="Opened"
perks={[
"Bookmarks become searchable memories",
"One-click import from the X bookmarks tab",
"Works via the Chrome extension",
]}
onConnect={() => openExternal("bookmarks", CHROME_EXTENSION_URL)}
/>
<SourceCard
title="Import from AI chat apps"
blurb="Bring memories from ChatGPT, Claude, Grok & more."
icon={<ChatAppsIconCluster />}
bareIconFrame
state={values.connected.chatapps ?? "idle"}
ctaLabel="Connect"
doneLabel="Opened"
perks={[
"Sync your ChatGPT memories",
"Carry context across every assistant",
"Import once, recall anywhere",
]}
onConnect={() => openExternal("chatapps", CHROME_EXTENSION_URL)}
/>
{mode === "personal" ? (
<NotionSourceCard
values={values}
isLocked={isLocked}
guard={guard}
connectRealProvider={connectRealProvider}
/>
<>
<SourceCard
title="Import bookmarks"
blurb="One-shot import of your saved tweets."
icon={<XBookmarksIcon className="size-6 text-[#fafafa]" />}
state={values.connected.bookmarks ?? "idle"}
ctaLabel="Connect"
doneLabel="Opened"
perks={[
"Bookmarks become searchable memories",
"One-click import from the X bookmarks tab",
"Works via the Chrome extension",
]}
onConnect={() =>
openExternal("bookmarks", CHROME_EXTENSION_URL)
}
/>
<SourceCard
title="Import from AI chat apps"
blurb="Bring memories from ChatGPT, Claude, Grok & more."
icon={<ChatAppsIconCluster />}
bareIconFrame
state={values.connected.chatapps ?? "idle"}
ctaLabel="Connect"
doneLabel="Opened"
perks={[
"Sync your ChatGPT memories",
"Carry context across every assistant",
"Import once, recall anywhere",
]}
onConnect={() =>
openExternal("chatapps", CHROME_EXTENSION_URL)
}
/>
<NotionSourceCard
mode={mode}
values={values}
isLocked={isLocked}
guard={guard}
connectRealProvider={connectRealProvider}
/>
</>
) : (
<GoogleDriveSourceCard
values={values}
onChange={onChange}
isLocked={isLocked}
guard={guard}
connectRealProvider={connectRealProvider}
/>
<>
<NotionSourceCard
mode={mode}
values={values}
isLocked={isLocked}
guard={guard}
connectRealProvider={connectRealProvider}
/>
<GranolaSourceCard
state={values.connected.granola ?? "idle"}
isLocked={isLocked}
guard={guard}
onOpen={() => setGranolaOpen(true)}
/>
<GoogleDriveSourceCard
mode={mode}
values={values}
onChange={onChange}
isLocked={isLocked}
guard={guard}
connectRealProvider={connectRealProvider}
/>
</>
)}
</div>
@ -465,7 +580,7 @@ export function StepSources({
/>
More integrations
<span className="text-[#525D6E]">
(Notion, Gmail, GitHub, OneDrive)
(Gmail, GitHub, OneDrive)
</span>
</button>
<SourceActions
@ -486,6 +601,7 @@ export function StepSources({
openExternal={openExternal}
requestWaitlist={requestWaitlist}
connectRealProvider={connectRealProvider}
onOpenGranola={() => setGranolaOpen(true)}
/>
</div>
) : null}
@ -500,6 +616,15 @@ export function StepSources({
requestedConnector={requestedConnector}
requestedPlan={requestedPlan}
/>
<GranolaConnectModal
open={granolaOpen}
onOpenChange={setGranolaOpen}
containerTags={[containerTag]}
onSuccess={() => {
announced.current.add("granola")
markConnected(["granola"])
}}
/>
</div>
)
}
@ -832,12 +957,14 @@ function SourceActions({
}
function GoogleDriveSourceCard({
mode,
values,
onChange,
isLocked,
guard,
connectRealProvider,
}: {
mode: BrainMode
values: SourcesValues
onChange: (next: SourcesValues) => void
isLocked: (plan?: RequiredPlan) => boolean
@ -882,17 +1009,21 @@ function GoogleDriveSourceCard({
onChange={(s) => onChange({ ...values, driveScope: s })}
/>
}
footerRight={<SpaceChip name="My Drive" />}
footerRight={
<SpaceChip name={mode === "team" ? "Team Brain" : "My Brain"} />
}
/>
)
}
function NotionSourceCard({
mode,
values,
isLocked,
guard,
connectRealProvider,
}: {
mode: BrainMode
values: SourcesValues
isLocked: (plan?: RequiredPlan) => boolean
guard: (
@ -922,7 +1053,46 @@ function NotionSourceCard({
onConnect={guard("pro", "Notion", () =>
connectRealProvider("notion", "notion"),
)}
footerRight={<SpaceChip name="Company Notion" />}
footerRight={
<SpaceChip name={mode === "team" ? "Team Brain" : "My Brain"} />
}
/>
)
}
function GranolaSourceCard({
state,
isLocked,
guard,
onOpen,
}: {
state: SourceState
isLocked: (plan?: RequiredPlan) => boolean
guard: (
plan: RequiredPlan | undefined,
title: string,
fn: () => void,
) => () => void
onOpen: () => void
}) {
return (
<SourceCard
title="Granola"
blurb="Meeting notes into searchable decisions."
icon={<Granola className="size-6" />}
state={state}
ctaLabel="Connect"
locked={isLocked("pro")}
requiredPlan="pro"
perks={[
"Meeting notes auto-captured",
"Decisions and action items extracted",
"Synced after every meeting",
]}
onConnect={guard("pro", "Granola", () => {
analytics.onboardingIntegrationClicked({ integration: "granola" })
onOpen()
})}
/>
)
}
@ -936,6 +1106,7 @@ function MoreSourcesGrid({
openExternal,
requestWaitlist,
connectRealProvider,
onOpenGranola,
}: {
mode: BrainMode
values: SourcesValues
@ -952,6 +1123,7 @@ function MoreSourcesGrid({
provider: "google-drive" | "notion" | "onedrive",
id: SourceId,
) => void
onOpenGranola: () => void
}) {
return (
<>
@ -999,20 +1171,14 @@ function MoreSourcesGrid({
/>
{mode === "personal" ? (
<GoogleDriveSourceCard
mode={mode}
values={values}
onChange={onChange}
isLocked={isLocked}
guard={guard}
connectRealProvider={connectRealProvider}
/>
) : (
<NotionSourceCard
values={values}
isLocked={isLocked}
guard={guard}
connectRealProvider={connectRealProvider}
/>
)}
) : null}
<SourceCard
title="OneDrive"
blurb="Office docs from OneDrive."
@ -1060,46 +1226,18 @@ function MoreSourcesGrid({
]}
onConnect={guard("max", "GitHub", () => requestWaitlist("github"))}
/>
<SourceCard
title="Granola"
blurb="Meeting notes into searchable decisions."
icon={<Granola className="size-6" />}
state="idle"
ctaLabel="Connect"
locked={isLocked("max")}
requiredPlan="max"
perks={[
"Meeting notes auto-captured",
"Decisions and action items extracted",
"Synced after every meeting",
]}
onConnect={guard("max", "Granola", () => {
analytics.onboardingIntegrationClicked({ integration: "granola" })
toast.info("Granola is coming soon.")
})}
/>
{mode === "personal" ? (
<GranolaSourceCard
state={values.connected.granola ?? "idle"}
isLocked={isLocked}
guard={guard}
onOpen={onOpenGranola}
/>
) : null}
</>
)
}
function RoutingChip({ workspaceName }: { workspaceName: string }) {
return (
<div
className="inline-flex items-center gap-2 px-3 h-9 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.2)] text-[12px]"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
title="All sources route to your brain. You can carve out spaces after setup."
>
<Logo className="size-3.5 text-[#8B8B8B]" />
<span className="text-[#737373] font-medium">Routing to</span>
<span className="text-[#fafafa] font-semibold">
{workspaceName || "your brain"}
</span>
</div>
)
}
function SourceCard({
title,
blurb,
@ -1167,8 +1305,8 @@ function SourceCard({
{headerNote}
</div>
{isDone ? (
<span className="inline-flex items-center gap-1 text-[11px] text-[#4BA0FA] font-semibold uppercase tracking-[0.08em] shrink-0 mt-1">
<Check className="size-3" />
<span className="inline-flex items-center gap-1.5 rounded-full border border-[#2261CA55] bg-[#2261CA1A] px-2.5 py-1 text-[12px] font-semibold text-[#4BA0FA] shrink-0 mt-0.5">
<Check className="size-3.5" />
{state === "waitlist" ? "Requested" : (doneLabel ?? "Connected")}
</span>
) : (

View file

@ -144,7 +144,7 @@ export function StepTeam({
const count = values.invites.length
return (
<div className="max-w-2xl mx-auto space-y-5">
<div className="max-w-2xl mx-auto">
<section
className="rounded-[22px] bg-[#1B1F24] p-7 md:p-8"
style={modalCardStyle}
@ -294,38 +294,38 @@ export function StepTeam({
</div>
</>
)}
</section>
<div className="flex items-center justify-end gap-[22px]">
<button
type="button"
onClick={onSkip ?? onContinue}
disabled={submitting}
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors disabled:opacity-50"
>
Skip for now
</button>
<Button
variant="insideOut"
onClick={onContinue}
disabled={submitting}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
{submitting ? (
<>
Sending
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
{count > 0
? `Send ${count} invite${count === 1 ? "" : "s"}`
: "Continue"}
<ArrowRight className="size-3.5" />
</>
)}
</Button>
</div>
<div className="mt-6 flex items-center justify-end gap-[22px] border-t border-white/[0.06] pt-5">
<button
type="button"
onClick={onSkip ?? onContinue}
disabled={submitting}
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors disabled:opacity-50"
>
Skip for now
</button>
<Button
variant="insideOut"
onClick={onContinue}
disabled={submitting}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
{submitting ? (
<>
Sending
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
{count > 0
? `Send ${count} invite${count === 1 ? "" : "s"}`
: "Continue"}
<ArrowRight className="size-3.5" />
</>
)}
</Button>
</div>
</section>
</div>
)
}

View file

@ -1,3 +1,7 @@
export type CompanyBrainConfirmResult =
| { ok: true; serverSchedulesResearch: boolean }
| { ok: false }
export type BrainMode = "personal" | "team"
export type BrainStep = "about" | "sources" | "ingest" | "team"
@ -66,6 +70,22 @@ export function workspaceNameFromEmail(
return root.charAt(0).toUpperCase() + root.slice(1)
}
/** e.g. duolingo.com → Duolingo (first hostname label, title-cased). */
export function workspaceNameFromDomain(
domain: string | undefined | null,
): string {
if (!domain) return ""
const clean = domain
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.replace(/\/.*$/, "")
const host = clean.split(".")[0] ?? ""
if (!host) return ""
return host.charAt(0).toUpperCase() + host.slice(1)
}
export function workspaceDomainFromEmail(
email: string | undefined | null,
): string | null {

View file

@ -0,0 +1,79 @@
"use client"
import { ReviewMemoriesModal } from "@/components/review-memories-modal"
import { useInferredMemories } from "@/hooks/use-inferred-memories"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { ArrowRight } from "lucide-react"
import { useEffect, useState } from "react"
// "Suggested for you" entry point — opens the swipe-review modal. The trigger
// hides when there's nothing to review, but the modal stays mounted while open
// (even after the last card) so it can show its "all caught up" summary.
// Styled to match the Weekly digest box above it.
export function ReviewMemoriesCard({
containerTag,
className,
}: {
containerTag: string | undefined
className?: string
}) {
const [open, setOpen] = useState(false)
const { data: memories = [] } = useInferredMemories(containerTag)
const liveCount = memories.length
// While the modal is open, freeze the count shown on the trigger so the
// background button stays put (doesn't tick down or vanish) until it closes.
const [frozenCount, setFrozenCount] = useState(0)
const count = open ? frozenCount : liveCount
const handleOpenChange = (next: boolean) => {
if (next) setFrozenCount(liveCount)
setOpen(next)
}
// Switching spaces shouldn't carry an open modal over to the new space.
// biome-ignore lint/correctness/useExhaustiveDependencies: reset on space switch
useEffect(() => {
setOpen(false)
}, [containerTag])
return (
<>
{count > 0 && (
<button
type="button"
onClick={() => handleOpenChange(true)}
className={cn(
"group flex w-full items-center justify-between gap-3 rounded-xl bg-[#0c1a30]/80 px-3.5 py-3 text-left shadow-[0_12px_40px_rgba(0,0,0,0.22)] ring-1 ring-[#4BA0FA]/20 backdrop-blur-md transition-colors hover:bg-[#0e2038]/90 hover:ring-[#4BA0FA]/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/45",
dmSansClassName(),
className,
)}
>
<div className="min-w-0">
<p className="text-[13px] font-semibold leading-tight text-fg-primary">
Review suggestions
</p>
<p className="mt-1 truncate text-[11px] leading-tight text-fg-faint">
{count} {count === 1 ? "memory" : "memories"} we inferred for you
</p>
</div>
<span className="flex shrink-0 items-center gap-2 text-fg-faint transition-colors group-hover:text-fg-muted">
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-brand-accent px-1.5 text-[10px] font-bold text-[#00111f]">
{count}
</span>
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
</span>
</button>
)}
{(open || liveCount > 0) && (
<ReviewMemoriesModal
open={open}
onOpenChange={handleOpenChange}
containerTag={containerTag}
/>
)}
</>
)
}

View file

@ -0,0 +1,477 @@
"use client"
import { dmSansClassName } from "@/lib/fonts"
import {
type InferredMemory,
useInferredMemories,
useInferredMemoryCache,
useReviewInferredMemory,
} from "@/hooks/use-inferred-memories"
import { cn } from "@lib/utils"
import { Dialog, DialogContent } from "@ui/components/dialog"
import { Check, Undo2, X } from "lucide-react"
import {
AnimatePresence,
motion,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react"
import { useCallback, useEffect, useRef, useState } from "react"
// Distance / velocity past which a drag commits to a decision.
const SWIPE_OFFSET = 120
const SWIPE_VELOCITY = 600
const STACK_DEPTH = 3 // how many cards are visible at once
type Decision = "approve" | "decline" | "skip"
export function ReviewMemoriesModal({
open,
onOpenChange,
containerTag,
}: {
open: boolean
onOpenChange: (open: boolean) => void
containerTag: string | undefined
}) {
const { data: memories = [] } = useInferredMemories(containerTag)
const { mutate: review } = useReviewInferredMemory(containerTag)
const queue = useInferredMemoryCache(containerTag)
const reduceMotion = useReducedMotion()
// Snapshot the queue when the modal opens so cache updates from the
// mutation don't reshuffle the stack mid-session.
const [cards, setCards] = useState<InferredMemory[]>([])
const [index, setIndex] = useState(0)
const [exitDir, setExitDir] = useState(1)
const [approved, setApproved] = useState(0)
const [history, setHistory] = useState<{ id: string; decision: Decision }[]>(
[],
)
// Synchronous source of truth so rapid swipes/undos don't read stale state.
const indexRef = useRef(0)
const historyRef = useRef<{ id: string; decision: Decision }[]>([])
// Intentionally keyed on `open` only: including `memories` would reset the
// stack every time the review mutation trims the cache. The queue is loaded
// by the time the trigger renders, so a snapshot on open is sufficient.
// biome-ignore lint/correctness/useExhaustiveDependencies: snapshot-on-open
useEffect(() => {
if (!open) return
setCards(memories)
setIndex(0)
indexRef.current = 0
setApproved(0)
setHistory([])
historyRef.current = []
}, [open])
const current = cards[index]
const done = cards.length > 0 && index >= cards.length
const decide = useCallback(
(decision: Decision) => {
const card = cards[indexRef.current]
if (!card) return
// 1 = fly right (approve), -1 = fly left (decline), 0 = drop down (skip)
setExitDir(decision === "approve" ? 1 : decision === "decline" ? -1 : 0)
if (decision === "approve") {
setApproved((n) => n + 1)
review({ memoryId: card.id, action: "approve" })
} else if (decision === "decline") {
review({ memoryId: card.id, action: "decline" })
} else {
// Skip persists nothing server-side, but drop it from the cached queue
// so the trigger's live count falls and the prompt can be dismissed.
queue.drop(card.id)
}
historyRef.current = [...historyRef.current, { id: card.id, decision }]
setHistory(historyRef.current)
indexRef.current += 1
setIndex(indexRef.current)
},
[cards, review, queue.drop],
)
const canUndo = history.length > 0
// Step back one card and revert its server-side review (skip had none).
// Reads from refs so consecutive/rapid undos each target the correct card.
const undo = useCallback(() => {
const last = historyRef.current[historyRef.current.length - 1]
if (!last) return
historyRef.current = historyRef.current.slice(0, -1)
setHistory(historyRef.current)
if (last.decision === "approve") {
setApproved((n) => Math.max(0, n - 1))
review({ memoryId: last.id, action: "undo" })
} else if (last.decision === "decline") {
review({ memoryId: last.id, action: "undo" })
} else {
const card = cards.find((c) => c.id === last.id)
if (card) queue.restore(card)
}
indexRef.current = Math.max(0, indexRef.current - 1)
setIndex(indexRef.current)
}, [review, cards, queue.restore])
// Keyboard: ← decline, → approve, ↓/space skip.
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
e.preventDefault()
undo()
return
}
if (done || !current) return
if (e.key === "ArrowRight") decide("approve")
else if (e.key === "ArrowLeft") decide("decline")
else if (e.key === "ArrowDown" || e.key === " ") {
e.preventDefault()
decide("skip")
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [open, done, current, decide, undo])
const visible = cards.slice(index, index + STACK_DEPTH)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
"border-surface-border bg-surface-base p-0 gap-0 overflow-hidden sm:max-w-[420px] rounded-[22px]",
dmSansClassName(),
)}
showCloseButton={false}
>
{/* Header */}
<div className="flex items-start justify-between px-6 pt-6 pb-4">
<div>
<p className="text-[15px] font-semibold leading-tight text-fg-primary">
Suggested memories
</p>
<p className="mt-1 text-[12px] leading-tight text-fg-faint">
Memories we inferred keep the ones that fit
</p>
</div>
<button
type="button"
onClick={() => onOpenChange(false)}
className="-mr-1 -mt-1 rounded-full p-1.5 text-fg-faint transition-colors hover:bg-white/[0.06] hover:text-fg-muted"
aria-label="Close"
>
<X className="size-4" />
</button>
</div>
{/* Progress dots + undo + counter */}
{cards.length > 0 && (
<div className="flex items-center justify-between px-6 pb-1">
<div className="flex flex-wrap items-center gap-1.5">
{cards.map((c, i) => (
<span
key={c.id}
className={cn(
"h-1.5 rounded-full transition-all duration-300",
i === index
? "w-5 bg-brand-accent"
: i < index
? "w-1.5 bg-brand-accent/40"
: "w-1.5 bg-white/15",
)}
/>
))}
</div>
<div className="flex shrink-0 items-center gap-2.5 pl-3">
{canUndo && (
<button
type="button"
onClick={undo}
className="flex items-center gap-1 text-[11px] font-medium text-fg-faint transition-colors hover:text-fg-primary"
>
<Undo2 className="size-3.5" />
Undo
</button>
)}
<span className="text-[11px] tabular-nums text-fg-faint">
{Math.min(index + 1, cards.length)}/{cards.length}
</span>
</div>
</div>
)}
{/* Card deck */}
<div className="relative px-6 pt-4 pb-3">
<div className="relative h-[340px]">
{/* Nova glow halo behind the deck */}
{!done && (
<div
aria-hidden
className="pointer-events-none absolute -inset-3 rounded-[28px] opacity-70 blur-2xl"
style={{
background:
"radial-gradient(58% 52% at 50% 42%, rgba(75,160,250,0.45), rgba(54,216,196,0.16) 58%, transparent 76%)",
}}
/>
)}
{done ? (
<DoneState approved={approved} total={cards.length} />
) : (
<AnimatePresence custom={exitDir} initial={false}>
{visible.map((mem, i) => (
<SwipeCard
key={mem.id}
memory={mem}
depth={i}
globalIndex={index + i}
isTop={i === 0}
reduceMotion={!!reduceMotion}
onDecide={decide}
/>
))}
</AnimatePresence>
)}
</div>
</div>
{/* Controls */}
{!done && current && (
<div className="flex items-center justify-center gap-3 px-5 pb-6 pt-3">
<motion.button
type="button"
aria-label="Decline"
onClick={() => decide("decline")}
whileHover={{ scale: 1.07 }}
whileTap={{ scale: 0.9 }}
transition={{ duration: 0.15 }}
className="flex size-14 items-center justify-center rounded-full border border-[#ff6b6b]/30 bg-[#ff6b6b]/10 text-[#ff6b6b] transition-colors hover:bg-[#ff6b6b]/20"
>
<X className="size-6" />
</motion.button>
<motion.button
type="button"
onClick={() => decide("skip")}
whileTap={{ scale: 0.94 }}
transition={{ duration: 0.15 }}
className="px-4 text-[13px] font-medium text-fg-faint transition-colors hover:text-fg-primary"
>
Skip
</motion.button>
<motion.button
type="button"
aria-label="Keep"
onClick={() => decide("approve")}
whileHover={{ scale: 1.07 }}
whileTap={{ scale: 0.9 }}
transition={{ duration: 0.15 }}
className="flex size-14 items-center justify-center rounded-full border border-[#4ade80]/30 bg-[#4ade80]/10 text-[#4ade80] transition-colors hover:bg-[#4ade80]/20"
>
<Check className="size-6" />
</motion.button>
</div>
)}
</DialogContent>
</Dialog>
)
}
function SwipeCard({
memory,
depth,
globalIndex,
isTop,
reduceMotion,
onDecide,
}: {
memory: InferredMemory
depth: number
globalIndex: number
isTop: boolean
reduceMotion: boolean
onDecide: (d: Decision) => void
}) {
const x = useMotionValue(0)
const rotate = useTransform(x, [-200, 200], reduceMotion ? [0, 0] : [-8, 8])
// Slack-style: the whole card washes green (keep) / red (decline) and the
// border + verdict pill intensify the closer you get to committing.
const wash = useTransform(
x,
[-150, -30, 0, 30, 150],
[
"rgba(239,68,68,0.34)",
"rgba(239,68,68,0.04)",
"rgba(255,255,255,0)",
"rgba(74,222,128,0.04)",
"rgba(74,222,128,0.34)",
],
)
const borderColor = useTransform(
x,
[-150, -30, 0, 30, 150],
[
"rgba(239,68,68,0.7)",
"rgba(255,255,255,0.08)",
"rgba(255,255,255,0.08)",
"rgba(255,255,255,0.08)",
"rgba(74,222,128,0.7)",
],
)
const keepOpacity = useTransform(x, [30, 120], [0, 1])
const keepScale = useTransform(x, [30, 120], [0.7, 1])
const declineOpacity = useTransform(x, [-120, -30], [1, 0])
const declineScale = useTransform(x, [-120, -30], [1, 0.7])
const scale = 1 - depth * 0.055
const y = depth * 16
return (
<motion.div
className="absolute inset-0"
style={{
x: isTop ? x : 0,
rotate: isTop ? rotate : 0,
// Absolute position keeps the exiting card above the one rising behind.
zIndex: 999 - globalIndex,
}}
variants={{
exit: (dir: number) => {
if (reduceMotion)
return { opacity: 0, transition: { duration: 0.25 } }
// dir 0 = skip → drop straight down; ±1 = fly out sideways.
if (dir === 0)
return {
y: 480,
opacity: 0,
scale: 0.9,
transition: { duration: 0.45, ease: [0.4, 0, 0.2, 1] },
}
return {
x: dir * 540,
opacity: 0,
rotate: dir * 12,
transition: { duration: 0.42, ease: [0.4, 0, 0.2, 1] },
}
},
}}
exit="exit"
initial={{ scale: scale - 0.04, y: y + 10, opacity: 0 }}
animate={{
scale,
y,
opacity: depth === 0 ? 1 : depth === 1 ? 0.8 : 0.45,
}}
transition={{ duration: 0.28, ease: [0.4, 0, 0.2, 1] }}
drag={isTop ? "x" : false}
dragSnapToOrigin
dragElastic={0.7}
whileDrag={{ scale: 1.02 }}
onDragEnd={(_e, info) => {
if (!isTop) return
const { offset, velocity } = info
if (offset.x > SWIPE_OFFSET || velocity.x > SWIPE_VELOCITY)
onDecide("approve")
else if (offset.x < -SWIPE_OFFSET || velocity.x < -SWIPE_VELOCITY)
onDecide("decline")
}}
>
<motion.div
style={isTop ? { borderColor } : undefined}
className={cn(
"relative h-full overflow-hidden rounded-[20px] border border-white/[0.08] bg-surface-card shadow-[0_20px_50px_-12px_rgba(0,0,0,0.55)]",
isTop && "cursor-grab active:cursor-grabbing",
)}
>
{/* Full-card color wash that tracks the swipe */}
{isTop && (
<motion.div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{ backgroundColor: wash }}
/>
)}
{/* Verdict pills — slide/scale in toward the swipe direction */}
{isTop && (
<>
<motion.div
style={{ opacity: keepOpacity, scale: keepScale }}
className="pointer-events-none absolute right-4 top-4 flex items-center gap-1.5 rounded-full bg-[#4ade80] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[#06230f] shadow-lg"
>
<Check className="size-3.5" /> Keep
</motion.div>
<motion.div
style={{ opacity: declineOpacity, scale: declineScale }}
className="pointer-events-none absolute left-4 top-4 flex items-center gap-1.5 rounded-full bg-[#ff6b6b] px-3 py-1 text-xs font-bold uppercase tracking-wide text-[#2a0808] shadow-lg"
>
<X className="size-3.5" /> Decline
</motion.div>
</>
)}
<div className="relative flex h-full flex-col p-6">
<div className="flex shrink-0 items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-brand-accent/85">
<span className="size-1.5 rounded-full bg-brand-accent shadow-[0_0_8px_rgba(75,160,250,0.8)]" />
Inferred
</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-width:thin]">
<p className="text-[19px] font-medium leading-[1.32] tracking-[-0.01em] text-fg-primary">
{memory.memory}
</p>
</div>
<div className="mt-4 flex shrink-0 items-center gap-2 text-[11px] text-fg-faint">
{memory.parentCount > 0 && (
<span className="rounded-full bg-white/[0.04] px-2 py-0.5 ring-1 ring-white/[0.06]">
{memory.parentCount === 1
? "from 1 memory"
: `from ${memory.parentCount} memories`}
</span>
)}
<span>{relativeTime(memory.createdAt)}</span>
</div>
</div>
</motion.div>
</motion.div>
)
}
function DoneState({ approved, total }: { approved: number; total: number }) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
className="flex h-full flex-col items-center justify-center gap-3 text-center"
>
<span className="flex size-14 items-center justify-center rounded-full bg-[#4ade801a] ring-1 ring-[#4ade8055]">
<Check className="size-7 text-[#4ade80]" />
</span>
<div>
<p className="text-base font-semibold text-fg-primary">All caught up</p>
<p className="mt-1 text-[13px] text-fg-faint">
{approved > 0
? `You kept ${approved} of ${total} suggested ${total === 1 ? "memory" : "memories"}.`
: "Nothing kept this time — they'll stay tucked away."}
</p>
</div>
</motion.div>
)
}
function relativeTime(iso: string): string {
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return ""
const diff = Date.now() - then
const day = 86_400_000
if (diff < day) return "today"
if (diff < 2 * day) return "yesterday"
if (diff < 7 * day) return `${Math.floor(diff / day)}d ago`
if (diff < 30 * day) return `${Math.floor(diff / (7 * day))}w ago`
return `${Math.floor(diff / (30 * day))}mo ago`
}

View file

@ -35,6 +35,7 @@ import {
Tag,
Plus,
} from "lucide-react"
import { useQueryState } from "nuqs"
import { useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { useContainerTags } from "@/hooks/use-container-tags"
@ -174,6 +175,10 @@ export default function Account() {
setIsEditingOrgName(false)
}, [org?.name])
// Deep link: ?invite=1 (e.g. from the dashboard) opens the invite dialog.
// Consumed below, once the role is known and only for admins/owners.
const [inviteParam, setInviteParam] = useQueryState("invite")
const activeMemberRoleQuery = useQuery({
queryKey: ["organization", org?.id, "active-member-role"],
queryFn: async () => {
@ -209,6 +214,18 @@ export default function Account() {
const canManageTeam = currentRole === "owner" || currentRole === "admin"
const isOwner = currentRole === "owner"
// Consume ?invite=1 only after the role resolves, and only for managers.
useEffect(() => {
if (inviteParam !== "1" || activeMemberRoleQuery.isLoading) return
if (canManageTeam) setInviteDialogOpen(true)
setInviteParam(null)
}, [
inviteParam,
activeMemberRoleQuery.isLoading,
canManageTeam,
setInviteParam,
])
const pendingInvitations = useMemo(
() => (org?.invitations ?? []).filter(isPendingInvitation),
[org?.invitations],

File diff suppressed because it is too large Load diff

View file

@ -8,13 +8,45 @@ import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { dmSans125ClassName } from "@/lib/fonts"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { useAuth } from "@lib/auth-context"
import { brainConnectorIcon, SlackMark } from "../brain-connector-icons"
import { PillButton } from "../integrations/install-steps"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
type ConnRow = { toolkit: string; org: boolean; user: boolean }
const MCP_BASE = `${BACKEND}/brain/mcp-connections`
type AuthType = "oauth" | "static" | "none"
type CatalogEntry = {
slug: string
name: string
category: string
authType: AuthType
tokenHint?: string
}
type ConnRow = {
serverSlug: string
serverUrl?: string
authType: AuthType
status: "active" | "pending" | "error"
userId: string | null
}
type SlackStatus = { connected: boolean; teamName: string | null }
type Scope = "org" | "user"
function titleCase(s: string) {
return s.replace(/\b\w/g, (c) => c.toUpperCase())
}
function slugifyMcpName(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 63)
}
function SecondaryButton({
children,
@ -37,85 +69,12 @@ function SecondaryButton({
)
}
function SlackMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
<path
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
fill="#E01E5A"
/>
<path
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
fill="#E01E5A"
/>
<path
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
fill="#36C5F0"
/>
<path
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
fill="#36C5F0"
/>
<path
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
fill="#2EB67D"
/>
<path
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
fill="#2EB67D"
/>
<path
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
fill="#ECB22E"
/>
<path
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
fill="#ECB22E"
/>
</svg>
)
}
function GithubMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
)
}
function LinearMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<title>Linear</title>
<path d="M3.084 12.866a8.916 8.916 0 0 0 8.05 8.05.27.27 0 0 0 .222-.46l-7.812-7.812a.27.27 0 0 0-.46.222Zm-.044-1.955a.27.27 0 0 0 .078.21l9.76 9.76c.06.06.142.087.21.078a8.87 8.87 0 0 0 1.273-.218.27.27 0 0 0 .127-.453L3.712 9.51a.27.27 0 0 0-.453.127 8.87 8.87 0 0 0-.218 1.273Zm.69-2.706a.27.27 0 0 0 .06.29l11.715 11.716a.27.27 0 0 0 .29.06 8.96 8.96 0 0 0 .837-.384.27.27 0 0 0 .066-.439L4.553 7.302a.27.27 0 0 0-.44.066 8.96 8.96 0 0 0-.383.837Zm1.11-1.798a.27.27 0 0 1-.017-.366A8.948 8.948 0 0 1 18.07 18.69a.27.27 0 0 1-.366-.017L4.94 6.407Z" />
</svg>
)
}
const TOOLKITS: Record<
string,
{ label: string; subtitle: string; icon: React.ReactNode }
> = {
github: {
label: "GitHub",
subtitle: "Repos, pull requests and issues",
icon: <GithubMark className="size-5 text-[#FAFAFA]" />,
},
linear: {
label: "Linear",
subtitle: "Issues, projects and cycles",
icon: <LinearMark className="size-5 text-[#5E6AD2]" />,
},
}
function StatusDot({ connected }: { connected: boolean }) {
return (
<span
className={cn(
dmSans125ClassName(),
"flex items-center gap-1.5 text-[13px] font-medium",
"flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[12px] font-medium",
connected ? "text-[#FAFAFA]" : "text-[#737373]",
)}
>
@ -131,7 +90,9 @@ function StatusDot({ connected }: { connected: boolean }) {
}
function AppCard({
toolkit,
name,
subtitle,
icon,
connected,
canConnect,
canDisconnect,
@ -140,7 +101,9 @@ function AppCard({
onConnect,
onDisconnect,
}: {
toolkit: string
name: string
subtitle: string
icon: React.ReactNode
connected: boolean
canConnect: boolean
canDisconnect: boolean
@ -149,37 +112,32 @@ function AppCard({
onConnect: () => void
onDisconnect: () => void
}) {
const meta = TOOLKITS[toolkit] ?? {
label: toolkit,
subtitle: "",
icon: null,
}
return (
<div className="flex items-center justify-between gap-4 rounded-[14px] bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] sm:p-5">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
{meta.icon}
<div className="flex min-h-[152px] min-w-0 flex-col justify-between gap-4 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<div className="flex min-w-0 items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
{icon}
</div>
<div className="min-w-0">
<div className="min-w-0 pt-0.5">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[15px] tracking-[-0.15px] text-[#FAFAFA]",
"truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]",
)}
>
{meta.label}
{name}
</p>
<p
className={cn(
dmSans125ClassName(),
"truncate text-[12px] font-medium text-[#737373]",
"mt-1 line-clamp-2 break-words text-[12px] font-medium leading-5 text-[#737373]",
)}
>
{meta.subtitle}
{subtitle}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
<div className="flex min-h-9 items-center justify-between gap-3 border-[#1E293B]/50 border-t pt-3">
<StatusDot connected={connected} />
{connected && canDisconnect ? (
<PillButton onClick={onDisconnect} disabled={busy}>
@ -205,57 +163,65 @@ function AppCard({
)
}
function Section({
title,
description,
children,
function ScopeToggle({
scope,
onChange,
}: {
title: string
description: string
children: React.ReactNode
scope: Scope
onChange: (s: Scope) => void
}) {
const items: { id: Scope; label: string }[] = [
{ id: "org", label: "Organization" },
{ id: "user", label: "Personal" },
]
return (
<div className="space-y-3">
<div className="px-1">
<p
<div className="inline-flex rounded-full border border-[#1E293B] bg-[#0D121A] p-1">
{items.map((it) => (
<button
key={it.id}
type="button"
onClick={() => onChange(it.id)}
className={cn(
dmSans125ClassName(),
"font-semibold text-[15px] tracking-[-0.15px] text-[#FAFAFA]",
"rounded-full px-4 h-8 text-[13px] font-medium transition-colors",
scope === it.id
? "bg-[#1E293B] text-[#FAFAFA]"
: "text-[#737373] hover:text-[#FAFAFA]",
)}
>
{title}
</p>
<p
className={cn(
dmSans125ClassName(),
"mt-0.5 text-[13px] font-medium text-[#737373]",
)}
>
{description}
</p>
</div>
{children}
{it.label}
</button>
))}
</div>
)
}
function CardSkeleton() {
function RowSkeleton() {
return (
<div className="flex items-center gap-3 rounded-[14px] bg-[#14161A] p-5 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<div className="size-10 animate-pulse rounded-[10px] bg-[#1c1f24]" />
<div className="space-y-2">
<div className="h-3.5 w-24 animate-pulse rounded bg-[#1c1f24]" />
<div className="h-3 w-40 animate-pulse rounded bg-[#1c1f24]" />
<div className="min-h-[152px] rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<div className="flex items-center gap-3">
<div className="size-10 animate-pulse rounded-[10px] bg-[#1c1f24]" />
<div className="space-y-2">
<div className="h-3 w-24 animate-pulse rounded bg-[#1c1f24]" />
<div className="h-2.5 w-32 animate-pulse rounded bg-[#1c1f24]" />
</div>
</div>
<div className="mt-8 h-8 w-28 animate-pulse rounded-full bg-[#1c1f24] ml-auto" />
</div>
)
}
export default function CompanyBrainConnections() {
const isCompanyBrain = useHasCompanyBrain()
const [rows, setRows] = useState<ConnRow[] | null>(null)
const { user } = useAuth()
const [catalog, setCatalog] = useState<CatalogEntry[] | null>(null)
const [catalogLoaded, setCatalogLoaded] = useState(false)
const [rows, setRows] = useState<ConnRow[]>([])
const [slackStatus, setSlackStatus] = useState<SlackStatus | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const [scope, setScope] = useState<Scope>("user")
const [customName, setCustomName] = useState("")
const [customServerUrl, setCustomServerUrl] = useState("")
const roleQuery = useQuery({
queryKey: ["company-brain-connections", "role"],
@ -268,15 +234,25 @@ export default function CompanyBrainConnections() {
const isAdmin = role === "owner" || role === "admin"
const load = useCallback(async () => {
const [connRes, slackRes] = await Promise.all([
fetch(`${BACKEND}/brain/connections`, { credentials: "include" }),
const [catRes, connRes, slackRes] = await Promise.all([
fetch(`${MCP_BASE}/catalog`, { credentials: "include" }),
fetch(`${MCP_BASE}/`, { credentials: "include" }),
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
])
if (catRes.ok) {
const data = (await catRes.json()) as { catalog?: CatalogEntry[] }
setCatalog(Array.isArray(data.catalog) ? data.catalog : [])
setCatalogLoaded(true)
} else {
setCatalog([])
setCatalogLoaded(false)
toast.error("Couldn't load the app catalog.")
}
if (connRes.ok) {
setRows(((await connRes.json()) as { toolkits: ConnRow[] }).toolkits)
const data = (await connRes.json()) as { connections?: ConnRow[] }
setRows(Array.isArray(data.connections) ? data.connections : [])
} else {
setRows([])
toast.error("Couldn't load connections.")
}
if (slackRes.ok) {
setSlackStatus((await slackRes.json()) as SlackStatus)
@ -293,13 +269,52 @@ export default function CompanyBrainConnections() {
return () => window.removeEventListener("focus", onFocus)
}, [isCompanyBrain, load])
const connect = async (toolkit: string, scope: "user" | "org") => {
setBusy(`${toolkit}:${scope}`)
// A row with userId === null is the org-shared connection; any other row
// returned to the caller is their own personal one.
const isConnected = (slug: string, shared: boolean) =>
rows.some(
(r) =>
r.serverSlug === slug &&
r.status === "active" &&
(shared ? r.userId === null : r.userId !== null),
)
const isStaff =
user?.email?.toLowerCase().endsWith("@supermemory.com") ?? false
const connect = async (entry: CatalogEntry, shared: boolean) => {
const key = `${entry.slug}:${shared ? "org" : "user"}`
setBusy(key)
try {
const res = await fetch(
`${BACKEND}/brain/connections/${toolkit}/link?scope=${scope}`,
{ method: "POST", credentials: "include" },
)
if (entry.authType === "static") {
const token = window.prompt(
`Paste a token for ${entry.name}.${entry.tokenHint ? `\n${entry.tokenHint}` : ""}`,
)
if (!token) return
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect-static`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token, shared }),
})
if (res.status === 403) {
toast.error("Only admins can connect the shared org account.")
return
}
if (!res.ok) {
toast.error("Couldn't connect.")
return
}
toast.success(`${entry.name} connected.`)
await load()
return
}
const res = await fetch(`${MCP_BASE}/${entry.slug}/connect`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ shared, redirectUrl: window.location.href }),
})
if (res.status === 403) {
toast.error("Only admins can connect the shared org account.")
return
@ -308,9 +323,19 @@ export default function CompanyBrainConnections() {
toast.error("Couldn't start the connection.")
return
}
const data = (await res.json()) as { url?: string; error?: string }
if (data.url) window.open(data.url, "_blank", "noopener")
else toast.error(data.error ?? "Couldn't start the connection.")
const data = (await res.json()) as {
authUrl?: string
ok?: boolean
error?: string
}
if (data.authUrl) {
window.open(data.authUrl, "_blank", "noopener")
} else if (data.ok) {
toast.success(`${entry.name} connected.`)
await load()
} else {
toast.error(data.error ?? "Couldn't start the connection.")
}
} catch {
toast.error("Couldn't start the connection.")
} finally {
@ -318,18 +343,78 @@ export default function CompanyBrainConnections() {
}
}
const disconnect = async (toolkit: string, scope: "user" | "org") => {
const label = TOOLKITS[toolkit]?.label ?? toolkit
const connectCustom = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const slug = slugifyMcpName(customName)
const serverUrl = customServerUrl.trim()
if (!slug) {
toast.error("Enter a custom MCP name.")
return
}
if (!serverUrl) {
toast.error("Enter an OAuth MCP URL.")
return
}
if (apps.some((entry) => entry.slug === slug)) {
toast.error("That name is already used by a catalog app.")
return
}
const key = `custom:${slug}`
setBusy(key)
try {
const res = await fetch(`${MCP_BASE}/${slug}/connect`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
serverUrl,
shared: false,
redirectUrl: window.location.href,
}),
})
if (res.status === 403) {
toast.error("Custom MCP URLs are staff-only.")
return
}
const data = (await res.json().catch(() => ({}))) as {
authUrl?: string
ok?: boolean
error?: string
}
if (!res.ok) {
toast.error(data.error ?? "Couldn't start the custom connection.")
return
}
if (data.authUrl) {
window.open(data.authUrl, "_blank", "noopener")
} else if (data.ok) {
toast.success(`${slug} connected.`)
setCustomName("")
setCustomServerUrl("")
await load()
} else {
toast.error(data.error ?? "Couldn't start the custom connection.")
}
} catch {
toast.error("Couldn't start the custom connection.")
} finally {
setBusy(null)
}
}
const disconnect = async (entry: CatalogEntry, shared: boolean) => {
if (
!window.confirm(
`Disconnect ${label} from ${scope === "org" ? "the shared org account" : "your personal account"}?`,
`Disconnect ${entry.name} from ${shared ? "the shared org account" : "your personal account"}?`,
)
)
return
setBusy(`${toolkit}:${scope}`)
const key = `${entry.slug}:${shared ? "org" : "user"}`
setBusy(key)
try {
const res = await fetch(
`${BACKEND}/brain/connections/${toolkit}?scope=${scope}`,
`${MCP_BASE}/${entry.slug}?shared=${shared ? "true" : "false"}`,
{ method: "DELETE", credentials: "include" },
)
if (res.status === 403) {
@ -340,7 +425,7 @@ export default function CompanyBrainConnections() {
toast.error("Couldn't disconnect.")
return
}
toast.success(`${label} disconnected.`)
toast.success(`${entry.name} disconnected.`)
await load()
} catch {
toast.error("Couldn't disconnect.")
@ -362,16 +447,34 @@ export default function CompanyBrainConnections() {
)
}
const loading = rows === null
const loading = catalog === null
const apps = catalog ?? []
const catalogSlugs = new Set(apps.map((entry) => entry.slug))
const canClassifyCustomRows = catalogLoaded && apps.length > 0
const customRows = canClassifyCustomRows
? rows.filter(
(row) =>
row.userId !== null &&
row.status === "active" &&
typeof row.serverUrl === "string" &&
row.serverUrl.length > 0 &&
!catalogSlugs.has(row.serverSlug),
)
: []
const shared = scope === "org"
const description = shared
? "Connected by admins. Used for reads when you haven't connected your own."
: "Your personal accounts, used for your actions and your reads."
return (
<div className="space-y-7">
<div className="flex items-center justify-end gap-3">
<div className="space-y-5">
<div className="flex items-center justify-between gap-3">
<ScopeToggle scope={scope} onChange={setScope} />
{slackStatus?.connected && slackStatus.teamName ? (
<p
className={cn(
dmSans125ClassName(),
"mr-auto text-[13px] font-medium text-[#737373]",
"ml-auto text-[13px] font-medium text-[#737373]",
)}
>
Slack · {slackStatus.teamName}
@ -384,50 +487,119 @@ export default function CompanyBrainConnections() {
</SecondaryButton>
) : null}
</div>
<Section
title="Organization (shared)"
description="Connected by admins. Used for reads when you haven't connected your own."
>
{loading ? (
<CardSkeleton />
) : (
rows.map((row) => (
<AppCard
key={`org-${row.toolkit}`}
toolkit={row.toolkit}
connected={row.org}
canConnect={isAdmin}
canDisconnect={isAdmin}
lockedHint="Admin only"
busy={busy === `${row.toolkit}:org`}
onConnect={() => connect(row.toolkit, "org")}
onDisconnect={() => disconnect(row.toolkit, "org")}
/>
))
)}
</Section>
<Section
title="Your connections"
description="Your personal accounts — used for your actions and your reads."
>
{loading ? (
<CardSkeleton />
) : (
rows.map((row) => (
<AppCard
key={`user-${row.toolkit}`}
toolkit={row.toolkit}
connected={row.user}
canConnect
canDisconnect
busy={busy === `${row.toolkit}:user`}
onConnect={() => connect(row.toolkit, "user")}
onDisconnect={() => disconnect(row.toolkit, "user")}
/>
))
<p
className={cn(
dmSans125ClassName(),
"px-1 text-[13px] font-medium text-[#737373]",
)}
</Section>
>
{description}
</p>
<div className="grid gap-3 md:grid-cols-2">
{loading ? (
<>
<RowSkeleton />
<RowSkeleton />
<RowSkeleton />
</>
) : (
<>
{!shared && isStaff ? (
<form
onSubmit={connectCustom}
className="flex min-h-[152px] min-w-0 flex-col justify-between gap-3 rounded-xl bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
>
<div>
<p
className={cn(
dmSans125ClassName(),
"text-[14px] font-semibold text-[#FAFAFA]",
)}
>
Custom MCP server
</p>
<p
className={cn(
dmSans125ClassName(),
"mt-1 line-clamp-2 text-[12px] font-medium text-[#737373]",
)}
>
Add a personal OAuth MCP server by URL.
</p>
</div>
<div className="space-y-2">
<input
value={customName}
onChange={(event) => setCustomName(event.target.value)}
placeholder="Name"
className="h-8 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3 text-[12px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
/>
<input
value={customServerUrl}
onChange={(event) => setCustomServerUrl(event.target.value)}
placeholder="https://example.com/mcp"
className="h-8 w-full rounded-full border border-[#1E293B] bg-[#0D121A] px-3 text-[12px] font-medium text-[#FAFAFA] outline-none placeholder:text-[#5F6673] focus:border-[#334155]"
/>
<div className="flex justify-end border-[#1E293B]/50 border-t pt-3">
<PillButton
type="submit"
disabled={busy?.startsWith("custom:") ?? false}
>
{busy?.startsWith("custom:") && (
<Loader2 className="size-3.5 animate-spin" />
)}
Connect
</PillButton>
</div>
</div>
</form>
) : null}
{apps.map((entry) => (
<AppCard
key={`${scope}-${entry.slug}`}
name={entry.name}
subtitle={titleCase(entry.category)}
icon={brainConnectorIcon(entry.slug, entry.name)}
connected={isConnected(entry.slug, shared)}
canConnect={shared ? isAdmin : true}
canDisconnect={shared ? isAdmin : true}
lockedHint={shared ? "Admin only" : undefined}
busy={busy === `${entry.slug}:${scope}`}
onConnect={() => connect(entry, shared)}
onDisconnect={() => disconnect(entry, shared)}
/>
))}
{!shared &&
customRows.map((row) => (
<AppCard
key={`custom-${row.serverSlug}`}
name={titleCase(row.serverSlug.replace(/-/g, " "))}
subtitle={row.serverUrl ?? "Custom OAuth MCP"}
icon={brainConnectorIcon(row.serverSlug, row.serverSlug)}
connected
canConnect={false}
canDisconnect
busy={busy === `${row.serverSlug}:user`}
onConnect={() => {}}
onDisconnect={() =>
disconnect(
{
slug: row.serverSlug,
name: titleCase(row.serverSlug.replace(/-/g, " ")),
category: "Custom OAuth MCP",
authType: "oauth",
},
false,
)
}
/>
))}
</>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,29 @@
export function ProactivenessIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M18.8284 18.8284C17.6569 20 15.7712 20 12 20C8.22876 20 6.34315 20 5.17157 18.8284C4 17.6569 4 15.7712 4 12C4 8.22876 4 6.34315 5.17157 5.17157C6.34315 4 8.22876 4 12 4C15.7712 4 17.6569 4 18.8284 5.17157C20 6.34315 20 8.22876 20 12C20 15.7712 20 17.6569 18.8284 18.8284Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 2V4M16 2V4M12 2V4M8 20V22M12 20V22M16 20V22M22 16H20M4 8H2M4 16H2M4 12H2M22 8H20M22 12H20"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M11.4802 7.86193C11.6587 7.37936 12.3413 7.37936 12.5198 7.86193L13.3202 10.0248C13.4325 10.3283 13.6717 10.5675 13.9752 10.6798L16.1381 11.4802C16.6206 11.6587 16.6206 12.3413 16.1381 12.5198L13.9752 13.3202C13.6717 13.4325 13.4325 13.6717 13.3202 13.9752L12.5198 16.1381C12.3413 16.6206 11.6587 16.6206 11.4802 16.1381L10.6798 13.9752C10.5675 13.6717 10.3283 13.4325 10.0248 13.3202L7.86193 12.5198C7.37936 12.3413 7.37936 11.6587 7.86193 11.4802L10.0248 10.6798C10.3283 10.5675 10.5675 10.3283 10.6798 10.0248L11.4802 7.86193Z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View file

@ -0,0 +1,26 @@
"use client"
import { cn } from "@lib/utils"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { dmSans125ClassName } from "@/lib/fonts"
import CompanyBrainAutomations from "./company-brain-automations"
export default function Proactiveness() {
const isCompanyBrain = useHasCompanyBrain()
if (!isCompanyBrain) {
return (
<div className="px-1 pt-2">
<p className={cn(dmSans125ClassName(), "text-[13px] text-[#6B6B6B]")}>
Company Brain isn't enabled for this organization.
</p>
</div>
)
}
return (
<div className="flex flex-col gap-6">
<CompanyBrainAutomations />
</div>
)
}

View file

@ -11,12 +11,15 @@ import Billing from "@/components/settings/billing"
import Integrations from "@/components/settings/integrations"
import ConnectionsMCP from "@/components/settings/connections-mcp"
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
import Proactiveness from "@/components/settings/proactiveness"
import Support from "@/components/settings/support"
import { ErrorBoundary } from "@/components/error-boundary"
import { useRouter } from "next/navigation"
import { useQuery } from "@tanstack/react-query"
import { useIsMobile } from "@hooks/use-mobile"
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import {
LogOut,
RotateCcw,
@ -52,6 +55,7 @@ export const TABS = [
"integrations",
"connections",
"company-brain",
"proactiveness",
"support",
] as const
export type SettingsTab = (typeof TABS)[number]
@ -91,9 +95,15 @@ const NAV_ITEMS: NavItem[] = [
{
id: "company-brain",
label: "Company Brain",
description: "GitHub & Linear — org and personal",
description: "Connect apps to your brain — org and personal",
icon: <Building2 className="size-[18px]" />,
},
{
id: "proactiveness",
label: "Proactiveness",
description: "Scheduled digests and unprompted actions",
icon: <ProactivenessIcon className="size-[18px]" />,
},
{
id: "support",
label: "Support & Help",
@ -154,6 +164,14 @@ export function SettingsContent({
showIdentity?: boolean
}) {
const { user, org, organizations, setActiveOrg, clearActiveOrg } = useAuth()
const isCompanyBrain = useHasCompanyBrain()
// Company Brain orgs manage tools inside Company Brain; hide the generic tabs.
const navItems = isCompanyBrain
? NAV_ITEMS.filter(
(item) => item.id !== "integrations" && item.id !== "connections",
)
: NAV_ITEMS
const router = useRouter()
const isMobile = useIsMobile()
const localStorageUsername = useLocalStorageUsername()
@ -307,7 +325,7 @@ export function SettingsContent({
dmSansClassName(),
)}
>
{NAV_ITEMS.map((item) => {
{navItems.map((item) => {
const isExternal = item.id === "integrations"
const isActive = !isExternal && activeTab === item.id
return (
@ -481,6 +499,7 @@ export function SettingsContent({
{activeTab === "integrations" && <Integrations />}
{activeTab === "connections" && <ConnectionsMCP />}
{activeTab === "company-brain" && <CompanyBrainConnections />}
{activeTab === "proactiveness" && <Proactiveness />}
{activeTab === "support" && <Support />}
</ErrorBoundary>
</section>

View file

@ -6,6 +6,7 @@ import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { isYouTubeUrl } from "@/lib/url-helpers"
import { SyncLogoIcon } from "@ui/assets/icons"
import { DocumentIcon } from "@/components/document-icon"
import { CheckIcon, ChevronDownIcon } from "lucide-react"
@ -41,7 +42,7 @@ type CategoryInfo = { label: string; singularLabel: string; key: string }
function getDocumentTypeInfo(doc: DocumentWithMemories): CategoryInfo {
if (doc.source === "mcp")
return { label: "MCP Items", singularLabel: "MCP Item", key: "mcp" }
if (doc.url?.includes("youtube.com") || doc.url?.includes("youtu.be"))
if (isYouTubeUrl(doc.url))
return {
label: "YouTube Videos",
singularLabel: "YouTube Video",

View file

@ -12,12 +12,23 @@ import {
} from "@ui/components/dropdown-menu"
import { authClient } from "@lib/auth"
import { useRouter } from "next/navigation"
import { LogOut, Settings, RotateCcw, HelpCircle, LifeBuoy } from "lucide-react"
import {
LogOut,
Settings,
RotateCcw,
HelpCircle,
LifeBuoy,
Building2,
Sun,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
import { useTokenUsage } from "@/hooks/use-token-usage"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { useViewMode } from "@/lib/view-mode-context"
export function UserProfileMenu({
className,
@ -31,6 +42,8 @@ export function UserProfileMenu({
const { user } = useAuth()
const router = useRouter()
const { openSettings } = useSettingsModal()
const { setViewMode } = useViewMode()
const isCompanyBrain = useHasCompanyBrain()
const { resetOrgOnboarded } = useOrgOnboarding()
const autumn = useCustomer()
const { currentPlan, isLoading: planLoading } = useTokenUsage(autumn)
@ -152,6 +165,31 @@ export function UserProfileMenu({
<Settings className="size-4 text-[#737373]" />
Settings
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openSettings("company-brain")}
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<Building2 className="size-4 text-[#737373]" />
Company Brain
</DropdownMenuItem>
{isCompanyBrain ? (
<DropdownMenuItem
onClick={() => openSettings("proactiveness")}
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<ProactivenessIcon className="size-4 text-[#737373]" />
Proactiveness
</DropdownMenuItem>
) : null}
{isCompanyBrain ? (
<DropdownMenuItem
onClick={() => void setViewMode("integrations")}
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"
>
<Sun className="size-4 text-[#737373]" />
Integrations
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onClick={handleTryOnboarding}
className="gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-white/85 hover:bg-white/[0.06] focus:bg-white/[0.06] focus:text-white cursor-pointer"

View file

@ -1,15 +1,9 @@
"use client"
import { useQuery } from "@tanstack/react-query"
import { isYouTubeUrl } from "@/lib/url-helpers"
export function isYouTubeUrl(url: string | undefined | null): boolean {
if (!url) return false
return (
url.includes("youtube.com") ||
url.includes("youtu.be") ||
url.includes("m.youtube.com")
)
}
export { isYouTubeUrl }
export function extractYouTubeVideoId(
url: string | undefined | null,

View file

@ -0,0 +1,19 @@
import { useCustomer } from "autumn-js/react"
import { hasActivePlan } from "@lib/queries"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
// Connector entitlement (pro tier or company_brain) — mirrors backend canAccessConnector. Not for plugins.
export function useConnectorAccess(opts?: { enabled?: boolean }) {
const enabled = opts?.enabled ?? true
const autumn = useCustomer({ queryOptions: { enabled } })
const hasCompanyBrain = useHasCompanyBrain()
const hasPro = enabled && hasActivePlan(autumn.data?.subscriptions, "api_pro")
const hasMax = enabled && hasActivePlan(autumn.data?.subscriptions, "api_max")
return {
hasPro,
hasMax,
hasCompanyBrain,
connectorAccess: hasPro || hasCompanyBrain,
loading: enabled && autumn.isLoading,
}
}

View file

@ -10,6 +10,7 @@ import { $fetch } from "@lib/api"
import { useAuth } from "@lib/auth-context"
import { analytics } from "@/lib/analytics"
import { fetchSpaceSettings, spaceSettingsKey } from "@/hooks/use-space-context"
import { getBackendUrl } from "@/lib/url-helpers"
/** Pull the human-readable message out of a $fetch error (handles `{error}`/`{message}`/string). */
function fetchErrorMessage(err: unknown, fallback: string): string {
@ -500,14 +501,11 @@ export function useDocumentMutations({
}
formData.append("metadata", JSON.stringify({ sm_source: "consumer" }))
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/documents/file`,
{
method: "POST",
body: formData,
credentials: "include",
},
)
const response = await fetch(`${getBackendUrl()}/v3/documents/file`, {
method: "POST",
body: formData,
credentials: "include",
})
if (!response.ok) {
let message = "Failed to upload file"

View file

@ -0,0 +1,109 @@
"use client"
import { $fetch } from "@lib/api"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useCallback } from "react"
import { toast } from "sonner"
export type InferredMemory = {
id: string
memory: string
parentCount: number
createdAt: string
updatedAt: string
metadata: Record<string, unknown> | null
}
export type ReviewAction = "approve" | "decline" | "undo"
const inferredKey = (containerTag: string | undefined) =>
["inferred-memories", containerTag] as const
// Inferred memories the engine wasn't fully confident about, awaiting review.
export function useInferredMemories(containerTag: string | undefined) {
return useQuery({
queryKey: inferredKey(containerTag),
queryFn: async (): Promise<InferredMemory[]> => {
if (!containerTag) return []
const res = await $fetch("@get/container-tags/:containerTag/inferred", {
params: { containerTag },
})
if (res.error) throw new Error(res.error?.message ?? "Failed to load")
return res.data?.memories ?? []
},
enabled: !!containerTag,
staleTime: 60 * 1000,
})
}
export function useReviewInferredMemory(containerTag: string | undefined) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
memoryId,
action,
}: {
memoryId: string
action: ReviewAction
}) => {
if (!containerTag) throw new Error("Missing container tag")
const res = await $fetch(
"@post/container-tags/:containerTag/inferred/:memoryId/review",
{ params: { containerTag, memoryId }, body: { action } },
)
if (res.error) throw new Error(res.error?.message ?? "Review failed")
return res.data
},
// The modal pops cards off its local stack as you swipe, so we just drop
// the reviewed entry from the cached queue once the request settles.
// Undo restores the memory server-side, so refetch to bring it back.
onSuccess: (_data, { memoryId, action }) => {
if (action === "undo") {
queryClient.invalidateQueries({
queryKey: inferredKey(containerTag),
})
return
}
queryClient.setQueryData<InferredMemory[]>(
inferredKey(containerTag),
(prev) => prev?.filter((m) => m.id !== memoryId),
)
},
// The card already flew off the stack, so a silent failure would read as
// "saved". Surface it; the entry stays cached (onSuccess never ran) so it
// resurfaces for review later.
onError: (_err, { action }) => {
toast.error(
action === "undo"
? "Couldn't undo that review. Try again."
: "Couldn't save your review. It'll resurface for review later.",
)
},
})
}
// Session-local queue edits for decisions that don't hit the server (skip),
// so the trigger's live count reflects them and the prompt can be dismissed.
export function useInferredMemoryCache(containerTag: string | undefined) {
const queryClient = useQueryClient()
const drop = useCallback(
(memoryId: string) =>
queryClient.setQueryData<InferredMemory[]>(
inferredKey(containerTag),
(prev) => prev?.filter((m) => m.id !== memoryId),
),
[queryClient, containerTag],
)
const restore = useCallback(
(memory: InferredMemory) =>
queryClient.setQueryData<InferredMemory[]>(
inferredKey(containerTag),
(prev) =>
prev?.some((m) => m.id === memory.id)
? prev
: [...(prev ?? []), memory],
),
[queryClient, containerTag],
)
return { drop, restore }
}

View file

@ -37,12 +37,20 @@ export function useProcessingDocuments() {
staleTime: 0,
})
const docs =
(
data as
| { documents?: Array<{ id?: string | null; status?: string | null }> }
| undefined
)?.documents ?? []
// Memoized on `data` (kept referentially stable between polls by React
// Query's structural sharing) so `processingMap` only changes identity
// when the poll payload actually changes — the effect below depends on it.
const docs = useMemo(
() =>
(
data as
| {
documents?: Array<{ id?: string | null; status?: string | null }>
}
| undefined
)?.documents ?? [],
[data],
)
const processingMap = useMemo(() => {
const map = new Map<string, string>()
@ -52,7 +60,6 @@ export function useProcessingDocuments() {
}
}
return map
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docs])
// Detect docs that just finished (present in previous poll, absent now).
@ -80,8 +87,10 @@ export function useProcessingDocuments() {
clearTimeout(t1)
clearTimeout(t2)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [processingMap.keys, queryClient.refetchQueries])
// `processingMap` (not `processingMap.keys` — that's the shared
// Map.prototype method, identical for every map, so the effect would
// never re-run and finished docs would never trigger a refresh).
}, [processingMap, queryClient])
return processingMap
}

View file

@ -0,0 +1,64 @@
"use client"
import { useQuery } from "@tanstack/react-query"
import { useAuth } from "@lib/auth-context"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const POLL_INTERVAL_MS = 2_000
const MAX_POLLS = 150
export type ResearchStat = { label: string; value: string }
export type ResearchEvent = {
aspect: string
label: string
status: "in_progress" | "complete" | "error" | string
detail: string | null
stats: ResearchStat[]
highlights: string[]
sources: string[]
createdAt: number
}
export type ResearchState = {
status: "queued" | "running" | "done" | null
domain: string | null
findings: number
events: ResearchEvent[]
}
const EMPTY: ResearchState = {
status: null,
domain: null,
findings: 0,
events: [],
}
export function useResearchStatus(enabled = true) {
const { org } = useAuth()
const orgId = org?.id
const { data } = useQuery<ResearchState>({
queryKey: ["brain-research-status", orgId],
queryFn: async () => {
const res = await fetch(`${BACKEND}/brain/research/status`, {
credentials: "include",
headers: { "X-App-Source": "nova" },
})
if (!res.ok) return EMPTY
return (await res.json()) as ResearchState
},
enabled: Boolean(enabled && orgId),
refetchInterval: (query) => {
const status = query.state.data?.status
const polls = query.state.dataUpdateCount
if (status === "done" || polls >= MAX_POLLS) return false
return POLL_INTERVAL_MS
},
staleTime: 0,
})
return data ?? EMPTY
}

View file

@ -222,7 +222,14 @@ export const analytics = {
// settings / spaces / docs analytics
settingsTabChanged: (props: {
tab: "account" | "billing" | "integrations" | "connections" | "support"
tab:
| "account"
| "billing"
| "integrations"
| "connections"
| "company-brain"
| "proactiveness"
| "support"
}) => safeCapture("settings_tab_changed", props),
spaceCreated: () => safeCapture("space_created"),

View file

@ -71,6 +71,26 @@ export function getSignupSource(
: null
}
// Company domain captured during team onboarding.
export function getBrainWorkspaceDomain(
metadataRaw: Record<string, unknown> | string | null | undefined,
): string | null {
if (!metadataRaw) return null
let metadata: Record<string, unknown>
if (typeof metadataRaw === "string") {
try {
metadata = JSON.parse(metadataRaw) as Record<string, unknown>
} catch {
return null
}
} else {
metadata = metadataRaw
}
return typeof metadata.brainWorkspaceDomain === "string"
? (metadata.brainWorkspaceDomain as string)
: null
}
// Brain mode chosen during onboarding ("personal" | "team"). Set synchronously
// at org creation, so it's the reliable pre-webhook signal for company brain.
export function getBrainMode(

View file

@ -0,0 +1,69 @@
import { describe, expect, it } from "bun:test"
import { extractUrls } from "./url-helpers"
describe("extractUrls", () => {
it("extracts bare, markdown, and angle-bracket links", () => {
const { urls } = extractUrls(
"see https://a.example/one, [docs](https://b.example/two) and <https://c.example/three>",
)
expect(urls).toEqual([
"https://a.example/one",
"https://b.example/two",
"https://c.example/three",
])
})
it("normalizes scheme-less URLs", () => {
const { urls } = extractUrls("check supermemory.ai for details")
expect(urls).toEqual(["https://supermemory.ai"])
})
it("does not extract URLs from email addresses", () => {
const result = extractUrls("email me at john.doe@example.com")
expect(result.urls).toEqual([])
expect(result.duplicates).toBe(0)
})
it("keeps real URLs while skipping emails in the same text", () => {
const { urls } = extractUrls(
"email john.doe@example.com or visit https://supermemory.ai",
)
expect(urls).toEqual(["https://supermemory.ai"])
})
it("skips multiple email addresses", () => {
const { urls } = extractUrls(
"contacts: a.person@foo.example, b.person@bar.example",
)
expect(urls).toEqual([])
})
it("strips trailing punctuation", () => {
const { urls } = extractUrls("read https://example.com/post.")
expect(urls).toEqual(["https://example.com/post"])
})
it("dedupes URLs that differ only by scheme/host case or trailing slash", () => {
const { urls, duplicates } = extractUrls(
"HTTPS://EXAMPLE.COM/docs https://example.com/docs https://example.com/docs/",
)
expect(urls).toHaveLength(1)
expect(duplicates).toBe(2)
})
it("keeps URLs whose paths differ only by case", () => {
const { urls, duplicates } = extractUrls(
"https://example.com/Page and https://example.com/page",
)
expect(urls).toEqual([
"https://example.com/Page",
"https://example.com/page",
])
expect(duplicates).toBe(0)
})
it("returns nothing for plain text", () => {
expect(extractUrls("no links here").urls).toEqual([])
expect(extractUrls("").urls).toEqual([])
})
})

View file

@ -0,0 +1,86 @@
import { describe, expect, it } from "bun:test"
import { parsePluginDocument } from "./plugin-document"
type PluginDocumentInput = Parameters<typeof parsePluginDocument>[0]
function makeCodexSessionDocument(content: string): PluginDocumentInput {
return {
id: "doc_1",
title: "Codex session",
content,
metadata: { sm_source: "codex" },
memoryEntries: [],
} as unknown as PluginDocumentInput
}
describe("parsePluginDocument — session transcripts", () => {
it("keeps multi-line message bodies intact", () => {
const parsed = parsePluginDocument(
makeCodexSessionDocument(
[
"[Session abc-123]",
"1. [user] Hello there",
"Here is more context on line two",
"2. [assistant] Sure!",
"Second line of the reply",
].join("\n"),
),
)
expect(parsed).not.toBeNull()
expect(parsed?.kind).toBe("codex-session")
expect(parsed?.messages).toHaveLength(2)
expect(parsed?.messages[0]?.text).toBe(
"Hello there\nHere is more context on line two",
)
expect(parsed?.messages[1]?.text).toBe("Sure!\nSecond line of the reply")
})
it("surfaces memory id artifacts from continuation lines", () => {
const parsed = parsePluginDocument(
makeCodexSessionDocument(
[
"[Session abc-123]",
"1. [user] Remember my editor is Neovim",
"memory id: mem_456",
"2. [assistant] Saved it.",
].join("\n"),
),
)
expect(parsed?.messages[0]?.text).toBe("Remember my editor is Neovim")
expect(parsed?.artifacts).toContainEqual({
label: "Memory ID",
value: "mem_456",
})
})
it("normalizes literal \\n escapes before splitting messages", () => {
const parsed = parsePluginDocument(
makeCodexSessionDocument(
"[Session abc-123]\\n1. [user] First line\\nSecond line\\n2. [assistant] Reply",
),
)
expect(parsed?.messages).toHaveLength(2)
expect(parsed?.messages[0]?.text).toBe("First line\nSecond line")
expect(parsed?.messages[1]?.text).toBe("Reply")
})
it("parses single-line messages as before", () => {
const parsed = parsePluginDocument(
makeCodexSessionDocument(
["[Session abc-123]", "1. [user] Hi", "2. [assistant] Hello!"].join(
"\n",
),
),
)
expect(parsed?.messages).toHaveLength(2)
expect(parsed?.messages[0]?.text).toBe("Hi")
expect(parsed?.messages[1]?.text).toBe("Hello!")
expect(parsed?.summary).toBe(
"1 user message and 1 assistant message captured from Codex.",
)
})
})

View file

@ -227,8 +227,11 @@ function parseTranscriptMessages(content: string): {
} {
const messages: PluginDocumentMessage[] = []
const artifacts: PluginArtifact[] = []
// The lookahead must end the last message at the true end of input:
// with the m flag, a bare $ matches every line end and would cut each
// message body off at its first newline.
const regex = new RegExp(
`^\\s*(\\d+)\\.\\s+\\[(${TRANSCRIPT_ROLE_PATTERN})\\]\\s*([\\s\\S]*?)(?=^\\s*\\d+\\.\\s+\\[(?:${TRANSCRIPT_ROLE_PATTERN})\\]\\s*|$)`,
`^\\s*(\\d+)\\.\\s+\\[(${TRANSCRIPT_ROLE_PATTERN})\\]\\s*([\\s\\S]*?)(?=^\\s*\\d+\\.\\s+\\[(?:${TRANSCRIPT_ROLE_PATTERN})\\]\\s*|(?![\\s\\S]))`,
"gm",
)

View file

@ -0,0 +1,73 @@
import { describe, expect, it } from "bun:test"
import { isYouTubeUrl } from "./url-helpers"
describe("isYouTubeUrl", () => {
it("matches canonical youtube.com watch URLs", () => {
expect(isYouTubeUrl("https://youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
expect(isYouTubeUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(
true,
)
})
it("matches real youtube subdomains", () => {
expect(isYouTubeUrl("https://m.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
expect(isYouTubeUrl("https://music.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(
true,
)
})
it("matches youtu.be short links", () => {
expect(isYouTubeUrl("https://youtu.be/dQw4w9WgXcQ")).toBe(true)
expect(isYouTubeUrl("https://www.youtu.be/dQw4w9WgXcQ")).toBe(true)
})
it("matches embed and shorts paths", () => {
expect(isYouTubeUrl("https://www.youtube.com/embed/dQw4w9WgXcQ")).toBe(true)
expect(isYouTubeUrl("https://www.youtube.com/shorts/dQw4w9WgXcQ")).toBe(
true,
)
})
it("is case-insensitive for scheme and host", () => {
expect(isYouTubeUrl("HTTPS://youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
expect(isYouTubeUrl("https://WWW.YOUTUBE.COM/watch?v=dQw4w9WgXcQ")).toBe(
true,
)
})
it("matches scheme-less URLs", () => {
expect(isYouTubeUrl("youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
expect(isYouTubeUrl("www.youtube.com/watch?v=dQw4w9WgXcQ")).toBe(true)
})
it("rejects lookalike domains", () => {
expect(isYouTubeUrl("https://notyoutube.com/watch?v=dQw4w9WgXcQ")).toBe(
false,
)
expect(isYouTubeUrl("https://myyoutu.be/dQw4w9WgXcQ")).toBe(false)
})
it("rejects hosts that merely start with youtube.com", () => {
expect(
isYouTubeUrl("https://youtube.com.evil.example/watch?v=dQw4w9WgXcQ"),
).toBe(false)
expect(isYouTubeUrl("https://youtu.be.evil.example/dQw4w9WgXcQ")).toBe(
false,
)
})
it("rejects URLs that only contain youtube.com in the path", () => {
expect(
isYouTubeUrl("https://evil.example/youtube.com/watch?v=dQw4w9WgXcQ"),
).toBe(false)
expect(isYouTubeUrl("https://evil.example/redirect?to=youtu.be/x")).toBe(
false,
)
})
it("rejects empty and nullish input", () => {
expect(isYouTubeUrl("")).toBe(false)
expect(isYouTubeUrl(null)).toBe(false)
expect(isYouTubeUrl(undefined)).toBe(false)
})
})

View file

@ -1,7 +1,15 @@
const PROXY_LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"])
const DEFAULT_BACKEND_URL = "https://api.supermemory.ai"
const DEV_APP_ORIGIN = "https://app.dev.supermemory.ai"
const PROD_APP_ORIGIN = "https://app.supermemory.ai"
export function getBackendUrl(): string {
return (process.env.NEXT_PUBLIC_BACKEND_URL ?? DEFAULT_BACKEND_URL).replace(
/\/+$/,
"",
)
}
export function getAppOriginForCurrentEnvironment(hostname?: string): string {
const currentHostname =
hostname ?? (typeof window !== "undefined" ? window.location.hostname : "")
@ -96,12 +104,20 @@ export const extractUrls = (
const unwrapped = text
.replace(MARKDOWN_LINK_REGEX, " $1 ")
.replace(ANGLE_LINK_REGEX, " $1 ")
const matches = unwrapped.match(URL_TOKEN_REGEX) ?? []
const seen = new Set<string>()
const urls: string[] = []
let duplicates = 0
for (const match of matches) {
let trimmed = match.trim().replace(/[.,;!]+$/, "")
for (const match of unwrapped.matchAll(URL_TOKEN_REGEX)) {
const start = match.index ?? 0
const end = start + match[0].length
const before = start > 0 ? (unwrapped[start - 1] ?? "") : ""
const after = end < unwrapped.length ? (unwrapped[end] ?? "") : ""
// Skip email addresses: a domain-shaped token ending at "@" is the
// local part, one starting right after "@" is the mail domain. Also
// skip matches that begin mid-token (e.g. after "_", which the
// hostname charset can't include) — those aren't standalone URLs.
if (after === "@" || before === "@" || /[\w.-]/.test(before)) continue
let trimmed = match[0].trim().replace(/[.,;!]+$/, "")
const opens = (trimmed.match(/\(/g) ?? []).length
const closes = (trimmed.match(/\)/g) ?? []).length
if (closes > opens && trimmed.endsWith(")")) {
@ -109,7 +125,15 @@ export const extractUrls = (
}
const normalized = normalizeUrl(trimmed)
if (!isValidUrl(normalized)) continue
const key = normalized.toLowerCase().replace(/\/+$/, "")
// Dedupe on the parsed URL so the scheme and host compare
// case-insensitively while the path/query — which are case-sensitive
// resources — stay distinct.
const parsed = new URL(normalized)
const key =
`${parsed.origin}${parsed.pathname}${parsed.search}${parsed.hash}`.replace(
/\/+$/,
"",
)
if (seen.has(key)) {
duplicates++
continue
@ -157,6 +181,22 @@ export const isTwitterUrl = (url: string): boolean => {
)
}
/**
* Checks if a URL is a YouTube URL by matching the hostname against
* youtube.com / youtu.be (and their subdomains, e.g. www / m).
* Lookalike hosts (`notyoutube.com`, `youtube.com.evil.example`) and URLs
* that only contain "youtube.com" in the path do not match.
*/
export const isYouTubeUrl = (url: string | undefined | null): boolean => {
if (!url) return false
const parsed = parseWebUrl(url)
if (!parsed) return false
return (
hostnameMatches(parsed.hostname, "youtube.com") ||
hostnameMatches(parsed.hostname, "youtu.be")
)
}
/**
* Checks if a URL is a LinkedIn profile URL (not a company page).
*/

View file

@ -2,6 +2,8 @@ import { getSessionCookie } from "better-auth/cookies"
import { NextResponse } from "next/server"
import { getPublicRequestUrl } from "@/lib/url-helpers"
const LOCAL_DEV_HOSTS = new Set(["localhost", "127.0.0.1", "::1"])
function getAuthSessionCookie(request: Request): string | null {
return (
getSessionCookie(request) ??
@ -16,6 +18,18 @@ export default async function proxy(request: Request) {
console.debug("[PROXY] Path:", url.pathname)
console.debug("[PROXY] Method:", request.method)
// Development builds only: getPublicRequestUrl trusts x-forwarded-host, so
// a hostname check alone could be spoofed in production to skip the /api
// 401 gate below. NODE_ENV is inlined at build time, making this dead code
// in production bundles.
if (
process.env.NODE_ENV === "development" &&
LOCAL_DEV_HOSTS.has(url.hostname)
) {
console.debug("[PROXY] Local dev host, allowing access")
return NextResponse.next()
}
const sessionCookie = getAuthSessionCookie(request)
console.debug("[PROXY] Session cookie exists:", !!sessionCookie)

View file

@ -166,20 +166,26 @@ class SupermemoryCartesiaAgent:
timeout=10.0
)
static_count = len(response.profile.static) if response.profile.static else 0
dynamic_count = len(response.profile.dynamic) if response.profile.dynamic else 0
search_count = len(response.search_results.results) if response.search_results and response.search_results.results else 0
logger.info(f"[Supermemory] Retrieved memories - static: {static_count}, dynamic: {dynamic_count}, search: {search_count}")
# A user with no stored memories yet gets a null profile back, which
# is a normal case, not an error. Guard against it so we return an
# empty profile instead of raising AttributeError on response.profile.
profile = getattr(response, "profile", None)
profile_static = profile.static if profile is not None and profile.static else []
profile_dynamic = profile.dynamic if profile is not None and profile.dynamic else []
search_results = []
if response.search_results and response.search_results.results:
search_results = response.search_results.results
logger.info(
f"[Supermemory] Retrieved memories - static: {len(profile_static)}, "
f"dynamic: {len(profile_dynamic)}, search: {len(search_results)}"
)
return {
"profile": {
"static": response.profile.static or [],
"dynamic": response.profile.dynamic or [],
"static": profile_static,
"dynamic": profile_dynamic,
},
"search_results": search_results,
}

View file

@ -0,0 +1,77 @@
from __future__ import annotations
import sys
import types
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock
def _install_test_stubs() -> None:
if "loguru" not in sys.modules:
loguru_module = types.ModuleType("loguru")
class _Logger:
def info(self, *_args, **_kwargs):
return None
def warning(self, *_args, **_kwargs):
return None
def error(self, *_args, **_kwargs):
return None
loguru_module.logger = _Logger()
sys.modules["loguru"] = loguru_module
if "pydantic" not in sys.modules:
pydantic_module = types.ModuleType("pydantic")
class BaseModel:
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
def Field(*, default=None, **_kwargs):
return default
pydantic_module.BaseModel = BaseModel
pydantic_module.Field = Field
sys.modules["pydantic"] = pydantic_module
_install_test_stubs()
from supermemory_cartesia.agent import SupermemoryCartesiaAgent
class _MockSupermemoryClient:
def __init__(self, response):
self.profile = AsyncMock(return_value=response)
class TestSupermemoryCartesiaNullProfile(unittest.IsolatedAsyncioTestCase):
async def test_retrieve_memories_handles_null_profile(self) -> None:
agent = SupermemoryCartesiaAgent(
agent=SimpleNamespace(),
api_key="mock_key",
container_tag="user-123",
custom_id="conversation-456",
)
response = SimpleNamespace(profile=None, search_results=None)
agent._supermemory_client = _MockSupermemoryClient(response)
result = await agent._retrieve_memories("Hello world")
self.assertEqual(
result,
{
"profile": {"static": [], "dynamic": []},
"search_results": [],
},
)
if __name__ == "__main__":
unittest.main()

View file

@ -57,6 +57,34 @@ const WaitlistStatusResponseSchema = z.object({
})
export const apiSchema = createSchema({
// Inferred-memory review queue (Nova "Suggested for you")
"@get/container-tags/:containerTag/inferred": {
output: z.object({
memories: z.array(
z.object({
id: z.string(),
memory: z.string(),
parentCount: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
metadata: z.record(z.string(), z.unknown()).nullable(),
}),
),
total: z.number(),
}),
params: z.object({ containerTag: z.string() }),
},
"@post/container-tags/:containerTag/inferred/:memoryId/review": {
input: z.object({ action: z.enum(["approve", "decline", "undo"]) }),
output: z.object({
id: z.string(),
isInference: z.boolean(),
reviewStatus: z.enum(["approved", "declined"]).nullable(),
}),
params: z.object({ containerTag: z.string(), memoryId: z.string() }),
},
"@get/analytics/chat": {
output: AnalyticsChatResponseSchema,
query: AnalyticsRequestSchema,

View file

@ -1,6 +1,7 @@
import { customAlphabet } from "nanoid"
export const generateId = () =>
customAlphabet("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz")(
22,
)
const generate = customAlphabet(
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz",
)
export const generateId = () => generate(22)

View file

@ -0,0 +1,154 @@
/**
* Tests for configurable labels and hover popover layering.
*
* Follows the same pattern as node-hover-popover.test.tsx: source-code
* assertions plus pure-logic tests on the exported defaults and merge
* behaviour, since hook-bearing components can't be mounted in this
* workspace's vitest environment.
*/
import { readFileSync } from "node:fs"
import { resolve } from "node:path"
import { describe, expect, it } from "vitest"
import { DEFAULT_HOVER_POPOVER_Z_INDEX, DEFAULT_LABELS } from "../constants"
import type { MemoryGraphLabels, ResolvedMemoryGraphLabels } from "../types"
const popoverSrc = readFileSync(
resolve(__dirname, "../components/node-hover-popover.tsx"),
"utf-8",
)
const legendSrc = readFileSync(
resolve(__dirname, "../components/legend.tsx"),
"utf-8",
)
const graphSrc = readFileSync(
resolve(__dirname, "../components/memory-graph.tsx"),
"utf-8",
)
const indexSrc = readFileSync(resolve(__dirname, "../index.tsx"), "utf-8")
const loadingSrc = readFileSync(
resolve(__dirname, "../components/loading-indicator.tsx"),
"utf-8",
)
describe("DEFAULT_LABELS — preserves original copy", () => {
it("matches every previously hardcoded string", () => {
expect(DEFAULT_LABELS.documentGroup).toBe("Documents")
expect(DEFAULT_LABELS.documentTypeFallback).toBe("document")
expect(DEFAULT_LABELS.documentSourceEdge).toBe("Document source")
expect(DEFAULT_LABELS.documentToMemoryEdge).toBe("Document to memory")
expect(DEFAULT_LABELS.documentIdLabel).toBe("Document")
expect(DEFAULT_LABELS.viewDocument).toBe("View document")
expect(DEFAULT_LABELS.goToDocument).toBe("Go to document")
expect(DEFAULT_LABELS.nextDocument).toBe("Next document")
expect(DEFAULT_LABELS.previousDocument).toBe("Prev document")
expect(DEFAULT_LABELS.showMemory).toBe("Go to memory")
})
it("memoryCount formats counts like the original template", () => {
expect(DEFAULT_LABELS.memoryCount(0)).toBe("0 memories")
expect(DEFAULT_LABELS.memoryCount(5)).toBe("5 memories")
})
it("loadingMoreDocuments formats counts like the original template", () => {
expect(DEFAULT_LABELS.loadingMoreDocuments(12)).toBe(
"Loading more documents... (12)",
)
})
})
describe("label merge — partial overrides win, defaults fill the rest", () => {
it("spread merge overrides only the provided keys", () => {
const overrides: MemoryGraphLabels = {
documentGroup: "Sources",
viewDocument: "View source",
}
const resolved: ResolvedMemoryGraphLabels = {
...DEFAULT_LABELS,
...overrides,
}
expect(resolved.documentGroup).toBe("Sources")
expect(resolved.viewDocument).toBe("View source")
expect(resolved.nextDocument).toBe("Next document")
expect(resolved.memoryCount(3)).toBe("3 memories")
})
it("custom memoryCount function replaces the default", () => {
const resolved: ResolvedMemoryGraphLabels = {
...DEFAULT_LABELS,
memoryCount: (count) => `${count} facts`,
}
expect(resolved.memoryCount(2)).toBe("2 facts")
})
})
describe("hover popover layering", () => {
it("default z-index stays at the previous hardcoded value", () => {
expect(DEFAULT_HOVER_POPOVER_Z_INDEX).toBe(100)
})
it("popover overlay uses the zIndex prop, not a literal", () => {
expect(popoverSrc).not.toContain("zIndex: 100")
const overlayIdx = popoverSrc.indexOf("const overlayStyle")
const overlayEnd = popoverSrc.indexOf("}", overlayIdx)
expect(popoverSrc.slice(overlayIdx, overlayEnd)).toContain("zIndex,")
})
it("popover defaults zIndex to DEFAULT_HOVER_POPOVER_Z_INDEX", () => {
expect(popoverSrc).toContain("zIndex = DEFAULT_HOVER_POPOVER_Z_INDEX")
})
it("MemoryGraph resolves layering.hoverPopoverZIndex with fallback", () => {
expect(graphSrc).toContain(
"layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX",
)
expect(graphSrc).toContain("zIndex={hoverPopoverZIndex}")
})
})
describe("no user-facing document strings remain hardcoded", () => {
it("popover uses labels for all document-facing copy", () => {
expect(popoverSrc).not.toContain('"View document"')
expect(popoverSrc).not.toContain('"Go to document"')
expect(popoverSrc).not.toContain('"Next document"')
expect(popoverSrc).not.toContain('"Prev document"')
expect(popoverSrc).not.toContain('label="Document"')
expect(popoverSrc).not.toContain('|| "document"')
expect(popoverSrc).toContain("labels.documentTypeFallback")
expect(popoverSrc).toContain("labels.memoryCount(")
expect(popoverSrc).toContain("labels.documentIdLabel")
expect(popoverSrc).toContain("labels.viewDocument")
expect(popoverSrc).toContain("labels.goToDocument")
expect(popoverSrc).toContain("labels.showMemory")
expect(popoverSrc).toContain("labels.nextDocument")
expect(popoverSrc).toContain("labels.previousDocument")
})
it("legend uses labels for group and edge copy", () => {
expect(legendSrc).not.toContain('label="Documents"')
expect(legendSrc).not.toContain("Document source</span>")
expect(legendSrc).not.toContain("Document to memory")
expect(legendSrc).toContain("labels.documentGroup")
expect(legendSrc).toContain("labels.documentSourceEdge")
expect(legendSrc).toContain("labels.documentToMemoryEdge")
})
it("loading indicator uses labels for the loading-more copy", () => {
expect(loadingSrc).not.toContain("Loading more documents")
expect(loadingSrc).toContain("labels.loadingMoreDocuments(totalLoaded)")
})
it("MemoryGraph merges label overrides and passes them down", () => {
expect(graphSrc).toContain("{ ...DEFAULT_LABELS, ...labelOverrides }")
expect(graphSrc).toContain("labels={resolvedLabels}")
})
})
describe("public API exports", () => {
it("index exports the new constants and types", () => {
expect(indexSrc).toContain("DEFAULT_LABELS")
expect(indexSrc).toContain("DEFAULT_HOVER_POPOVER_Z_INDEX")
expect(indexSrc).toContain("MemoryGraphLabels")
expect(indexSrc).toContain("MemoryGraphLayering")
})
})

View file

@ -209,8 +209,8 @@ describe("'View document' button — render guard in JSX", () => {
expect(src).toContain("onClick={() => onOpenDocument(documentId)}")
})
it("button label text is 'View document'", () => {
expect(src).toContain('label="View document"')
it("button label comes from configurable labels", () => {
expect(src).toContain("label={labels.viewDocument}")
})
it("button icon is the EyeIcon component (not a string shortcut key)", () => {

View file

@ -0,0 +1,101 @@
/**
* Mounted-render verification for configurable labels and popover layering.
*/
// @vitest-environment happy-dom
import { cleanup, render } from "@testing-library/react"
import { afterEach, describe, expect, it } from "vitest"
afterEach(cleanup)
import { NodeHoverPopover } from "../components/node-hover-popover"
import { DEFAULT_COLORS, DEFAULT_LABELS } from "../constants"
import type { GraphNode, ResolvedMemoryGraphLabels } from "../types"
const documentNode: GraphNode = {
id: "doc-1",
type: "document",
x: 0,
y: 0,
data: {
id: "doc-1",
title: "Doc title",
summary: "Doc summary",
type: "",
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
memories: [],
},
size: 40,
borderColor: "#fff",
isHovered: false,
isDragging: false,
}
function renderPopover(
labels?: ResolvedMemoryGraphLabels,
zIndex?: number,
onOpenDocument?: (id: string) => void,
) {
return render(
<NodeHoverPopover
colors={DEFAULT_COLORS}
labels={labels}
node={documentNode}
nodeRadius={20}
screenX={100}
screenY={100}
zIndex={zIndex}
onOpenDocument={onOpenDocument}
/>,
)
}
describe("NodeHoverPopover mounted render", () => {
it("renders default document copy when no labels are passed", () => {
const { container, getByText } = renderPopover(
undefined,
undefined,
() => {},
)
getByText("View document")
getByText("Go to memory")
getByText("Next document")
getByText("Prev document")
getByText("document")
getByText("Document")
getByText("0 memories")
const overlay = container.firstChild as HTMLElement
expect(overlay.style.zIndex).toBe("100")
})
it("renders custom source-facing copy and custom z-index", () => {
const labels: ResolvedMemoryGraphLabels = {
...DEFAULT_LABELS,
documentGroup: "Sources",
documentTypeFallback: "source",
documentIdLabel: "Source",
viewDocument: "View source",
goToDocument: "Go to source",
nextDocument: "Next source",
previousDocument: "Prev source",
showMemory: "Show memory",
memoryCount: (count) => `${count} facts`,
}
const { container, getByText, queryByText } = renderPopover(
labels,
30,
() => {},
)
getByText("View source")
getByText("Show memory")
getByText("Next source")
getByText("Prev source")
getByText("source")
getByText("Source")
getByText("0 facts")
expect(queryByText("View document")).toBeNull()
const overlay = container.firstChild as HTMLElement
expect(overlay.style.zIndex).toBe("30")
})
})

View file

@ -1,11 +1,18 @@
import { memo, useState } from "react"
import type { GraphEdge, GraphNode, GraphThemeColors } from "../types"
import { DEFAULT_LABELS } from "../constants"
import type {
GraphEdge,
GraphNode,
GraphThemeColors,
ResolvedMemoryGraphLabels,
} from "../types"
interface LegendProps {
nodes?: GraphNode[]
edges?: GraphEdge[]
isLoading?: boolean
colors: GraphThemeColors
labels?: ResolvedMemoryGraphLabels
hoveredNode?: string | null
compact?: boolean
maxHeight?: number
@ -291,6 +298,7 @@ export const Legend = memo(function Legend({
edges = [],
isLoading: _isLoading = false,
colors,
labels = DEFAULT_LABELS,
hoveredNode,
compact = false,
maxHeight,
@ -462,7 +470,7 @@ export const Legend = memo(function Legend({
}}
/>
}
label="Documents"
label={labels.documentGroup}
colors={colors}
/>
<StatRow
@ -523,12 +531,14 @@ export const Legend = memo(function Legend({
<div style={rowStyle}>
<div style={rowLeftStyle}>
<LineIcon color={colors.edgeDerives} />
<span style={edgeLabelStyle}>Document source</span>
<span style={edgeLabelStyle}>
{labels.documentSourceEdge}
</span>
</div>
<span style={countStyle}>{derivesCount}</span>
</div>
<div style={edgeDescriptionStyle}>
Document to memory
{labels.documentToMemoryEdge}
</div>
</div>
<div>

View file

@ -1,5 +1,10 @@
import { memo } from "react"
import type { GraphThemeColors, LoadingIndicatorProps } from "../types"
import { DEFAULT_LABELS } from "../constants"
import type {
GraphThemeColors,
LoadingIndicatorProps,
ResolvedMemoryGraphLabels,
} from "../types"
const spinKeyframes = `
@keyframes mg-spin {
@ -18,70 +23,82 @@ function injectSpinStyle() {
}
export const LoadingIndicator = memo<
LoadingIndicatorProps & { colors?: GraphThemeColors }
>(({ isLoading, isLoadingMore, totalLoaded, colors }) => {
if (!isLoading && !isLoadingMore) return null
injectSpinStyle()
const containerStyle: React.CSSProperties = {
position: "absolute",
zIndex: 30,
overflow: "hidden",
top: 16,
left: 16,
borderRadius: 12,
border: `1px solid ${colors?.controlBorder ?? "#2A2F36"}`,
backgroundColor: colors?.controlBg ?? "#1a1f29",
paddingLeft: 16,
paddingRight: 16,
paddingTop: 12,
paddingBottom: 12,
boxShadow: "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)",
LoadingIndicatorProps & {
colors?: GraphThemeColors
labels?: ResolvedMemoryGraphLabels
}
>(
({
isLoading,
isLoadingMore,
totalLoaded,
colors,
labels = DEFAULT_LABELS,
}) => {
if (!isLoading && !isLoadingMore) return null
const flexStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
}
injectSpinStyle()
const spinnerStyle: React.CSSProperties = {
width: 16,
height: 16,
animation: "mg-spin 1s linear infinite",
color: colors?.accent ?? "#3B73B8",
flexShrink: 0,
}
const containerStyle: React.CSSProperties = {
position: "absolute",
zIndex: 30,
overflow: "hidden",
top: 16,
left: 16,
borderRadius: 12,
border: `1px solid ${colors?.controlBorder ?? "#2A2F36"}`,
backgroundColor: colors?.controlBg ?? "#1a1f29",
paddingLeft: 16,
paddingRight: 16,
paddingTop: 12,
paddingBottom: 12,
boxShadow:
"0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)",
}
const textStyle: React.CSSProperties = {
fontSize: 14,
color: colors?.textSecondary ?? "#e2e8f0",
}
const flexStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 8,
}
return (
<div style={containerStyle}>
<div style={flexStyle}>
<svg
aria-hidden="true"
fill="none"
role="img"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
style={spinnerStyle}
>
<title>Loading</title>
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
</svg>
<span style={textStyle}>
{isLoading
? "Loading memory graph..."
: `Loading more documents... (${totalLoaded})`}
</span>
const spinnerStyle: React.CSSProperties = {
width: 16,
height: 16,
animation: "mg-spin 1s linear infinite",
color: colors?.accent ?? "#3B73B8",
flexShrink: 0,
}
const textStyle: React.CSSProperties = {
fontSize: 14,
color: colors?.textSecondary ?? "#e2e8f0",
}
return (
<div style={containerStyle}>
<div style={flexStyle}>
<svg
aria-hidden="true"
fill="none"
role="img"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
style={spinnerStyle}
>
<title>Loading</title>
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
</svg>
<span style={textStyle}>
{isLoading
? "Loading memory graph..."
: labels.loadingMoreDocuments(totalLoaded)}
</span>
</div>
</div>
</div>
)
})
)
},
)
LoadingIndicator.displayName = "LoadingIndicator"

View file

@ -3,6 +3,7 @@ import {
DENSE_GRAPH_STATIC_THRESHOLD,
ForceSimulation,
} from "../canvas/simulation"
import { DEFAULT_HOVER_POPOVER_Z_INDEX, DEFAULT_LABELS } from "../constants"
import { VersionChainIndex } from "../canvas/version-chain"
import type { ViewportState } from "../canvas/viewport"
import { useGraphData } from "../hooks/use-graph-data"
@ -11,6 +12,7 @@ import type {
GraphApiDocument,
GraphThemeColors,
MemoryGraphProps,
ResolvedMemoryGraphLabels,
} from "../types"
import { GraphCanvas } from "./graph-canvas"
import { Legend } from "./legend"
@ -37,7 +39,15 @@ export function MemoryGraph({
colors: colorOverrides,
totalCount,
onOpenDocument,
labels: labelOverrides,
layering,
}: MemoryGraphProps) {
const resolvedLabels = useMemo<ResolvedMemoryGraphLabels>(
() => ({ ...DEFAULT_LABELS, ...labelOverrides }),
[labelOverrides],
)
const hoverPopoverZIndex =
layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX
const resolvedColors = useGraphTheme(colorOverrides)
const colors = useMemo<GraphThemeColors>(
() =>
@ -397,8 +407,8 @@ export function MemoryGraph({
}, [containerSize.width, graphFitHeight])
// Wrap onOpenDocument to dismiss the popover before opening the modal.
// Without this, the popover (z-index: 100) stays mounted on top of the
// document modal (z-50), obscuring it and intercepting clicks.
// Without this, the popover overlay stays mounted on top of the
// document modal, obscuring it and intercepting clicks.
const handleOpenDocument = useCallback(
(documentId: string) => {
setSelectedNode(null)
@ -748,6 +758,7 @@ export function MemoryGraph({
<LoadingIndicator
isLoading={isLoading}
isLoadingMore={isLoadingMore}
labels={resolvedLabels}
totalLoaded={totalCount ?? documents.length}
colors={colors}
/>
@ -783,7 +794,9 @@ export function MemoryGraph({
<NodeHoverPopover
colors={colors}
containerBounds={containerBounds ?? undefined}
labels={resolvedLabels}
node={activeNodeData}
zIndex={hoverPopoverZIndex}
nodeRadius={activePopoverPosition.nodeRadius}
onNavigateDown={navigateDown}
onNavigateNext={navigateNext}
@ -812,6 +825,7 @@ export function MemoryGraph({
<Legend
colors={colors}
edges={edges}
labels={resolvedLabels}
hoveredNode={hoveredNode}
compact={isCompactViewport}
isLoading={isLoading}

View file

@ -1,10 +1,12 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { ChainEntry } from "../canvas/version-chain"
import { DEFAULT_HOVER_POPOVER_Z_INDEX, DEFAULT_LABELS } from "../constants"
import type {
DocumentNodeData,
GraphNode,
GraphThemeColors,
MemoryNodeData,
ResolvedMemoryGraphLabels,
} from "../types"
export interface NodeHoverPopoverProps {
@ -15,6 +17,8 @@ export interface NodeHoverPopoverProps {
containerBounds?: DOMRect
versionChain?: ChainEntry[] | null
colors: GraphThemeColors
labels?: ResolvedMemoryGraphLabels
zIndex?: number
onNavigateNext?: () => void
onNavigatePrev?: () => void
onNavigateUp?: () => void
@ -310,6 +314,8 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
containerBounds,
versionChain,
colors,
labels = DEFAULT_LABELS,
zIndex = DEFAULT_HOVER_POPOVER_Z_INDEX,
onNavigateNext,
onNavigatePrev,
onNavigateUp,
@ -419,7 +425,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
left: 0,
right: 0,
bottom: 0,
zIndex: 100,
zIndex,
}
const svgStyle: React.CSSProperties = {
@ -605,7 +611,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
color: colors.popoverTextSecondary,
}}
>
{docData?.type || "document"}
{docData?.type || labels.documentTypeFallback}
</span>
<span
style={{
@ -613,7 +619,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
color: colors.popoverTextSecondary,
}}
>
{docData?.memories?.length ?? 0} memories
{labels.memoryCount(docData?.memories?.length ?? 0)}
</span>
</>
)}
@ -623,7 +629,11 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
{isMemory ? (
<CopyableId colors={colors} label="Memory" value={node.id} />
) : (
<CopyableId colors={colors} label="Document" value={node.id} />
<CopyableId
colors={colors}
label={labels.documentIdLabel}
value={node.id}
/>
)}
</div>
</div>
@ -633,7 +643,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
<NavButton
colors={colors}
icon={<EyeIcon color={colors.popoverTextMuted} />}
label="View document"
label={labels.viewDocument}
onClick={() => onOpenDocument(documentId)}
/>
)}
@ -641,7 +651,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
<NavButton
colors={colors}
icon="↑"
label={hasChain ? "Older version" : "Go to document"}
label={hasChain ? "Older version" : labels.goToDocument}
onClick={onNavigateUp}
/>
)}
@ -649,20 +659,20 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
<NavButton
colors={colors}
icon="↓"
label={isMemory ? "Newer version" : "Go to memory"}
label={isMemory ? "Newer version" : labels.showMemory}
onClick={onNavigateDown}
/>
)}
<NavButton
colors={colors}
icon="→"
label={isMemory ? "Next memory" : "Next document"}
label={isMemory ? "Next memory" : labels.nextDocument}
onClick={onNavigateNext}
/>
<NavButton
colors={colors}
icon="←"
label={isMemory ? "Prev memory" : "Prev document"}
label={isMemory ? "Prev memory" : labels.previousDocument}
onClick={onNavigatePrev}
/>
</div>

View file

@ -1,4 +1,22 @@
import type { GraphThemeColors } from "./types"
import type { GraphThemeColors, ResolvedMemoryGraphLabels } from "./types"
export const DEFAULT_LABELS: ResolvedMemoryGraphLabels = {
documentGroup: "Documents",
documentTypeFallback: "document",
documentSourceEdge: "Document source",
documentToMemoryEdge: "Document to memory",
memoryCount: (count: number) => `${count} memories`,
documentIdLabel: "Document",
viewDocument: "View document",
goToDocument: "Go to document",
nextDocument: "Next document",
previousDocument: "Prev document",
showMemory: "Go to memory",
loadingMoreDocuments: (count: number) =>
`Loading more documents... (${count})`,
}
export const DEFAULT_HOVER_POPOVER_Z_INDEX = 100
export const MEMORY_BORDER_KEYS = {
forgotten: "memBorderForgotten",

View file

@ -13,11 +13,20 @@ export { SpatialIndex } from "./canvas/hit-test"
export { VersionChainIndex } from "./canvas/version-chain"
// Constants
export { DEFAULT_COLORS, FORCE_CONFIG, GRAPH_SETTINGS } from "./constants"
export {
DEFAULT_COLORS,
DEFAULT_HOVER_POPOVER_Z_INDEX,
DEFAULT_LABELS,
FORCE_CONFIG,
GRAPH_SETTINGS,
} from "./constants"
// Types
export type {
MemoryGraphProps,
MemoryGraphLabels,
MemoryGraphLayering,
ResolvedMemoryGraphLabels,
GraphNode,
GraphEdge,
GraphThemeColors,

View file

@ -132,6 +132,27 @@ export interface GraphThemeColors {
controlBorder: string
}
export interface MemoryGraphLabels {
documentGroup?: string
documentTypeFallback?: string
documentSourceEdge?: string
documentToMemoryEdge?: string
memoryCount?: (count: number) => string
documentIdLabel?: string
viewDocument?: string
goToDocument?: string
nextDocument?: string
previousDocument?: string
showMemory?: string
loadingMoreDocuments?: (count: number) => string
}
export type ResolvedMemoryGraphLabels = Required<MemoryGraphLabels>
export interface MemoryGraphLayering {
hoverPopoverZIndex?: number
}
export interface GraphCanvasProps {
nodes: GraphNode[]
edges: GraphEdge[]
@ -195,6 +216,10 @@ export interface MemoryGraphProps {
totalCount?: number
/** Callback when user wants to view full document content */
onOpenDocument?: (documentId: string) => void
/** Custom user-facing labels (partial) - merged with defaults */
labels?: MemoryGraphLabels
/** Overlay layering controls */
layering?: MemoryGraphLayering
}
export interface ChainEntry {

View file

@ -1,7 +1,7 @@
{
"name": "@supermemory/tools",
"type": "module",
"version": "2.0.0",
"version": "2.1.0",
"description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory",
"scripts": {
"build": "tsdown",

View file

@ -7,6 +7,7 @@ import {
TOOL_DESCRIPTIONS,
getContainerTags,
} from "./tools-shared"
import { forgetMemoryRequest } from "./shared/forget-memory"
import type { SupermemoryToolsConfig } from "./types"
// Export individual tool creators
@ -39,11 +40,11 @@ export const searchMemoriesTool = (
.default(DEFAULT_VALUES.includeFullDocs)
.describe(PARAMETER_DESCRIPTIONS.includeFullDocs),
limit: strict
? z
? z.coerce
.number()
.default(DEFAULT_VALUES.limit)
.describe(PARAMETER_DESCRIPTIONS.limit)
: z
: z.coerce
.number()
.optional()
.default(DEFAULT_VALUES.limit)
@ -182,32 +183,30 @@ export const documentListTool = (
.optional()
.describe(PARAMETER_DESCRIPTIONS.containerTag),
limit: strict
? z
? z.coerce
.number()
.default(DEFAULT_VALUES.limit)
.describe(PARAMETER_DESCRIPTIONS.limit)
: z
: z.coerce
.number()
.optional()
.default(DEFAULT_VALUES.limit)
.describe(PARAMETER_DESCRIPTIONS.limit),
offset: z.number().optional().describe(PARAMETER_DESCRIPTIONS.offset),
status: z.string().optional().describe(PARAMETER_DESCRIPTIONS.status),
page: z.coerce.number().optional().describe(PARAMETER_DESCRIPTIONS.page),
}),
execute: async ({ containerTag, limit, offset, status }) => {
execute: async ({ containerTag, limit, page }) => {
try {
const tag = containerTag || containerTags[0]
const response = await client.documents.list({
containerTags: [tag],
limit: limit || DEFAULT_VALUES.limit,
...(offset !== undefined && { offset }),
...(status && { status }),
...(page !== undefined && { page }),
})
return {
success: true,
documents: response.documents,
documents: response.memories,
pagination: response.pagination,
}
} catch (error) {
@ -236,7 +235,7 @@ export const documentDeleteTool = (
}),
execute: async ({ documentId }) => {
try {
await client.documents.delete({ docId: documentId })
await client.documents.delete(documentId)
return {
success: true,
@ -303,11 +302,6 @@ export const memoryForgetTool = (
apiKey: string,
config?: SupermemoryToolsConfig,
) => {
const client = new Supermemory({
apiKey,
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
})
const containerTags = getContainerTags(config)
return tool({
@ -335,12 +329,16 @@ export const memoryForgetTool = (
const tag = containerTag || containerTags[0]
await client.memories.forget({
containerTag: tag,
...(memoryId && { id: memoryId }),
...(memoryContent && { content: memoryContent }),
...(reason && { reason }),
})
await forgetMemoryRequest(
apiKey,
{
containerTag: tag as string,
...(memoryId && { id: memoryId }),
...(memoryContent && { content: memoryContent }),
...(reason && { reason }),
},
config?.baseUrl,
)
return {
success: true,

View file

@ -96,7 +96,10 @@ export class ClaudeMemoryTool {
}
return await this.create(command.path, command.file_text)
case "str_replace":
if (!command.old_str || !command.new_str) {
// new_str may legitimately be "" (deleting text), so only reject
// when it is missing entirely. old_str must be non-empty — replacing
// the empty string would prepend instead of replacing.
if (!command.old_str || command.new_str === undefined) {
return {
success: false,
error: "old_str and new_str are required for str_replace command",
@ -108,7 +111,11 @@ export class ClaudeMemoryTool {
command.new_str,
)
case "insert":
if (command.insert_line === undefined || !command.insert_text) {
// insert_text may be "" (inserting a blank line).
if (
command.insert_line === undefined ||
command.insert_text === undefined
) {
return {
success: false,
error:
@ -487,9 +494,9 @@ export class ClaudeMemoryTool {
}
}
// Delete using the document ID
// Note: We'll need to implement this based on supermemory's delete API
// For now, we'll return a success message
const documentId =
readResult.document.documentId ?? this.normalizePathToCustomId(filePath)
await this.client.documents.delete(documentId)
return {
success: true,
@ -544,7 +551,14 @@ export class ClaudeMemoryTool {
},
})
// Delete the old document (would need proper delete API)
// Remove the old document so the previous path stops showing up in
// listings and search. Skip when both paths normalize to the same
// customId — the add above already replaced the content.
const oldNormalizedId = this.normalizePathToCustomId(oldPath)
if (oldNormalizedId !== newNormalizedId) {
const oldDocumentId = readResult.document.documentId ?? oldNormalizedId
await this.client.documents.delete(oldDocumentId)
}
return {
success: true,

View file

@ -6,6 +6,7 @@ import {
TOOL_DESCRIPTIONS,
getContainerTags,
} from "../tools-shared"
import { forgetMemoryRequest } from "../shared/forget-memory"
import type { SupermemoryToolsConfig } from "../types"
/**
@ -36,7 +37,7 @@ export interface ProfileResult {
export interface DocumentListResult {
success: boolean
documents?: Awaited<ReturnType<Supermemory["documents"]["list"]>>["documents"]
documents?: Awaited<ReturnType<Supermemory["documents"]["list"]>>["memories"]
pagination?: Awaited<
ReturnType<Supermemory["documents"]["list"]>
>["pagination"]
@ -139,13 +140,9 @@ export const memoryToolSchemas = {
description: PARAMETER_DESCRIPTIONS.limit,
default: DEFAULT_VALUES.limit,
},
offset: {
page: {
type: "number",
description: PARAMETER_DESCRIPTIONS.offset,
},
status: {
type: "string",
description: PARAMETER_DESCRIPTIONS.status,
description: PARAMETER_DESCRIPTIONS.page,
},
},
required: [],
@ -359,13 +356,11 @@ export function createDocumentListFunction(
return async function documentList({
containerTag,
limit,
offset,
status,
page,
}: {
containerTag?: string
limit?: number
offset?: number
status?: string
page?: number
}): Promise<DocumentListResult> {
try {
const tag = containerTag || containerTags[0]
@ -373,13 +368,12 @@ export function createDocumentListFunction(
const response = await client.documents.list({
containerTags: [tag],
limit: limit || DEFAULT_VALUES.limit,
...(offset !== undefined && { offset }),
...(status && { status }),
...(page !== undefined && { page }),
})
return {
success: true,
documents: response.documents,
documents: response.memories,
pagination: response.pagination,
}
} catch (error) {
@ -470,7 +464,7 @@ export function createMemoryForgetFunction(
apiKey: string,
config?: SupermemoryToolsConfig,
) {
const { client, containerTags } = createClient(apiKey, config)
const containerTags = getContainerTags(config)
return async function memoryForget({
containerTag,
@ -493,12 +487,16 @@ export function createMemoryForgetFunction(
const tag = containerTag || containerTags[0]
await client.memories.forget({
containerTag: tag,
...(memoryId && { id: memoryId }),
...(memoryContent && { content: memoryContent }),
...(reason && { reason }),
})
await forgetMemoryRequest(
apiKey,
{
containerTag: tag as string,
...(memoryId && { id: memoryId }),
...(memoryContent && { content: memoryContent }),
...(reason && { reason }),
},
config?.baseUrl,
)
return {
success: true,

View file

@ -0,0 +1,38 @@
const DEFAULT_BASE_URL = "https://api.supermemory.ai"
export interface ForgetMemoryParams {
containerTag: string
id?: string
content?: string
reason?: string
}
/**
* Marks a memory as forgotten via `DELETE /v4/memories`.
*
* The supermemory SDK version this package depends on (v3) has no
* `memories.forget` method, so the endpoint is called directly the same
* pattern the middleware already uses for `/v4/profile` and
* `/v4/conversations`.
*/
export async function forgetMemoryRequest(
apiKey: string,
params: ForgetMemoryParams,
baseUrl: string = DEFAULT_BASE_URL,
): Promise<void> {
const response = await fetch(`${baseUrl}/v4/memories`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(params),
})
if (!response.ok) {
const errorText = await response.text().catch(() => "Unknown error")
throw new Error(
`Supermemory forget memory failed: ${response.status} ${response.statusText}. ${errorText}`,
)
}
}

Some files were not shown because too many files have changed in this diff Show more