Merge branch 'main' into fix/1030-listmemories-mcp-tool

This commit is contained in:
Dhravya Shah 2026-06-12 18:13:43 -07:00 • committed by GitHub
commit c47529c378
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
138 changed files with 17137 additions and 4780 deletions

View file

@ -13,6 +13,7 @@
<p align="center">
<a href="https://supermemory.ai/docs">Docs</a> ·
<a href="https://supermemory.ai/docs/quickstart">Quickstart</a> ·
<a href="https://supermemory.ai/docs/self-hosting/overview">Self-host</a> ·
<a href="https://console.supermemory.ai">Dashboard</a> ·
<a href="https://supermemory.link/discord">Discord</a>
</p>
@ -77,6 +78,21 @@ No vector DB config. No embedding pipelines. No chunking strategies.
**[→ Jump to developer quickstart](#build-with-supermemory-api)**
</td>
</tr>
<tr>
<td colspan="2" valign="top">
<h3>🖥️ I want to run it myself</h3>
State-of-the-art memory, on your machine. **One binary. Zero config.** Bring any model — or run fully offline with Ollama.
```bash
curl -fsSL https://supermemory.ai/install | bash
```
**[→ Jump to Supermemory local](#supermemory-local--run-it-yourself)**
</td>
</tr>
</table>
@ -301,6 +317,38 @@ Full API reference → [supermemory.ai/docs](https://supermemory.ai/docs)
---
## Supermemory local — run it yourself
State-of-the-art memory, on your machine. One binary. Zero config.
```bash
curl -fsSL https://supermemory.ai/install | bash
# or
npx supermemory local
```
```bash
supermemory-server
```
First boot sets up the embedded Supermemory graph engine, local embeddings, and your credentials, then prints an API key. The full Memory API — documents, memories, user profiles, hybrid search — runs against `http://localhost:6767`.
```typescript
const client = new Supermemory({
apiKey: "sm_...",
baseURL: "http://localhost:6767", // that's the only change
});
```
- **Bring any model** — OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint. An interactive wizard walks you through it on first boot.
- **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).
---
## Benchmarks
Supermemory is state of the art across all major AI memory benchmarks:
@ -354,6 +402,7 @@ Your app / AI tool
- 📖 [Documentation](https://supermemory.ai/docs)
- 🚀 [Quickstart](https://supermemory.ai/docs/quickstart)
- 🖥️ [Self-hosting (Supermemory local)](https://supermemory.ai/docs/self-hosting/overview)
- 🧪 [MemoryBench](https://supermemory.ai/docs/memorybench/overview)
- 🔌 [Integrations](https://supermemory.ai/docs/integrations)
- 💬 [Discord](https://supermemory.link/discord)

View file

@ -0,0 +1,176 @@
---
title: "Container Tags"
sidebarTitle: "Container Tags"
description: "The isolation boundary that groups and partitions memories by user, project, or any logical scope"
icon: "folder"
---
A **container tag** is the primary way you organize and isolate memories in Supermemory. It's a simple string identifier you attach to content when you add it — and that you pass back when you search, list, or update it.
Think of a container tag as a **namespace**: every memory tagged with `user_alex` lives in its own isolated space, completely separate from memories tagged `user_jordan`. This is what makes Supermemory safe to use in multi-tenant applications — one user can never see another user's memories unless you explicitly query across both tags.
<CardGroup cols={2}>
<Card title="Group" icon="layers">
Bucket memories by user, project, agent, workspace, or any boundary that makes sense for your app.
</Card>
<Card title="Isolate" icon="shield">
Each container tag maps to its own vector namespace, so search and retrieval never leak across boundaries.
</Card>
</CardGroup>
---
## How it works
When you add a memory with a container tag, Supermemory automatically creates a **space** for that tag (scoped to your organization) if one doesn't already exist. You don't need to provision anything ahead of time — the first write with a new tag creates the container, and subsequent writes reuse it.
```typescript
// First call auto-creates the "user_alex" container
await client.add({
content: "Alex prefers dark mode and concise answers",
containerTag: "user_alex",
});
// Later, retrieve only Alex's memories
const results = await client.search.memories({
q: "what are the user's UI preferences?",
containerTag: "user_alex",
});
```
Under the hood, each container tag is hashed into a dedicated vector namespace. Embeddings, chunks, and memory entries for one tag are stored and searched independently of every other tag — there is no shared index to filter through, which is why isolation is strict rather than best-effort.
<Note>
A container tag is an **opaque identifier you choose**. Supermemory does not parse meaning out of it — `user_123`, `project_mobile`, and `org:acme:team:growth` are all equally valid. Pick a convention that mirrors the access boundaries in your own application.
</Note>
---
## Naming rules
Container tags are validated on every request. A tag must:
- Be **100 characters or less**
- Contain only **alphanumeric characters, hyphens (`-`), underscores (`_`), and colons (`:`)**
Matching pattern: `^[a-zA-Z0-9_:-]+$`
```typescript
// ✅ Valid
"user_123"
"project-mobile-app"
"org:acme:user:john"
"tenant_42_workspace_7"
// ❌ Invalid — spaces, slashes, and other symbols are rejected
"user 123"
"project/mobile"
"team@acme"
```
The colon is intentionally allowed so you can build **hierarchical** tags (for example `org:acme:user:john`) that encode several levels of structure in a single identifier.
---
## `containerTag` vs `containerTags`
Supermemory's current API uses a **single** `containerTag` string per request.
<Warning>
The plural `containerTags` array field is **deprecated**. It still works for backward compatibility on older (`/v3`) endpoints, but new integrations should use the singular `containerTag` string. The `/v4` API only accepts `containerTag`.
</Warning>
| API field | Type | Status |
|-----------|------|--------|
| `containerTag` | `string` | ✅ Current — use this |
| `containerTags` | `string[]` | ⚠️ Deprecated |
---
## Where container tags are used
The same tag flows through the entire lifecycle of a memory. Pass it consistently and your data stays neatly partitioned.
| Operation | Behavior |
|-----------|----------|
| **Add** | Writes the memory into the tag's container (auto-creating the space). |
| **Search** | Restricts retrieval to the given tag's namespace. |
| **List** | Returns only memories belonging to the tag(s). |
| **Update / Delete** | Targets the memory inside the specified tag's container. |
```typescript
// Add
await client.add({ content: "Q1 planning notes", containerTag: "project_q1" });
// Search within the same container
await client.search.memories({ q: "planning", containerTag: "project_q1" });
// List everything in the container
await client.documents.list({ containerTags: ["project_q1"] });
```
---
## Access control
Container tags are also an **authorization boundary**, not just an organizational one. Two mechanisms can restrict which tags a given caller may touch:
- **API key scopes** — an API key can be limited to a specific set of container tags, with read or write permission per tag.
- **Member restrictions** — an organization member can be granted access to only certain container tags.
When a request is restricted, Supermemory validates the requested tag against the caller's allowed set:
- Requesting a tag outside the allowed set returns `403 Forbidden`.
- A write (add/update/delete) to a read-only tag returns `403 Forbidden`.
- If no tag is supplied by a restricted caller, the request is automatically scoped to their allowed tag(s).
This means you can hand out an API key that is physically incapable of reading or writing another tenant's data, enforced at the data layer rather than in your application code.
---
## Per-container settings
Each container tag can carry its own configuration, independent of other tags in the same organization:
| Setting | Purpose |
|---------|---------|
| `name` | A human-friendly display name for the container. |
| `entityContext` | A custom context prompt applied when processing documents in this container — useful for steering extraction and summarization per project or tenant. |
```typescript
await client.containerTags.update("project_research", {
entityContext: "This project contains research papers about machine learning.",
});
```
Container tags can also be **merged** when you need to consolidate two buckets of memories into one.
---
## Choosing a convention
Pick a tagging scheme that maps onto the isolation boundaries your application actually needs.
| Pattern | Example | Use case |
|---------|---------|----------|
| User isolation | `user_{userId}` | Per-user memory in a consumer app |
| Project grouping | `project_{projectId}` | Project- or workspace-scoped content |
| Agent scoping | `agent_{agentId}` | Separate long-term memory per AI agent |
| Hierarchical | `org:{orgId}:user:{userId}` | Multi-level, multi-tenant SaaS |
<Tip>
Keep tags **deterministic** — derive them directly from IDs you already have (a user ID, a tenant ID) so you can always reconstruct the right tag at query time without a lookup.
</Tip>
---
## Next steps
<CardGroup cols={2}>
<Card title="Organizing & Filtering" icon="filter" href="/concepts/filtering">
Combine container tags with metadata filters for precise retrieval.
</Card>
<Card title="Adding Memories" icon="plus" href="/add-memories">
See container tags in action across the add API.
</Card>
</CardGroup>

View file

@ -1,243 +0,0 @@
---
title: 'Self Hosting'
description: 'Deploy your own instance of the supermemory API on Cloudflare Workers'
---
<Warning>
This guide is intended for **enterprise customers only** who have specifically opted for self-hosting as part of their enterprise plan. If you're on a standard plan, please use our hosted API at [console.supermemory.ai](https://console.supermemory.ai).
</Warning>
## Prerequisites
Before you start, you'll need to gather several API keys and set up accounts with various services. This comprehensive guide will walk you through obtaining each required component.
### Enterprise Deployment Package
Your enterprise deployment package is provided by the supermemory team and contains:
- Your unique Host ID (`NEXT_PUBLIC_HOST_ID`)
- The compiled JavaScript bundle
- The deployment script
Contact your supermemory enterprise representative to receive your deployment package.
### Cloudflare
#### Create Account
1. Go to [cloudflare.com](https://dash.cloudflare.com/sign-up) and create an account
3. Your **Account ID** is the long randon string in the URL bar
#### Create API Token
1. Navigate to [Cloudflare API Tokens](https://dash.cloudflare.com/?to=/:account/api-tokens)
2. Click **"Create Token"**
3. Use the **"Custom token"** template
4. Configure the token with these permissions:
- **Account:AI Gateway:Edit**
- **Account:Hyperdrive:Edit**
- **Account:Workers KV Storage:Edit**
- **Account:Workers R2 Storage:Edit**
7. Click **"Continue to summary"** → **"Create Token"**
8. **Important**: Copy and securely store the token immediately (it won't be shown again)
#### Enable Workers
1. In your Cloudflare dashboard, go to **Workers & Pages**
2. If prompted, accept the Workers terms of service
3. Choose a subdomain for your workers (e.g., `yourcompany.workers.dev`)
Your `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` are now ready.
### Database
You'll need to provide a PostgreSQL connection string via the `DATABASE_URL` environment variable.
The database must:
- Support the **pgvector extension** for vector operations
- Be accessible from Cloudflare Workers
- Support SSL connections
- Allow connections from Cloudflare's IP ranges
Your connection string should follow this format:
```
postgresql://username:password@hostname:port/database
```
### LLM Providers
#### OpenAI
1. Go to [platform.openai.com](https://platform.openai.com)
2. Sign in or create an account
3. Navigate to **API Keys** in the left sidebar
4. Click **"Create new secret key"**
5. Name your key (e.g., "supermemory Self-Hosted")
6. Copy the key and store it securely
7. Add billing information if you haven't already
#### Anthropic
1. Go to [console.anthropic.com](https://console.anthropic.com)
2. Create an account and complete verification
3. Navigate to **API Keys**
4. Click **"Create Key"**
5. Name your key and copy it securely
#### Gemini
1. Go to [Google AI Studio](https://aistudio.google.com)
2. Sign in with your Google account
3. Click **"Get API key"** → **"Create API key"**
4. Choose an existing Google Cloud project or create a new one
5. Copy your API key
#### Groq
1. Go to [console.groq.com](https://console.groq.com)
2. Sign up for an account
3. Navigate to **API Keys**
4. Click **"Create API Key"**
5. Name your key and copy it
{/* TODO: Add OAuth documentation */}
{/* ### Authentication Providers
#### GitHub OAuth (Optional)
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Click **"New OAuth App"**
3. Fill in the application details:
- **Application name**: Your app name
- **Homepage URL**: Your API domain (e.g., `https://api.yourdomain.com`)
- **Authorization callback URL**: `https://api.yourdomain.com/api/auth/callback/github`
4. Click **"Register application"**
5. Note the **Client ID** and generate a **Client Secret**
6. Use these for `AUTH_GITHUB_ID` and `AUTH_GITHUB_SECRET`
#### Google OAuth (Optional)
1. Go to [Google Cloud Console](https://console.cloud.google.com)
2. Create a new project or select an existing one
3. Enable the **Google+ API**
4. Go to **Credentials** → **Create Credentials** → **OAuth client ID**
5. Choose **Web application**
6. Add your domain to **Authorized JavaScript origins**
7. Add `https://api.yourdomain.com/api/auth/callback/google` to **Authorized redirect URIs**
8. Copy the **Client ID** and **Client secret**
9. Use these for `AUTH_GOOGLE_ID` and `AUTH_GOOGLE_SECRET` */}
### Email Service Setup
#### Resend
1. Go to [resend.com](https://resend.com) and create an account
2. Navigate to **API Keys**
3. Click **"Create API Key"**
4. Name your key (e.g., "supermemory Production")
5. Copy the key for `RESEND_API_KEY`
6. Verify your sending domain in the **Domains** section
### Connectors (Optional)
#### Google Drive
1. Go to [Google Cloud Console](https://console.cloud.google.com)
2. Create or select a project
3. Enable the **Google Drive API**
4. Go to **Credentials** → **Create Credentials** → **OAuth client ID**
5. Configure the OAuth consent screen if required
6. Choose **Web application**
7. Add authorized redirect URIs for your domain
8. Copy `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`
#### Microsoft OneDrive
1. Go to [Azure Portal](https://portal.azure.com)
2. Navigate to **Microsoft Entra ID** → **App registrations**
3. Click **"New registration"**
4. Name your app and set redirect URI
5. Go to **Certificates & secrets** → **New client secret**
6. Copy the **Application (client) ID** and **Client secret**
7. Use for `MICROSOFT_CLIENT_ID` and `MICROSOFT_CLIENT_SECRET`
#### Notion
1. Go to [Notion Developers](https://developers.notion.com)
2. Click **"Create new integration"**
3. Fill in the integration details
4. Copy the **Internal Integration Token**
5. Set up OAuth if needed for user connections
6. Use for `NOTION_CLIENT_ID` and `NOTION_CLIENT_SECRET`
---
## Setup deployment files
Extract the deployment package provided by the supermemory team to your preferred directory:
```bash
# Extract the deployment package
$ unzip supermemory-enterprise-deployment.zip
$ cd supermemory-deployment
```
---
## Configure environment variables
The deployment script reads **all** environment variables from your shell at runtime. We ship an example file that lists the full set supported by the worker.
```bash
# Copy the template and start editing
$ cp packages/alchemy/env.example .env
# Open the file in your editor of choice and fill in the blanks
$ $EDITOR .env
```
Below is a quick reference.
**Required** values are mandatory for a successful deploy – leave optional ones empty if you don't need the related feature.
| Name | Required? | Description |
|------|-----------|-------------|
| `NODE_ENV` | ✅ | `development`, `staging` or `production`. |
| `NEXT_PUBLIC_HOST_ID` | ✅ | Your unique Host ID provided by the supermemory team. |
| `BETTER_AUTH_SECRET` | ✅ | Random 32-byte string – run `openssl rand -base64 32`. |
| `BETTER_AUTH_URL` | ✅ | Public base URL for the API (no trailing `/`). Example: `https://api.example.com`. |
| `DATABASE_URL` | ✅ | Postgres connection string (e.g. `postgres://user:pass@host:5432/db`). |
| `CLOUDFLARE_ACCOUNT_ID` | ✅ | Your Cloudflare account ID. |
| `CLOUDFLARE_API_TOKEN` | ✅ | Token created in *Prerequisites*. |
| `OPENAI_API_KEY` | ✅ | Key from [platform.openai.com](https://platform.openai.com). |
| `RESEND_API_KEY` | ✅ | E-mail provider key if you plan to send e-mails. |
| `ANTHROPIC_API_KEY` | | Needed to use Claude models. |
| `GEMINI_API_KEY` | | Key for Google Gemini models. |
| `GROQ_API_KEY` | | Key for Groq models. |
| `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET` | | Enable GitHub OAuth login. |
| `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET` | | Enable Google OAuth login. |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | | Needed for Google Drive connector. |
| `MICROSOFT_CLIENT_ID` / `MICROSOFT_CLIENT_SECRET` | | Needed for OneDrive connector. |
| `NOTION_CLIENT_ID` / `NOTION_CLIENT_SECRET` | | Needed for Notion connector. |
| `CLOUDFLARE_AI_GATEWAY_NAME` / `CLOUDFLARE_AI_GATEWAY_TOKEN` | | Only if you want to route requests through an AI Gateway. |
| `SENTRY_DSN` | | If you use Sentry for error reporting. |
---
## Deploy
With your `.env` in place, run the deployment script:
```bash
# Run the deployment script provided in your package
$ bun ./deploy.ts
```
---
## Updating Your Deployment
To update your supermemory deployment, follow the same process as the initial deployment described in the **Deploy** section above. You can reuse your existing `.env` file and add/remove any new environment variables as needed.
---

View file

@ -71,6 +71,15 @@
"group": "Getting Started",
"pages": ["intro", "quickstart", "vibe-coding"]
},
{
"group": "Self-Hosting",
"pages": [
"self-hosting/overview",
"self-hosting/quickstart",
"self-hosting/configuration",
"self-hosting/local-vs-enterprise"
]
},
{
"group": "Concepts",
"pages": [
@ -79,6 +88,7 @@
"concepts/content-types",
"concepts/super-rag",
"concepts/memory-vs-rag",
"concepts/container-tags",
"concepts/filtering",
"concepts/user-profiles",
"concepts/customization",

View file

@ -19,6 +19,10 @@ This integration requires the **Supermemory Pro plan**. [Upgrade here](https://c
[Claude-Supermemory](https://github.com/supermemoryai/claude-supermemory) is a Claude Code plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects.
<Tip>
**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_API_URL="http://localhost:6767"` and use the API key printed on first boot.
</Tip>
## Get Your API Key
Create a Supermemory API key from the [API Keys](https://console.supermemory.ai/keys) page, then add it to your shell profile so it persists across sessions:

View file

@ -10,6 +10,10 @@ icon: "terminal"
- **Implicit** (hooks) — automatically recalls context before each prompt and captures conversations after each session.
- **Explicit** (skills) — lets you or the agent save, search, and manage memories on demand.
<Tip>
**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_API_URL="http://localhost:6767"` (or set `baseUrl` in `~/.codex/supermemory.json`) and use the API key printed on first boot.
</Tip>
## Get Your API Key
Create a Supermemory API key from the [API Keys](https://console.supermemory.ai/keys) page, then export it in your shell profile:

View file

@ -11,6 +11,10 @@ This integration requires the **Supermemory Pro plan**. [Upgrade here](https://c
[OpenClaw](https://github.com/supermemoryai/openclaw-supermemory) is a multi-platform AI messaging gateway that connects to WhatsApp, Telegram, Discord, Slack, iMessage, and other messaging channels. The Supermemory plugin gives OpenClaw memory across every channel.
<Tip>
**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_BASE_URL="http://localhost:6767"` (or set `baseUrl` in the plugin config) and use the API key printed on first boot.
</Tip>
## Install the Plugin
Get started by installing the plugin with a single command.

View file

@ -11,6 +11,10 @@ This integration requires the **Supermemory Pro plan**. [Upgrade here](https://c
[OpenCode-Supermemory](https://github.com/supermemoryai/opencode-supermemory) is an OpenCode plugin that gives your AI persistent memory across sessions. Your agent remembers what you worked on — across sessions, across projects.
<Tip>
**Prefer to keep everything on your machine?** This plugin works with [self-hosted Supermemory](/self-hosting/overview) — run `npx supermemory local`, then `export SUPERMEMORY_API_URL="http://localhost:6767"` and use the API key printed on first boot.
</Tip>
## Get Your API Key
Create a Supermemory API key from the [API Keys](https://console.supermemory.ai/keys) page, then add it to your shell profile so it persists across sessions:

View file

@ -14,6 +14,10 @@ icon: "/images/supermemory.svg"
</Card>
</CardGroup>
<Tip>
Both SDKs also work against [self-hosted Supermemory](/self-hosting/overview) — pass `baseURL: "http://localhost:6767"` (TypeScript) or `base_url="http://localhost:6767"` (Python) when creating the client.
</Tip>
<Tabs>
<Tab title="TypeScript">
## Installation

View file

@ -82,5 +82,8 @@ All three approaches share the **same context pool** when using the same user ID
<Card title="How it Works" icon="cpu" href="/concepts/how-it-works">
Understand the knowledge graph architecture
</Card>
<Card title="Self-host it" icon="server" href="/self-hosting/overview">
Run Supermemory on your own machine — one binary, zero config, fully offline
</Card>
</CardGroup>

View file

@ -8,6 +8,10 @@ icon: "play"
**Using Vercel AI SDK?** Check out the [AI SDK integration](/integrations/ai-sdk) for the cleanest implementation with `@supermemory/tools/ai-sdk`.
</Tip>
<Tip>
**Prefer to run it locally?** Supermemory is also a [self-hostable single binary](/self-hosting/overview) — `curl -fsSL https://supermemory.ai/install | bash` and you're running.
</Tip>
## Memory API
**Step 1.** Sign up for [Supermemory's Developer Platform](http://console.supermemory.ai) to get the API key. Click on **API Keys -> Create API Key** to generate one.

View file

@ -0,0 +1,135 @@
---
title: "Self-Hosting Configuration"
sidebarTitle: "Configuration"
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 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.
## Core
| Variable | Purpose | Default |
|---|---|---|
| `PORT` (or `SUPERMEMORY_PORT`) | HTTP listen port | `6767` |
| `SUPERMEMORY_DATA_DIR` | Where the graph engine's data, auth secret, and model cache live | `./.supermemory` |
## 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**:
| Variable | Provider |
|---|---|
| `OPENAI_API_KEY` | OpenAI — or any OpenAI-compatible endpoint, see below |
| `ANTHROPIC_API_KEY` | Anthropic |
| `GEMINI_API_KEY` | Google AI Studio (Gemini) |
| `GROQ_API_KEY` | Groq |
| `WORKERS_AI_API_KEY` + `CLOUDFLARE_ACCOUNT_ID` | Cloudflare Workers AI |
| `GOOGLE_VERTEX_PROJECT_ID` + `GOOGLE_VERTEX_LOCATION` | GCP Vertex AI |
<Tip>
No key set? The server walks you through it. On first boot, an interactive setup wizard asks which provider you want, securely prompts for the key, and saves it encrypted — including a custom base URL and model name if you pick an OpenAI-compatible endpoint.
</Tip>
With multiple providers configured, the first one in the order above is used.
<Note>
Image, video, and high-fidelity PDF understanding require a Gemini or Vertex AI key. Text ingestion, memory extraction, and search work with any provider.
</Note>
### Fully offline with local models
`OPENAI_API_KEY` + `OPENAI_BASE_URL` covers any OpenAI-compatible endpoint: Ollama, LM Studio, vLLM, llama.cpp server, Together, Fireworks, and more.
```bash
# Ollama example — gpt-oss-20b works great
OPENAI_BASE_URL=http://localhost:11434/v1
OPENAI_API_KEY=ollama # any non-empty string for local runners
OPENAI_MODEL=gpt-oss:20b
```
| Variable | Purpose | Default |
|---|---|---|
| `OPENAI_BASE_URL` | OpenAI-compatible endpoint URL | OpenAI |
| `OPENAI_MODEL` | Model ID sent to that endpoint | `gpt-5.1` |
| `OPENAI_FAST_MODEL` | Override for fast/light tasks | `OPENAI_MODEL` |
| `OPENAI_TEXT_MODEL` | Override for heavier text tasks | `OPENAI_MODEL` |
## File storage
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
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:
| 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 |
## Memory limits & ingestion queue
The server manages memory for you and separates the two kinds of work you send it:
- **Searches are always served immediately.** They never wait behind ingestion, regardless of how much is queued.
- **Adds are accepted instantly but processed through a queue.** A `POST /v3/documents` call returns in milliseconds with status `queued`; extraction, embedding, and indexing happen in the background at a controlled pace.
Ingestion may grow the server's memory usage by at most `SUPERMEMORY_EMBEDDING_RAM_LIMIT` (default **1 GB**) above its post-boot baseline. Past that, new documents simply wait in the queue until memory drops back under the limit — nothing is dropped, ingestion just slows down. The limit is measured above the boot baseline because the built-in local embeddings and storage engine have a fixed footprint that exists before any document is processed.
The limit is printed at boot, and whenever adds are waiting the binary shows a live status line in the terminal:
```
[ingest] memory limit 1.0 GB above baseline (1.6 GB) · 2 concurrent — set SUPERMEMORY_EMBEDDING_RAM_LIMIT=ngb to change
[ingest] 2 running · 193 queued · 0.4 GB / 1.0 GB ingest memory
[ingest] 2 running · 193 queued · paused — 1.1 GB / 1.0 GB ingest memory, waiting for it to drop
[ingest] resumed — memory back under the 1.0 GB ingest limit
```
| Variable | Purpose | Default |
|---|---|---|
| `SUPERMEMORY_EMBEDDING_RAM_LIMIT` | Memory ingestion may use above the boot baseline. Accepts `1gb`, `1.5gb`, `512mb`, or a bare number (GB). | `1gb` |
| `SUPERMEMORY_INGEST_CONCURRENCY` | Documents processed concurrently | `2` |
```bash
# Give ingestion 4 GB of headroom on a larger machine
SUPERMEMORY_EMBEDDING_RAM_LIMIT=4gb ./supermemory-server
```
Raise the limit and concurrency on machines with spare RAM for faster bulk imports; lower them on small VPSes where you want the server to stay lean and don't mind adds draining slowly.
## Telemetry
The self-hosted binary sends no analytics — there is nothing to opt out of. The only related switch:
| Variable | Purpose | Default |
|---|---|---|
| `SUPERMEMORY_DISABLE_TELEMETRY` | Set to `1` to also disable internal AI SDK telemetry instrumentation | unset |
## Platform-only features
These exist in the codebase but are exclusive to the [hosted platform](https://console.supermemory.ai) — the self-hosted binary doesn't include them:
- **Connectors** — Google Drive, Notion, Gmail, OneDrive background sync
- **Supermemory MCP** — managed MCP server endpoints
- **Optimized memory extraction** — the platform's extraction pipeline is tuned for higher quality at lower cost than bring-your-own-key
- **Managed scale** — globally distributed infrastructure, no capacity planning
Any other environment variables you may find referenced in the codebase are platform-only: the self-hosted binary ignores them even when set.
## Example: production-ish `.env`
```dotenv
# Persistent data location
SUPERMEMORY_DATA_DIR=/var/lib/supermemory
# One LLM provider
OPENAI_API_KEY=sk-...
```
That's enough for full ingestion, memory extraction, and hybrid search.

View file

@ -0,0 +1,56 @@
---
title: "Local vs. Enterprise"
sidebarTitle: "Local vs. Enterprise"
description: "Supermemory local is for builders. Supermemory Enterprise is for organizations."
icon: "building-2"
---
Supermemory local — the self-hosted binary — is free, open source, and built for individual developers: local-first workflows, prototyping, air-gapped experiments, privacy-sensitive side projects.
**Supermemory Enterprise** is the full platform, run for your organization: the same memory engine with proprietary models, organizational controls, and infrastructure that scales with you — without you operating any of it.
## At a glance
| | Supermemory local | Enterprise |
|---|---|---|
| **Memory engine** | Full graph engine, embedded | Full graph engine, managed |
| **Models** | Bring your own key (any provider, incl. fully offline) | Proprietary models tuned for long-horizon data understanding |
| **Authentication** | Single auto-generated API key | Organization-wide authentication and access controls |
| **Team access** | Single org on one machine | Multi-member organizations, roles, and scoped API keys |
| **Observability** | Server logs | Control dashboard: usage analytics, ingestion monitoring, request logs |
| **Control** | Env vars on your box | Org-wide settings, key management, and governance from the console |
| **Connectors** | — | Google Drive, Notion, Gmail, OneDrive with continuous background sync |
| **Scalability** | One machine, one process | Globally distributed, scales elastically with your workload |
| **Hosting** | You run it | Fully managed — or dedicated deployments for compliance needs |
| **Support** | Community ([GitHub](https://git.new/memory)) | Dedicated support, onboarding, and SLAs |
## What Enterprise adds
### Auth and team access
Local runs as a single-tenant server with one API key. Enterprise gives your whole organization structured access: member roles, and API keys scoped per environment, per team, or per app — all revocable from one place.
### Observability and the control dashboard
Local gives you logs. Enterprise gives you the console: live usage analytics, ingestion pipeline visibility, search and request logs, and per-key attribution — so you always know what your agents are remembering, and what it costs.
### Memory quality
Local runs the extraction pipeline on whatever model you bring. Enterprise runs it on Supermemory's proprietary models, purpose-tuned for long-horizon data understanding — higher-quality memories at a lower effective cost than any bring-your-own-key setup.
### Scale and hosting
Local is bounded by one machine — which is the point. Enterprise runs on globally distributed infrastructure that scales with your ingestion volume and query load, with no capacity planning on your side. For strict data residency or compliance requirements, dedicated deployment options are available.
## Moving between them
The two speak the same API. Code written against your local server moves to Enterprise by changing the `baseURL` — and vice versa. Prototype locally, ship on Enterprise.
<CardGroup cols={2}>
<Card title="Talk to us" icon="mail" href="mailto:dhravya@supermemory.com">
Get a walkthrough of Supermemory Enterprise for your team
</Card>
<Card title="Start locally" icon="terminal" href="/self-hosting/quickstart">
Install the binary and build against the same API today
</Card>
</CardGroup>

View file

@ -0,0 +1,85 @@
---
title: "Self-Hosting Supermemory"
sidebarTitle: "Overview"
description: "State-of-the-art memory, running on your machine. One binary, zero config."
icon: "server"
---
Supermemory runs on your own hardware. It's the same memory engine behind the [hosted platform](https://console.supermemory.ai) — ingestion, memory extraction, hybrid semantic search, and the full API — as a single self-contained binary.
<CodeGroup>
```bash curl
curl -fsSL https://supermemory.ai/install | bash
```
```bash npx
npx supermemory local
```
</CodeGroup>
No Docker. No database to provision. No config files. It boots in seconds with everything built in, and it's [open source](https://git.new/memory).
## Zero config, actually
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.
- **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.
The only thing you bring is a model. In production, Supermemory runs its own proprietary models, purpose-tuned for long-horizon data understanding and memory extraction. Self-hosted, the same pipeline runs on whatever model you point it at — OpenAI, Anthropic, Gemini, Groq, or any OpenAI-compatible endpoint. Bring a key and go. Or don't bring one at all:
## Runs fully offline
Supermemory works with any OpenAI-compatible endpoint, which means it runs end-to-end on your machine with a local model — Ollama, LM Studio, vLLM, llama.cpp. `gpt-oss-20b` is a great fit:
```bash
OPENAI_BASE_URL=http://localhost:11434/v1 \
OPENAI_API_KEY=ollama \
OPENAI_MODEL=gpt-oss:20b \
supermemory-server
```
Local graph engine, local embeddings, local LLM. Your data never leaves the building.
## Drop-in with your existing code
The self-hosted server speaks the same API as the hosted platform. Point any Supermemory SDK at it with a one-line change:
```typescript
const client = new Supermemory({
apiKey: "sm_...", // printed on first boot
baseURL: "http://localhost:6767",
})
```
Everything in the [Memory API docs](/quickstart) works the same way. The coding plugins do too — [Claude Code](/integrations/claude-code), [Codex](/integrations/codex), and [OpenCode](/integrations/opencode) all target your local server with `SUPERMEMORY_API_URL=http://localhost:6767`.
## Self-hosted vs. the platform
Self-hosted is free, open source, and great for local development, air-gapped environments, and privacy-sensitive workloads. The hosted platform is where the full product lives:
| | Self-hosted | Platform |
|---|---|---|
| Full Memory API | ✅ | ✅ |
| Hybrid semantic search | ✅ | ✅ |
| Local embeddings | ✅ | Managed |
| File ingestion (PDFs, images) | ✅ | ✅ |
| [Connectors](/connectors/overview) (Google Drive, Notion, Gmail, OneDrive) | — | ✅ |
| [Supermemory MCP](/supermemory-mcp/mcp) | — | ✅ |
| Memory extraction | Your model, your key | Proprietary long-horizon models — higher quality, cheaper at scale |
| Infrastructure | Your machine | Globally distributed, scales with you |
If you outgrow a single machine — or want connectors, MCP, and the best-tuned extraction pipeline — [the platform](https://console.supermemory.ai) is one `baseURL` change away. Running this for a team or organization? See [Local vs. Enterprise](/self-hosting/local-vs-enterprise).
## Next steps
<CardGroup cols={2}>
<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>
</CardGroup>

View file

@ -0,0 +1,151 @@
---
title: "Self-Hosting Quickstart"
sidebarTitle: "Quickstart"
description: "From zero to your first memory in under two minutes."
icon: "play"
---
## Install
<Tabs>
<Tab title="curl">
```bash
curl -fsSL https://supermemory.ai/install | bash
```
</Tab>
<Tab title="npx">
```bash
npx supermemory local
```
</Tab>
<Tab title="bunx">
```bash
bunx supermemory local
```
</Tab>
</Tabs>
The installer detects your OS and architecture, downloads the right binary, verifies it, and (when run interactively) prompts you for an LLM API key. Supported platforms: macOS (Apple Silicon & Intel), Linux (x64 & arm64).
## Run
```bash
supermemory-server
```
First boot sets everything up — the embedded Supermemory graph engine, local embeddings, and your credentials:
```
┌──────────────────────────────────────────────────┐
│ url http://localhost:6767 │
│ database ./.supermemory │
│ api key sm_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx │
│ org id xxxxxxxxxxxxxxxxxxxxxx │
└──────────────────────────────────────────────────┘
```
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).
</Note>
## Add your first memory
<Tabs>
<Tab title="TypeScript">
```typescript
import Supermemory from "supermemory"
const client = new Supermemory({
apiKey: "sm_...",
baseURL: "http://localhost:6767",
})
await client.memories.add({
content: "I'm Dhravya. I love building dev tools and I'm allergic to peanuts.",
containerTag: "user_dhravya",
})
```
</Tab>
<Tab title="Python">
```python
from supermemory import Supermemory
client = Supermemory(
api_key="sm_...",
base_url="http://localhost:6767",
)
client.memories.add(
content="I'm Dhravya. I love building dev tools and I'm allergic to peanuts.",
container_tag="user_dhravya",
)
```
</Tab>
<Tab title="curl">
```bash
curl http://localhost:6767/v3/documents \
-H "Authorization: Bearer sm_..." \
-H "Content-Type: application/json" \
-d '{
"content": "I am Dhravya. I love building dev tools and I am allergic to peanuts.",
"containerTag": "user_dhravya"
}'
```
</Tab>
</Tabs>
## Search it
<Tabs>
<Tab title="TypeScript">
```typescript
const results = await client.search.memories({
q: "what food should I avoid?",
containerTag: "user_dhravya",
})
```
</Tab>
<Tab title="Python">
```python
results = client.search.memories(
q="what food should I avoid?",
container_tag="user_dhravya",
)
```
</Tab>
<Tab title="curl">
```bash
curl http://localhost:6767/v3/search \
-H "Authorization: Bearer sm_..." \
-H "Content-Type: application/json" \
-d '{
"q": "what food should I avoid?",
"containerTag": "user_dhravya"
}'
```
</Tab>
</Tabs>
That's it. Everything in the [Memory API](/quickstart) — documents, memories, user profiles, spaces, filtering — works identically against your local server.
## Where things live
By default, all state lives in a single directory you can back up or move:
| Path | Contents |
|---|---|
| `./.supermemory/` (or `$SUPERMEMORY_DATA_DIR`) | The Supermemory graph engine's data, auth secret, embedding model cache |
| `~/.supermemory/env` | API keys saved by the installer, loaded on every launch |
## Next steps
<CardGroup cols={2}>
<Card title="Configuration" icon="settings" href="/self-hosting/configuration">
LLM providers, local models, performance tuning
</Card>
<Card title="Memory API" icon="book-open" href="/quickstart">
The full API — it all works against your local server
</Card>
</CardGroup>

View file

@ -170,6 +170,54 @@ The server will start at `http://localhost:8788`.
**Note:** For local development, you also need the main Supermemory API running at the `API_URL` for OAuth token validation.
### End-to-End Tests
The `e2e/` suite drives a real MCP server over streamable HTTP (no mocks) and asserts the
core journey: handshake → tool/resource/prompt discovery → `whoAmI` → `listProjects` →
`memory` save → `recall` round-trip, plus `memory-graph`/`fetch-graph-data`, resource reads,
the `context` prompt, container-tag isolation, and auth rejections.
```bash
export SUPERMEMORY_API_KEY=sm_... # staging key (required; tests skip without it)
export SUPERMEMORY_MCP_URL=https://mcp.supermemory.ai/mcp # optional, this is the default
export SUPERMEMORY_API_URL=https://api.supermemory.ai # optional, OAuth authorization server
bun run test:e2e
```
| File | Covers |
|------|--------|
| `e2e/auth.test.ts` | `GET /` info, OAuth discovery, 401 on missing/invalid token (runs without a key) |
| `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/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 |
#### OAuth flow tests
`mcp.supermemory.ai` is an OAuth **resource server**; the **authorization server** is the main
API (`api.supermemory.ai`, better-auth). `oauth.test.ts` covers the real flow in tiers:
- **A–C (no secrets)** — discovery chain, dynamic client registration, and token/authorize
negatives. These exercise the protocol wiring with no key and no browser, so they always run.
- **D (real token)** — exchanges a seeded `refresh_token` for an `access_token` and connects to
`/mcp` with it, exercising the OAuth-token validation path (not the `sm_` API-key path). It
**skips** unless both env vars below are set.
```bash
# One-time capture (opens a browser for login + consent, prints the env vars):
bun e2e/capture-oauth-token.ts
export SUPERMEMORY_MCP_CLIENT_ID=...
export SUPERMEMORY_MCP_REFRESH_TOKEN=...
```
Notes:
- Tests **skip** (not fail) without `SUPERMEMORY_API_KEY`; Tier D OAuth tests skip without the
refresh-token env vars — so CI is safe without secrets.
- `recall` is eventually-consistent (save → ingestion pipeline → memories), so the round-trip
**polls up to ~90s**. `forget` removal is slower still and is asserted as best-effort.
- The suite uses unique per-run markers and forgets them in teardown to avoid polluting the account.
### Deploy
```bash

65
apps/mcp/e2e/auth.test.ts Normal file
View file

@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest"
import { MCP_URL, ORIGIN } from "./helpers"
const initBody = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "smtest", version: "0.0.1" },
},
})
const mcpHeaders = (auth?: string) => ({
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
...(auth ? { Authorization: auth } : {}),
})
// No API key needed — exercises the public surface and auth rejections.
describe("MCP — transport & auth (raw HTTP)", () => {
it("GET / returns service info", async () => {
const res = await fetch(`${ORIGIN}/`)
expect(res.status).toBe(200)
const body = (await res.json()) as { name?: string; version?: string }
expect(body.name).toBe("supermemory-mcp")
expect(body.version).toBeTruthy()
})
it("exposes OAuth protected-resource discovery", async () => {
const res = await fetch(
`${ORIGIN}/.well-known/oauth-protected-resource/mcp`,
)
expect(res.status).toBe(200)
const body = (await res.json()) as {
resource?: string
authorization_servers?: string[]
}
expect(body.resource).toMatch(/\/mcp$/)
expect(Array.isArray(body.authorization_servers)).toBe(true)
expect(body.authorization_servers?.length).toBeGreaterThan(0)
})
it("rejects a request with no token (401 + WWW-Authenticate)", async () => {
const res = await fetch(MCP_URL, {
method: "POST",
headers: mcpHeaders(),
body: initBody,
})
expect(res.status).toBe(401)
expect(res.headers.get("www-authenticate")).toMatch(/Bearer/)
})
it("rejects an invalid API key (401 with JSON-RPC error)", async () => {
const res = await fetch(MCP_URL, {
method: "POST",
headers: mcpHeaders("Bearer sm_invalid_key_for_e2e"),
body: initBody,
})
expect(res.status).toBe(401)
const body = (await res.json()) as { error?: { message?: string } }
expect(body.error?.message).toMatch(/invalid|expired/i)
})
})

View file

@ -0,0 +1,103 @@
// One-time helper to capture a Tier D refresh token — run: bun e2e/capture-oauth-token.ts
import { createHash, randomBytes } from "node:crypto"
import { createServer } from "node:http"
import { exec } from "node:child_process"
const API_URL = process.env.SUPERMEMORY_API_URL ?? "https://api.supermemory.ai"
const PORT = 8765
const REDIRECT_URI = `http://localhost:${PORT}/callback`
const b64url = (b: Buffer) =>
b
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "")
async function main() {
const meta = (await (
await fetch(`${API_URL}/.well-known/oauth-authorization-server`)
).json()) as {
authorization_endpoint: string
token_endpoint: string
registration_endpoint: string
}
const reg = (await (
await fetch(meta.registration_endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_name: "sm-mcp-e2e-capture",
redirect_uris: [REDIRECT_URI],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
}),
})
).json()) as { client_id: string }
const verifier = b64url(randomBytes(32))
const challenge = b64url(createHash("sha256").update(verifier).digest())
const state = b64url(randomBytes(16))
const authUrl = new URL(meta.authorization_endpoint)
authUrl.search = new URLSearchParams({
response_type: "code",
client_id: reg.client_id,
redirect_uri: REDIRECT_URI,
code_challenge: challenge,
code_challenge_method: "S256",
scope: "openid profile email offline_access",
state,
}).toString()
const code: string = await new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const u = new URL(req.url ?? "", `http://localhost:${PORT}`)
if (u.pathname !== "/callback") return res.end()
if (u.searchParams.get("state") !== state) {
res.end("state mismatch")
return reject(new Error("state mismatch"))
}
const c = u.searchParams.get("code")
res.end("Done — you can close this tab.")
server.close()
c ? resolve(c) : reject(new Error("no code in callback"))
}).listen(PORT, () => {
console.log(`\nOpening browser to log in:\n${authUrl}\n`)
exec(`open "${authUrl}" || xdg-open "${authUrl}"`)
})
})
const tokenRes = (await (
await fetch(meta.token_endpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: reg.client_id,
code_verifier: verifier,
redirect_uri: REDIRECT_URI,
}),
})
).json()) as { refresh_token?: string; error?: string }
if (!tokenRes.refresh_token) {
console.error("No refresh_token returned:", tokenRes)
process.exit(1)
}
console.log("\nExport these to enable Tier D OAuth tests:\n")
console.log(`export SUPERMEMORY_MCP_CLIENT_ID="${reg.client_id}"`)
console.log(
`export SUPERMEMORY_MCP_REFRESH_TOKEN="${tokenRes.refresh_token}"`,
)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})

View file

@ -0,0 +1,52 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { API_KEY, callTool, connect, textOf, type Session } from "./helpers"
const EXPECTED_TOOLS = [
"memory",
"recall",
"listProjects",
"whoAmI",
"memory-graph",
]
describe.skipIf(!API_KEY)("MCP — discovery & identity", () => {
let s: Session
beforeAll(async () => {
s = await connect()
})
afterAll(async () => {
await s?.close()
})
it("handshakes and lists the expected tools", async () => {
const { tools } = await s.client.listTools()
const names = tools.map((t) => t.name)
for (const t of EXPECTED_TOOLS) expect(names).toContain(t)
})
it("lists profile & projects resources", async () => {
const { resources } = await s.client.listResources()
const uris = resources.map((r) => r.uri)
expect(uris).toContain("supermemory://profile")
expect(uris).toContain("supermemory://projects")
})
it("lists the context prompt", async () => {
const { prompts } = await s.client.listPrompts()
expect(prompts.map((p) => p.name)).toContain("context")
})
it("whoAmI resolves to the authenticated account", async () => {
const res = await callTool(s.client, "whoAmI")
expect(res.isError).toBeFalsy()
const parsed = JSON.parse(textOf(res))
expect(parsed.userId).toBeTruthy()
})
it("listProjects returns content", async () => {
const res = await callTool(s.client, "listProjects", { refresh: true })
expect(res.isError).toBeFalsy()
expect(textOf(res).length).toBeGreaterThan(0)
})
})

View file

@ -0,0 +1,59 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { API_KEY, callTool, connect, type Session, textOf } from "./helpers"
describe.skipIf(!API_KEY)("MCP — graph, resources & prompts", () => {
let s: Session
beforeAll(async () => {
s = await connect()
})
afterAll(async () => {
await s?.close()
})
it("memory-graph returns a summary + structured documents", async () => {
const res = await callTool(s.client, "memory-graph")
expect(res.isError).toBeFalsy()
expect(textOf(res)).toMatch(/Memory Graph: \d+ documents/)
const sc = res.structuredContent as {
documents?: unknown[]
totalCount?: number
}
expect(Array.isArray(sc?.documents)).toBe(true)
})
it("fetch-graph-data returns paginated documents", async () => {
const res = await callTool(s.client, "fetch-graph-data", {
page: 1,
limit: 5,
})
expect(res.isError).toBeFalsy()
const sc = res.structuredContent as {
documents?: unknown[]
pagination?: { limit?: number }
}
expect(Array.isArray(sc?.documents)).toBe(true)
expect(sc?.pagination?.limit).toBe(5)
})
it("reads the profile resource", async () => {
const res = await s.client.readResource({ uri: "supermemory://profile" })
expect(res.contents.length).toBeGreaterThan(0)
expect(res.contents[0].mimeType).toBe("text/plain")
expect(typeof res.contents[0].text).toBe("string")
})
it("reads the projects resource as JSON", async () => {
const res = await s.client.readResource({ uri: "supermemory://projects" })
const text = res.contents[0].text as string
const parsed = JSON.parse(text)
expect(Array.isArray(parsed.projects)).toBe(true)
})
it("gets the context prompt as a system message", async () => {
const res = await s.client.getPrompt({ name: "context", arguments: {} })
expect(res.messages.length).toBeGreaterThan(0)
const text = res.messages[0].content.text as string
expect(text).toMatch(/memory|context/i)
})
})

186
apps/mcp/e2e/helpers.ts Normal file
View file

@ -0,0 +1,186 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
export const MCP_URL =
process.env.SUPERMEMORY_MCP_URL ?? "https://mcp.supermemory.ai/mcp"
export const API_KEY = process.env.SUPERMEMORY_API_KEY
export const ORIGIN = new URL(MCP_URL).origin
export const API_URL =
process.env.SUPERMEMORY_API_URL ?? "https://api.supermemory.ai"
// Tier D (real OAuth token) creds — captured once via e2e/capture-oauth-token.ts.
export const OAUTH_REFRESH_TOKEN = process.env.SUPERMEMORY_MCP_REFRESH_TOKEN
export const OAUTH_CLIENT_ID = process.env.SUPERMEMORY_MCP_CLIENT_ID
export type AuthServerMetadata = {
authorization_endpoint: string
token_endpoint: string
registration_endpoint: string
grant_types_supported?: string[]
code_challenge_methods_supported?: string[]
response_types_supported?: string[]
}
// Walk the discovery chain a real MCP client follows: protected-resource → authorization server.
export async function authServerMetadata(): Promise<{
authServer: string
metadata: AuthServerMetadata
}> {
const prRes = await fetch(
`${ORIGIN}/.well-known/oauth-protected-resource/mcp`,
)
const pr = (await prRes.json()) as { authorization_servers?: string[] }
const authServer = pr.authorization_servers?.[0]
if (!authServer) throw new Error("no authorization_servers in metadata")
const metaRes = await fetch(
`${authServer}/.well-known/oauth-authorization-server`,
)
return { authServer, metadata: (await metaRes.json()) as AuthServerMetadata }
}
export async function registerClient(registrationEndpoint: string): Promise<{
status: number
body: { client_id?: string; grant_types?: string[] }
}> {
const res = await fetch(registrationEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_name: "sm-mcp-e2e",
redirect_uris: ["http://localhost:8765/callback"],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
}),
})
return { status: res.status, body: await res.json() }
}
export async function exchangeRefreshToken(
tokenEndpoint: string,
refreshToken: string,
clientId: string,
): Promise<{
status: number
body: { access_token?: string; error?: string }
}> {
const res = await fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
}),
})
return { status: res.status, body: await res.json() }
}
export type CallResult = {
content?: Array<{ type: string; text?: string }>
structuredContent?: unknown
isError?: boolean
}
export function textOf(res: CallResult): string {
return (res.content ?? [])
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text)
.join("\n")
}
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
export type Session = { client: Client; close: () => Promise<void> }
export async function connect(
opts: { apiKey?: string; token?: string; containerTag?: string } = {},
): Promise<Session> {
const headers: Record<string, string> = {
Authorization: `Bearer ${opts.token ?? opts.apiKey ?? API_KEY}`,
}
if (opts.containerTag) headers["x-sm-project"] = opts.containerTag
const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
requestInit: { headers },
})
const client = new Client({ name: "sm-mcp-e2e", version: "0.0.1" })
await client.connect(transport)
return {
client,
close: () => transport.close().catch(() => {}),
}
}
export async function callTool(
client: Client,
name: string,
args: Record<string, unknown> = {},
): Promise<CallResult> {
return (await client.callTool({ name, arguments: args })) as CallResult
}
// recall is eventually-consistent (save → ingestion pipeline → memories), so poll.
export async function recallUntil(
client: Client,
query: string,
needle: string,
{
tries = 18,
delayMs = 5000,
containerTag = undefined as string | undefined,
} = {},
): Promise<string | null> {
for (let i = 0; i < tries; i++) {
const res = await callTool(client, "recall", {
query,
includeProfile: false,
...(containerTag ? { containerTag } : {}),
})
const txt = textOf(res)
if (txt.includes(needle)) return txt
await sleep(delayMs)
}
return null
}
// forget only matches extracted memory entries, not raw chunks, so a just-saved doc
// returns "No matching memory found..." until extraction completes — poll for real removal.
export async function forgetUntilForgotten(
client: Client,
content: string,
{
tries = 18,
delayMs = 5000,
containerTag = undefined as string | undefined,
} = {},
): Promise<string | null> {
for (let i = 0; i < tries; i++) {
const res = await callTool(client, "memory", {
content,
action: "forget",
...(containerTag ? { containerTag } : {}),
})
if (!res.isError && /forgot/i.test(textOf(res))) return textOf(res)
await sleep(delayMs)
}
return null
}
// poll until a memory is NO LONGER returned (for verifying forget).
export async function recallUntilAbsent(
client: Client,
query: string,
needle: string,
{ tries = 12, delayMs = 5000 } = {},
): Promise<boolean> {
for (let i = 0; i < tries; i++) {
const res = await callTool(client, "recall", {
query,
includeProfile: false,
})
if (!textOf(res).includes(needle)) return true
await sleep(delayMs)
}
return false
}

127
apps/mcp/e2e/memory.test.ts Normal file
View file

@ -0,0 +1,127 @@
import { randomUUID } from "node:crypto"
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import {
API_KEY,
callTool,
connect,
forgetUntilForgotten,
recallUntil,
recallUntilAbsent,
type Session,
textOf,
} from "./helpers"
describe.skipIf(!API_KEY)("MCP — memory behaviors", () => {
let s: Session
const created: Array<{ content: string; containerTag?: string }> = []
beforeAll(async () => {
s = await connect()
})
afterAll(async () => {
for (const { content, containerTag } of created) {
await callTool(s.client, "memory", {
content,
action: "forget",
...(containerTag ? { containerTag } : {}),
}).catch(() => {})
}
await s?.close()
})
it("save → recall round-trips the saved memory", async () => {
const marker = `rt-${randomUUID()}`
const content = `e2e round-trip. token=${marker}. The test fruit is dragonfruit.`
created.push({ content })
const save = await callTool(s.client, "memory", { content, action: "save" })
expect(save.isError).toBeFalsy()
expect(textOf(save)).toMatch(/Saved memory/i)
const found = await recallUntil(s.client, "test fruit dragonfruit", marker)
expect(found, `recall never returned marker ${marker}`).not.toBeNull()
}, 120_000)
it("recall includeProfile=true returns profile + memories sections", async () => {
const res = await callTool(s.client, "recall", {
query: "dragonfruit",
includeProfile: true,
})
expect(res.isError).toBeFalsy()
const txt = textOf(res)
expect(txt).toMatch(/## (User Profile|Relevant Memories)/)
}, 30_000)
// Hybrid search returns nearest matches even for unrelated queries — assert it responds gracefully, not empty.
it("recall responds gracefully for an unmatched query", async () => {
const res = await callTool(s.client, "recall", {
query: `zzz-no-such-memory-${randomUUID()}`,
includeProfile: false,
})
expect(res.isError).toBeFalsy()
expect(textOf(res)).toMatch(/## Relevant Memories|No memories found/i)
})
// Hard-asserts forget is accepted; removal is eventually-consistent, so disappearance is best-effort.
it("forget accepts and removes a saved memory", async () => {
const marker = `fg-${randomUUID()}`
const content = `e2e forget target. token=${marker}. Secret animal is axolotl.`
created.push({ content })
await callTool(s.client, "memory", { content, action: "save" })
const found = await recallUntil(s.client, "secret animal axolotl", marker)
expect(found, "memory should exist before forget").not.toBeNull()
// Polls forget until it confirms real removal ("forgot"), past the extraction window.
const forgotten = await forgetUntilForgotten(s.client, content)
expect(
forgotten,
`forget never confirmed removal for ${marker} (memory entry never extracted in time)`,
).not.toBeNull()
const gone = await recallUntilAbsent(
s.client,
"secret animal axolotl",
marker,
)
if (!gone) {
console.warn(
`[e2e] forget confirmed but ${marker} still indexed after ~60s (eventual deletion)`,
)
}
}, 240_000)
it("containerTag scopes memories (isolation)", async () => {
// Fixed tags (not per-run UUIDs) so the test doesn't mint a new project each run.
const tagA = "sm_e2e_scope_a"
const tagB = "sm_e2e_scope_b"
const marker = `sc-${randomUUID()}`
const content = `e2e scoping. token=${marker}. Project color is teal.`
created.push({ content, containerTag: tagA })
await callTool(s.client, "memory", {
content,
action: "save",
containerTag: tagA,
})
const inA = await recallUntil(s.client, "project color teal", marker, {
containerTag: tagA,
})
expect(inA, "marker should be found in its own container").not.toBeNull()
// Same query scoped to a different container must NOT see it.
const leaked = await recallUntil(s.client, "project color teal", marker, {
containerTag: tagB,
tries: 3,
delayMs: 3000,
})
expect(leaked, "marker leaked across containers").toBeNull()
}, 120_000)
it("returns an error result for a missing required argument", async () => {
const res = await callTool(s.client, "recall", {})
expect(res.isError).toBe(true)
expect(textOf(res).length).toBeGreaterThan(0)
})
})

126
apps/mcp/e2e/oauth.test.ts Normal file
View file

@ -0,0 +1,126 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import {
authServerMetadata,
type AuthServerMetadata,
callTool,
connect,
exchangeRefreshToken,
OAUTH_CLIENT_ID,
OAUTH_REFRESH_TOKEN,
registerClient,
type Session,
textOf,
} from "./helpers"
// Tiers A–C exercise the real OAuth protocol wiring with no secrets and no browser.
describe("MCP — OAuth protocol (no secrets)", () => {
let meta: AuthServerMetadata
beforeAll(async () => {
meta = (await authServerMetadata()).metadata
})
// Tier A — the discovery chain a client walks from a 401 to the auth server.
it("discovers the authorization server from protected-resource metadata", () => {
expect(meta.authorization_endpoint).toMatch(/\/authorize$/)
expect(meta.token_endpoint).toMatch(/\/token$/)
expect(meta.registration_endpoint).toMatch(/\/register$/)
})
it("advertises PKCE S256 and the authorization_code + refresh_token grants", () => {
expect(meta.code_challenge_methods_supported).toContain("S256")
expect(meta.response_types_supported).toContain("code")
expect(meta.grant_types_supported).toContain("authorization_code")
expect(meta.grant_types_supported).toContain("refresh_token")
})
// Tier B — Dynamic Client Registration, the first authenticated-flow step.
it("issues a client_id via dynamic client registration", async () => {
const { status, body } = await registerClient(meta.registration_endpoint)
expect(status).toBe(201)
expect(body.client_id).toBeTruthy()
expect(body.grant_types).toContain("refresh_token")
})
// Tier C — token endpoint rejects forged grants with proper OAuth errors.
it("rejects a bogus refresh_token with invalid_grant", async () => {
const { status, body } = await exchangeRefreshToken(
meta.token_endpoint,
"bogus_rt_for_e2e",
"bogus_client",
)
expect(status).toBe(401)
expect(body.error).toBe("invalid_grant")
})
it("rejects a bogus authorization code with invalid_grant", async () => {
const res = await fetch(meta.token_endpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: "bogus_code",
client_id: "bogus",
code_verifier: "abc",
redirect_uri: "http://localhost:8765/callback",
}),
})
expect(res.status).toBe(401)
expect(((await res.json()) as { error?: string }).error).toBe(
"invalid_grant",
)
})
it("redirects an unauthenticated authorize request to login", async () => {
const url = new URL(meta.authorization_endpoint)
url.search = new URLSearchParams({
response_type: "code",
client_id: "any",
redirect_uri: "http://localhost:8765/callback",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
scope: "openid profile email offline_access",
state: "xyz",
}).toString()
const res = await fetch(url, { redirect: "manual" })
expect(res.status).toBe(302)
expect(res.headers.get("location")).toMatch(/\/login/)
})
})
// Tier D — real OAuth token through /mcp, exercising validateOAuthToken (not the sm_ branch); needs a seeded refresh token.
describe.skipIf(!OAUTH_REFRESH_TOKEN || !OAUTH_CLIENT_ID)(
"MCP — real OAuth token round-trip",
() => {
let s: Session
let accessToken: string
beforeAll(async () => {
const { metadata } = await authServerMetadata()
const { status, body } = await exchangeRefreshToken(
metadata.token_endpoint,
OAUTH_REFRESH_TOKEN as string,
OAUTH_CLIENT_ID as string,
)
expect(status, `refresh exchange failed: ${JSON.stringify(body)}`).toBe(
200,
)
expect(body.access_token).toBeTruthy()
accessToken = body.access_token as string
})
afterAll(async () => {
await s?.close()
})
it("mints an OAuth access token that is not an sm_ API key", () => {
expect(accessToken.startsWith("sm_")).toBe(false)
})
it("connects to /mcp with the OAuth token and resolves identity", async () => {
s = await connect({ token: accessToken })
const res = await callTool(s.client, "whoAmI")
expect(res.isError).toBeFalsy()
expect(JSON.parse(textOf(res)).userId).toBeTruthy()
})
},
)

View file

@ -0,0 +1,80 @@
import { randomUUID } from "node:crypto"
import { describe, expect, it } from "vitest"
import { API_KEY, callTool, connect, recallUntil, textOf } from "./helpers"
type ToolLike = {
name: string
inputSchema?: { properties?: Record<string, unknown> }
}
const propsOf = (tools: ToolLike[], name: string): Record<string, unknown> =>
tools.find((t) => t.name === name)?.inputSchema?.properties ?? {}
// Fixed tag (not a per-run UUID) so the test doesn't mint a new project each run.
const SCOPE_TAG = "sm_e2e_root"
// x-sm-project locks the connection to one project: strips containerTag from schemas and scopes every op — distinct from the per-call arg.
describe.skipIf(!API_KEY)("MCP — x-sm-project root scoping", () => {
it("strips containerTag from tool schemas when x-sm-project is set", async () => {
const scoped = await connect({ containerTag: SCOPE_TAG })
const plain = await connect()
try {
const scopedTools = (await scoped.client.listTools()).tools
const plainTools = (await plain.client.listTools()).tools
expect(propsOf(plainTools, "memory")).toHaveProperty("containerTag")
expect(propsOf(plainTools, "recall")).toHaveProperty("containerTag")
expect(propsOf(scopedTools, "memory")).not.toHaveProperty("containerTag")
expect(propsOf(scopedTools, "recall")).not.toHaveProperty("containerTag")
} finally {
await scoped.close()
await plain.close()
}
})
it("scopes saves to the connection project and isolates them from default", async () => {
const marker = `root-${randomUUID()}`
const content = `e2e root scope. token=${marker}. The root flower is bluebell.`
const rooted = await connect({ containerTag: SCOPE_TAG })
try {
const save = await callTool(rooted.client, "memory", {
content,
action: "save",
})
expect(save.isError).toBeFalsy()
expect(textOf(save)).toContain(SCOPE_TAG)
const found = await recallUntil(
rooted.client,
"root flower bluebell",
marker,
)
expect(found, "marker not found within its root scope").not.toBeNull()
} finally {
await callTool(rooted.client, "memory", {
content,
action: "forget",
}).catch(() => {})
await rooted.close()
}
// A default connection searches sm_project_default only — must not see it.
const plain = await connect()
try {
const leaked = await recallUntil(
plain.client,
"root flower bluebell",
marker,
{
tries: 3,
delayMs: 3000,
},
)
expect(leaked, "rooted memory leaked into the default project").toBeNull()
} finally {
await plain.close()
}
}, 120_000)
})

View file

@ -8,7 +8,8 @@
"dev": "portless",
"dev:app": "vite build && wrangler dev --port ${PORT:-8788}",
"deploy": "vite build && wrangler deploy --minify",
"cf-typegen": "wrangler types --env-interface CloudflareBindings"
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
"test:e2e": "vitest run"
},
"dependencies": {
"@cloudflare/workers-oauth-provider": "^0.2.2",
@ -27,6 +28,7 @@
"typescript": "^5.8.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0",
"vitest": "^3.2.4",
"wrangler": "^4.4.0"
}
}

View file

@ -5,21 +5,29 @@ const DEFAULT_PROJECT_ID = "sm_project_default"
const DEFAULT_LIST_LIMIT = 50
const MAX_LIST_LIMIT = 200
interface MemoryRichFields {
metadata?: Record<string, unknown> | null
updatedAt?: string
context?: Record<string, unknown>
documents?: Array<Record<string, unknown>>
isAggregated?: boolean
}
export type Memory =
| {
| ({
id: string
memory: string
similarity: number
title?: string
content?: string
}
| {
} & MemoryRichFields)
| ({
id: string
chunk: string
similarity: number
title?: string
content?: string
}
} & MemoryRichFields)
export interface SearchResult {
results: Memory[]
@ -67,6 +75,17 @@ export interface ListMemoriesOptions {
export interface ListMemoriesResult {
memories: ListedMemory[]
nextCursor: string | null
export interface SearchOptions {
searchMode?: "memories" | "hybrid" | "documents"
rerank?: boolean
rewriteQuery?: boolean
include?: {
documents?: boolean
relatedMemories?: boolean
summaries?: boolean
chunks?: boolean
forgottenMemories?: boolean
}
}
export interface Profile {
@ -142,7 +161,11 @@ interface SDKResult {
content?: string
similarity: number
title?: string
context?: string
metadata?: Record<string, unknown> | null
updatedAt?: string
context?: Record<string, unknown>
documents?: Array<Record<string, unknown>>
isAggregated?: boolean
}
interface SDKListMemory {
@ -377,26 +400,32 @@ export class SupermemoryClient {
query: string,
limit = 10,
threshold?: number,
options?: SearchOptions,
): Promise<SearchResult> {
try {
const result = await this.client.search.memories({
q: query,
limit,
containerTag: this.containerTag,
searchMode: "hybrid",
searchMode: options?.searchMode ?? "hybrid",
threshold, // Optional threshold parameter
rerank: options?.rerank,
rewriteQuery: options?.rewriteQuery,
include: options?.include,
})
// Normalize and limit response size — preserve memory vs chunk distinction
const results: Memory[] = (result.results as SDKResult[]).map((r) => {
const text = limitByChars(
r.content || r.memory || r.chunk || r.context || "",
)
const text = limitByChars(r.content || r.memory || r.chunk || "")
const base = {
id: r.id,
similarity: r.similarity,
title: r.title,
content: r.content,
metadata: r.metadata,
updatedAt: r.updatedAt,
context: r.context,
documents: r.documents,
isAggregated: r.isAggregated,
}
if (r.chunk && !r.memory) {
return { ...base, chunk: text }
@ -468,9 +497,7 @@ export class SupermemoryClient {
if (result.searchResults) {
response.searchResults = {
results: (result.searchResults.results as SDKResult[]).map((r) => {
const text = limitByChars(
r.content || r.memory || r.chunk || r.context || "",
)
const text = limitByChars(r.content || r.memory || r.chunk || "")
const base = {
id: r.id,
similarity: r.similarity,

157
apps/mcp/src/format.ts Normal file
View file

@ -0,0 +1,157 @@
export function formatMemories(
response: { results?: Array<Record<string, unknown>>; total?: number },
opts: {
minSimilarity?: number
maxRelations?: number
maxDocuments?: number
maxChunkLength?: number
includeScores?: boolean
includeLegend?: boolean
} = {},
) {
const {
minSimilarity = 0,
maxRelations = 4,
maxDocuments = 3,
maxChunkLength = Number.POSITIVE_INFINITY,
includeScores = true,
includeLegend = true,
} = opts
const day = (s: string | null | undefined) => s?.slice(0, 10) ?? ""
const mime = (m: string | undefined) =>
!m
? ""
: m === "application/pdf"
? "pdf"
: m.includes("spreadsheet")
? "xlsx"
: m.includes("presentation")
? "pptx"
: m.includes("document")
? "doc"
: (m.split("/").pop() ?? "")
const temporal = (tc: Record<string, unknown> | undefined) => {
if (!tc) return [] as string[]
const ev = ((tc.eventDate as string[]) ?? []).map(day).filter(Boolean)
return [
tc.documentDate && `doc ${day(tc.documentDate as string)}`,
ev.length === 1 && `event ${ev[0]}`,
ev.length > 1 && `event ${ev[0]} → ${ev.at(-1)}`,
].filter(Boolean) as string[]
}
const describeMeta = (m: Record<string, unknown> | undefined | null) => {
if (!m) return ""
const tags = [
mime(m.mimeType as string | undefined),
m.source as string | undefined,
...temporal(m.temporalContext as Record<string, unknown> | undefined),
].filter(Boolean)
return [m.title && `"${m.title}"`, tags.length && `(${tags.join(", ")})`]
.filter(Boolean)
.join(" ")
}
const renderRelations = (
rels: Array<Record<string, unknown>> | undefined,
arrow: string,
root: string,
) => {
if (!rels?.length) return [] as string[]
const seen = new Set<string>()
const items = rels.filter((r) => {
const k = (r.memory as string).trim()
if (k === root.trim() || seen.has(k)) return false
seen.add(k)
return true
})
const shown = items.slice(0, maxRelations)
const lines = shown.map((r) => {
const t = temporal(
(r.metadata as Record<string, unknown> | undefined)?.temporalContext as
| Record<string, unknown>
| undefined,
)
const when = t.length ? t.join(", ") : day(r.updatedAt as string)
return ` ${arrow} ${r.relation}${when ? `, ${when}` : ""}: ${r.memory}`
})
if (items.length > shown.length)
lines.push(` ${arrow} … +${items.length - shown.length} more`)
return lines
}
const renderDocs = (ds: Array<Record<string, unknown>> | undefined) =>
(ds ?? []).slice(0, maxDocuments).map((d) => {
const title = d.title ? `"${d.title}"` : "(untitled)"
const type = d.type ? ` (${d.type})` : ""
const summary = d.summary ? ` — ${d.summary}` : ""
return ` Document: ${title}${type}${summary}`
})
const results = (response.results ?? []).filter(
(m) => ((m.similarity as number) ?? 0) >= minSimilarity,
)
if (!results.length) return "No relevant memories found."
const total = response.total ?? results.length
const header = [
`${results.length} memor${results.length === 1 ? "y" : "ies"}` +
(total !== results.length ? ` of ${total}` : "") +
", ranked by relevance.",
includeLegend &&
"Markers: 'agg' = aggregated synthesis, 'chunk' = raw excerpt; ← parent, → child, ~ related.",
]
.filter(Boolean)
.join(" ")
const arrows = [
["parents", "←"],
["children", "→"],
["related", "~"],
] as const
const blocks = results.map((m) => {
const score = (m.similarity as number)?.toFixed(2) ?? "—"
const prefix = includeScores ? `${score} ` : ""
const memory = (m.memory as string) ?? ""
if (m.isAggregated) return `${prefix}agg ${memory}`
if (m.chunk != null && m.memory == null) {
const body = (m.chunk as string).replace(/\s+$/, "")
const text =
body.length > maxChunkLength
? `${body.slice(0, maxChunkLength)} … [truncated, ${body.length - maxChunkLength} more chars]`
: body
return [
`${prefix}chunk ${describeMeta(m.metadata as Record<string, unknown> | null)}`.trimEnd(),
...renderDocs(
m.documents as Array<Record<string, unknown>> | undefined,
),
...text.split("\n").map((l: string) => ` ${l}`),
].join("\n")
}
const meta = describeMeta(m.metadata as Record<string, unknown> | null)
const ctx = (m.context ?? {}) as Record<
string,
Array<Record<string, unknown>>
>
return [
`${prefix}${memory}`,
meta
? ` Source: ${meta}`
: day(m.updatedAt as string)
? ` Source: updated ${day(m.updatedAt as string)}`
: null,
...renderDocs(m.documents as Array<Record<string, unknown>> | undefined),
...arrows.flatMap(([k, a]) => renderRelations(ctx[k], a, memory)),
]
.filter(Boolean)
.join("\n")
})
return [header, "", blocks.join("\n\n")].join("\n")
}

View file

@ -5,7 +5,8 @@ import {
registerAppResource,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server"
import { SupermemoryClient, getMemoryText } from "./client"
import { SupermemoryClient } from "./client"
import { formatMemories } from "./format"
import { initPosthog, posthog } from "./posthog"
import { z } from "zod"
import mcpAppHtml from "../dist/mcp-app.html"
@ -26,6 +27,8 @@ type Props = {
const CONTAINER_TAGS_TTL_MS = 5 * 60 * 1000
const MAX_RECALL_CHARS = 200000
export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
private clientInfo: { name: string; version?: string } | null = null
private cachedContainerTags: string[] = []
@ -680,10 +683,21 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
const clientInfo = await this.getClientInfo()
const startTime = Date.now()
if (includeProfile) {
const profileResult = await client.getProfile(query)
const parts: string[] = []
const searchResult = await client.search(query, 10, undefined, {
searchMode: "hybrid",
include: {
documents: true,
relatedMemories: true,
summaries: false,
chunks: false,
forgottenMemories: false,
},
})
const parts: string[] = []
if (includeProfile) {
const profileResult = await client.getProfile()
if (
profileResult.profile.static.length > 0 ||
profileResult.profile.dynamic.length > 0
@ -701,54 +715,23 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
parts.push(`- ${fact}`)
}
}
}
if (profileResult.searchResults?.results.length) {
parts.push("\n## Relevant Memories")
for (const [
i,
memory,
] of profileResult.searchResults.results.entries()) {
parts.push(
`\n### Memory ${i + 1} (${Math.round(memory.similarity * 100)}% match)`,
)
if (memory.title) parts.push(`**${memory.title}**`)
parts.push(getMemoryText(memory))
}
}
const endTime = Date.now()
// Track search event
posthog
.memorySearch({
query_length: query.length,
results_count: profileResult.searchResults?.results.length || 0,
search_duration_ms: endTime - startTime,
container_tags_count: 1,
source: "mcp",
userId: this.props?.userId || "unknown",
mcp_client_name: clientInfo?.name,
mcp_client_version: clientInfo?.version,
sessionId: this.getMcpSessionId(),
containerTag: containerTag || this.props?.containerTag,
})
.catch((error) => console.error("PostHog tracking error:", error))
return {
content: [
{
type: "text" as const,
text:
parts.length > 0
? parts.join("\n")
: "No memories or profile found.",
},
],
parts.push("")
}
}
const searchResult = await client.search(query, 10)
parts.push("## Relevant Memories")
parts.push(
formatMemories(
{
results: searchResult.results as unknown as Array<
Record<string, unknown>
>,
total: searchResult.total,
},
{ includeScores: true, includeLegend: true },
),
)
const endTime = Date.now()
// Track search event
@ -767,22 +750,18 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
})
.catch((error) => console.error("PostHog tracking error:", error))
if (searchResult.results.length === 0) {
return {
content: [{ type: "text" as const, text: "No memories found." }],
}
const text = parts.join("\n")
return {
content: [
{
type: "text" as const,
text:
text.length > MAX_RECALL_CHARS
? `${text.slice(0, MAX_RECALL_CHARS)}...`
: text,
},
],
}
const parts = ["## Relevant Memories"]
for (const [i, memory] of searchResult.results.entries()) {
parts.push(
`\n### Memory ${i + 1} (${Math.round(memory.similarity * 100)}% match)`,
)
if (memory.title) parts.push(`**${memory.title}**`)
parts.push(getMemoryText(memory))
}
return { content: [{ type: "text" as const, text: parts.join("\n") }] }
} catch (error) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred"

View file

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

View file

@ -0,0 +1,45 @@
import { NextResponse } from "next/server"
const SUPERMEMORY_API_BASE_URL = "https://api.supermemory.ai"
export async function POST(request: Request) {
try {
const { apiKey } = await request.json()
if (!apiKey) {
return NextResponse.json(
{ error: "API key is required" },
{ status: 400 },
)
}
const containerTagsUrl = new URL(
"/v3/container-tags/list",
SUPERMEMORY_API_BASE_URL,
)
const response = await fetch(containerTagsUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
return NextResponse.json(
{ error: errorData.message || `API error: ${response.status}` },
{ status: response.status },
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error("Container tags API error:", error)
return NextResponse.json(
{ error: "Failed to fetch container tags" },
{ status: 500 },
)
}
}

View file

@ -1,5 +1,7 @@
import { NextResponse } from "next/server"
const SUPERMEMORY_API_BASE_URL = "https://api.supermemory.ai"
export async function POST(request: Request) {
try {
const body = await request.json()
@ -9,6 +11,7 @@ export async function POST(request: Request) {
limit = 500,
sort = "createdAt",
order = "desc",
containerTags,
} = body
if (!apiKey) {
@ -18,23 +21,28 @@ export async function POST(request: Request) {
)
}
const response = await fetch(
"https://api.supermemory.ai/v3/documents/documents",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
page,
limit,
sort,
order,
}),
},
const graphUrl = new URL(
"/v3/documents/documents",
SUPERMEMORY_API_BASE_URL,
)
const response = await fetch(graphUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
page,
limit,
sort,
order,
...(Array.isArray(containerTags) && containerTags.length > 0
? { containerTags }
: {}),
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
return NextResponse.json(

View file

@ -1,16 +1,52 @@
"use client"
import { useState, useCallback, useMemo } from "react"
import { useState, useCallback, useEffect, useMemo } from "react"
import {
MemoryGraph,
type DocumentWithMemories,
type GraphApiDocument,
type GraphApiMemory,
type GraphThemeColors,
type MemoryRelation,
} from "@supermemory/memory-graph"
import { generateMockGraphData } from "@supermemory/memory-graph/mock-data"
interface PlaygroundApiMemory {
id: string
memory?: string | null
content?: string | null
isStatic?: boolean
spaceId?: string | null
isLatest?: boolean
isForgotten?: boolean
forgetAfter?: string | null
forgetReason?: string | null
version?: number
parentMemoryId?: string | null
rootMemoryId?: string | null
createdAt: string
updatedAt: string
relation?: MemoryRelation | null
updatesMemoryId?: string | null
nextVersionId?: string | null
memoryRelations?: Record<string, MemoryRelation> | null
spaceContainerTag?: string | null
}
interface PlaygroundApiDocument {
id: string
title: string | null
summary?: string | null
documentType?: string
type?: string
containerTags?: string[]
createdAt: string
updatedAt: string
memories?: PlaygroundApiMemory[]
memoryEntries?: PlaygroundApiMemory[]
}
interface DocumentsResponse {
documents: DocumentWithMemories[]
documents: PlaygroundApiDocument[]
pagination: {
currentPage: number
limit: number
@ -19,42 +55,82 @@ interface DocumentsResponse {
}
}
interface ContainerTagOption {
id: string
name?: string | null
containerTag: string
documentCount?: number
memoryCount?: number
lastActivityAt?: string | null
}
type GraphVariant = "consumer" | "console"
type LoadBehavior = "zoom" | "manual" | "background"
const PAGE_SIZE = 100
const BACKGROUND_LOAD_DELAY_MS = 900
const CONSUMER_GRAPH_COLORS = {
bg: "transparent",
edgeDerives: "#9ca3af",
} satisfies Partial<GraphThemeColors>
/** Convert the external API format to the internal graph format */
function toGraphDocuments(docs: DocumentWithMemories[]): GraphApiDocument[] {
return docs.map((doc) => ({
id: doc.id,
title: doc.title,
summary: doc.summary ?? null,
documentType: doc.documentType,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
memories: doc.memories.map(
(mem): GraphApiMemory => ({
id: mem.id,
memory: mem.content,
isStatic: mem.isStatic ?? false,
spaceId: mem.spaceId ?? "",
isLatest: mem.isLatest ?? true,
isForgotten: mem.isForgotten ?? false,
forgetAfter: mem.forgetAfter ?? null,
forgetReason: mem.forgetReason ?? null,
version: mem.version ?? 1,
parentMemoryId: mem.parentMemoryId ?? null,
rootMemoryId: mem.rootMemoryId ?? null,
createdAt: mem.createdAt,
updatedAt: mem.updatedAt,
}),
),
}))
function toGraphDocuments(docs: PlaygroundApiDocument[]): GraphApiDocument[] {
return docs.map((doc) => {
const memories = doc.memories ?? doc.memoryEntries ?? []
return {
id: doc.id,
title: doc.title,
summary: doc.summary ?? null,
documentType: doc.documentType ?? doc.type ?? "unknown",
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
memories: memories.map(
(mem): GraphApiMemory => ({
id: mem.id,
memory: mem.memory ?? mem.content ?? "",
isStatic: mem.isStatic ?? false,
spaceId: mem.spaceId ?? "",
isLatest: mem.isLatest ?? true,
isForgotten: mem.isForgotten ?? false,
forgetAfter: mem.forgetAfter ?? null,
forgetReason: mem.forgetReason ?? null,
version: mem.version ?? 1,
parentMemoryId: mem.parentMemoryId ?? null,
rootMemoryId: mem.rootMemoryId ?? null,
createdAt: mem.createdAt,
updatedAt: mem.updatedAt,
relation: mem.relation ?? null,
updatesMemoryId: mem.updatesMemoryId ?? null,
nextVersionId: mem.nextVersionId ?? null,
memoryRelations: mem.memoryRelations ?? null,
spaceContainerTag: mem.spaceContainerTag ?? null,
}),
),
}
})
}
export default function Home() {
const [apiKey, setApiKey] = useState("")
const [documents, setDocuments] = useState<DocumentWithMemories[]>([])
const [containerTag, setContainerTag] = useState("")
const [containerTags, setContainerTags] = useState<ContainerTagOption[]>([])
const [isLoadingContainerTags, setIsLoadingContainerTags] = useState(false)
const [containerTagsError, setContainerTagsError] = useState<Error | null>(
null,
)
const [documents, setDocuments] = useState<PlaygroundApiDocument[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [showGraph, setShowGraph] = useState(false)
const [stressTestCount, setStressTestCount] = useState(0)
const [graphVariant, setGraphVariant] = useState<GraphVariant>("consumer")
const [loadBehavior, setLoadBehavior] = useState<LoadBehavior>("zoom")
const [pagination, setPagination] = useState<
DocumentsResponse["pagination"] | null
>(null)
// State for slideshow
const [isSlideshowActive, setIsSlideshowActive] = useState(false)
@ -64,13 +140,47 @@ export default function Home() {
documents: GraphApiDocument[]
} | null>(null)
const PAGE_SIZE = 500
const selectedContainerTags = useMemo(() => {
const trimmed = containerTag.trim()
return trimmed ? [trimmed] : undefined
}, [containerTag])
const fetchContainerTags = useCallback(async () => {
if (!apiKey || isLoadingContainerTags) return
setIsLoadingContainerTags(true)
setContainerTagsError(null)
try {
const response = await fetch("/api/container-tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey }),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || "Failed to fetch container tags")
}
const data = (await response.json()) as ContainerTagOption[]
setContainerTags(data)
} catch (err) {
setContainerTagsError(
err instanceof Error ? err : new Error("Unknown error"),
)
} finally {
setIsLoadingContainerTags(false)
}
}, [apiKey, isLoadingContainerTags])
const fetchDocuments = useCallback(
async (page: number, append = false) => {
if (!apiKey) return
if (page === 1) {
if (append) {
setIsLoadingMore(true)
} else {
setIsLoading(true)
}
setError(null)
@ -87,6 +197,7 @@ export default function Home() {
limit: PAGE_SIZE,
sort: "createdAt",
order: "desc",
containerTags: selectedContainerTags,
}),
})
@ -103,6 +214,7 @@ export default function Home() {
setDocuments(data.documents)
}
setPagination(data.pagination)
setShowGraph(true)
setMockData(null)
setStressTestCount(0)
@ -110,19 +222,27 @@ export default function Home() {
setError(err instanceof Error ? err : new Error("Unknown error"))
} finally {
setIsLoading(false)
setIsLoadingMore(false)
}
},
[apiKey],
[apiKey, selectedContainerTags],
)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (apiKey) {
setDocuments([])
setPagination(null)
void fetchContainerTags()
fetchDocuments(1)
}
}
const handleLoadMoreDocuments = useCallback(() => {
if (!pagination || pagination.currentPage >= pagination.totalPages) return
fetchDocuments(pagination.currentPage + 1, true)
}, [fetchDocuments, pagination])
const handleStressTest = (count: number) => {
const data = generateMockGraphData({
documentCount: count,
@ -131,6 +251,7 @@ export default function Home() {
})
setMockData({ documents: data.documents })
setDocuments([])
setPagination(null)
setStressTestCount(count)
setShowGraph(true)
setError(null)
@ -157,7 +278,67 @@ export default function Home() {
return toGraphDocuments(documents)
}, [documents, mockData])
const availableContainerTags = useMemo(() => {
const options = new Map<string, ContainerTagOption>()
for (const tag of containerTags) {
if (tag.containerTag) options.set(tag.containerTag, tag)
}
for (const doc of documents) {
for (const tag of doc.containerTags ?? []) {
if (tag && !options.has(tag)) {
options.set(tag, { id: tag, containerTag: tag, name: tag })
}
}
const memories = doc.memories ?? doc.memoryEntries ?? []
for (const mem of memories) {
const tag = mem.spaceContainerTag
if (tag && !options.has(tag)) {
options.set(tag, { id: tag, containerTag: tag, name: tag })
}
}
}
return [...options.values()]
}, [containerTags, documents])
const displayCount = mockData ? stressTestCount : documents.length
const hasMore =
!mockData &&
pagination != null &&
pagination.currentPage < pagination.totalPages
const totalCount = mockData
? stressTestCount
: (pagination?.totalItems ?? documents.length)
const maxNodes = mockData ? 1000 : undefined
const graphHandlesLoadMore = loadBehavior === "zoom"
useEffect(() => {
if (
loadBehavior !== "background" ||
!showGraph ||
mockData ||
!hasMore ||
isLoading ||
isLoadingMore ||
error
) {
return
}
const timer = window.setTimeout(
handleLoadMoreDocuments,
BACKGROUND_LOAD_DELAY_MS,
)
return () => window.clearTimeout(timer)
}, [
error,
handleLoadMoreDocuments,
hasMore,
isLoading,
isLoadingMore,
loadBehavior,
mockData,
showGraph,
])
return (
<div className="flex flex-col h-screen bg-zinc-950">
@ -178,9 +359,49 @@ export default function Home() {
type="password"
placeholder="Enter your Supermemory API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
onChange={(e) => {
setApiKey(e.target.value)
setContainerTags([])
setContainerTagsError(null)
}}
className="w-80 rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-white placeholder-zinc-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<div className="flex items-center gap-2">
<input
list="container-tag-options"
value={containerTag}
onChange={(e) => setContainerTag(e.target.value)}
onFocus={() => {
if (availableContainerTags.length === 0) {
void fetchContainerTags()
}
}}
disabled={!apiKey}
placeholder={
isLoadingContainerTags
? "Loading container tags..."
: "All container tags"
}
className="w-64 rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
/>
<datalist id="container-tag-options">
{availableContainerTags.map((tag) => (
<option key={tag.containerTag} value={tag.containerTag}>
{tag.name && tag.name !== tag.containerTag
? `${tag.name} (${tag.containerTag})`
: tag.containerTag}
</option>
))}
</datalist>
<button
type="button"
onClick={() => void fetchContainerTags()}
disabled={!apiKey || isLoadingContainerTags}
className="rounded-lg border border-zinc-700 px-3 py-2 text-xs font-medium text-zinc-300 transition-colors hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoadingContainerTags ? "Loading..." : "Tags"}
</button>
</div>
<button
type="submit"
disabled={!apiKey || isLoading}
@ -205,8 +426,80 @@ export default function Home() {
Stress Test Mode
</span>
)}
{pagination && !mockData && (
<span className="font-mono text-xs text-zinc-500">
Page {pagination.currentPage}/{pagination.totalPages}
</span>
)}
{selectedContainerTags && (
<span className="rounded bg-sky-950/70 px-2 py-0.5 font-mono text-xs text-sky-300">
{selectedContainerTags[0]}
</span>
)}
{containerTagsError && (
<span className="text-xs text-amber-300">
{containerTagsError.message}
</span>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-zinc-500 text-xs">Mode:</span>
<div className="flex rounded-lg border border-zinc-700 bg-zinc-950/50 p-0.5">
{(["consumer", "console"] as const).map((variant) => (
<button
key={variant}
type="button"
onClick={() => setGraphVariant(variant)}
className={`rounded-md px-3 py-1 text-xs font-medium capitalize transition-colors ${
graphVariant === variant
? "bg-blue-600 text-white"
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
}`}
aria-pressed={graphVariant === variant}
>
{variant}
</button>
))}
</div>
<div className="h-6 w-px bg-zinc-700" />
<span className="text-zinc-500 text-xs">Load:</span>
<div className="flex rounded-lg border border-zinc-700 bg-zinc-950/50 p-0.5">
{(["zoom", "manual", "background"] as const).map((behavior) => (
<button
key={behavior}
type="button"
onClick={() => setLoadBehavior(behavior)}
className={`rounded-md px-3 py-1 text-xs font-medium capitalize transition-colors ${
loadBehavior === behavior
? "bg-emerald-600 text-white"
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200"
}`}
aria-pressed={loadBehavior === behavior}
>
{behavior}
</button>
))}
</div>
{loadBehavior === "manual" && (
<button
type="button"
onClick={handleLoadMoreDocuments}
disabled={!hasMore || isLoadingMore}
className="rounded-lg border border-zinc-700 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoadingMore
? "Loading..."
: hasMore
? "Load next"
: "All loaded"}
</button>
)}
{loadBehavior === "background" && !mockData && hasMore && (
<span className="rounded bg-emerald-950/70 px-2 py-0.5 text-xs text-emerald-300">
Auto paging
</span>
)}
<div className="h-6 w-px bg-zinc-700" />
{/* Stress test buttons */}
<span className="text-zinc-500 text-xs">Stress Test:</span>
{[50, 100, 200, 500].map((count) => (
@ -300,13 +593,24 @@ export default function Home() {
<MemoryGraph
documents={graphDocuments}
isLoading={isLoading}
isLoadingMore={isLoadingMore}
hasMore={graphHandlesLoadMore && hasMore}
onLoadMore={
graphHandlesLoadMore && hasMore
? handleLoadMoreDocuments
: undefined
}
error={error}
variant="consumer"
maxNodes={1000}
variant={graphVariant}
maxNodes={maxNodes}
showFps={stressTestCount > 0}
isSlideshowActive={isSlideshowActive}
onSlideshowNodeChange={handleSlideshowNodeChange}
onSlideshowStop={handleSlideshowStop}
totalCount={totalCount}
colors={
graphVariant === "consumer" ? CONSUMER_GRAPH_COLORS : undefined
}
>
<div className="flex h-full items-center justify-center">
<p className="text-zinc-400">

View file

@ -0,0 +1,992 @@
"use client"
import { Suspense } from "react"
import { useSearchParams } from "next/navigation"
import { GoogleDrive, Notion } from "@ui/assets/icons"
import { cn } from "@lib/utils"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { ArrowRight, Mail, MessageSquare, Plus, Sparkles } from "lucide-react"
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
import { Header } from "@/components/header"
import { HomeChatComposer } from "@/components/chat/home-chat-composer"
import {
RecommendedRail,
SourcesHealthRail,
} from "@/components/brain-home/widgets"
const modalCardStyle = {
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}
const inputBevelStyle = {
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
}
type Space = {
id: string
name: string
icon: React.ReactNode
itemCount: number
lastSync: string
}
const _SPACES: Space[] = [
{
id: "drive",
name: "My Drive",
icon: <GoogleDrive className="size-4" />,
itemCount: 287,
lastSync: "2h ago",
},
{
id: "notion",
name: "Company Notion",
icon: <Notion className="size-4" />,
itemCount: 142,
lastSync: "12m ago",
},
{
id: "gmail",
name: "Gmail",
icon: <Mail className="size-4 text-[#EA4335]" />,
itemCount: 1843,
lastSync: "Just now",
},
]
type Stat = {
label: string
value: string
delta?: string
deltaTint?: "up" | "down" | "neutral"
}
const STATS: Stat[] = [
{
label: "Items ingested",
value: "2,272",
delta: "+148 this week",
deltaTint: "up",
},
{
label: "Agent sessions",
value: "184",
delta: "+42 vs last wk",
deltaTint: "up",
},
{
label: "Active contributors",
value: "4",
delta: "of 6 invited",
deltaTint: "neutral",
},
{
label: "Top space",
value: "Gmail",
delta: "1,843 items",
deltaTint: "neutral",
},
]
type AgentSeries = {
key: string
label: string
color: string
}
const AGENT_SERIES: AgentSeries[] = [
{ key: "cursor", label: "Cursor", color: "#4BA0FA" },
{ key: "claudeCode", label: "Claude Code", color: "#FF8A47" },
{ key: "claudeDesktop", label: "Claude Desktop", color: "#B19CFF" },
{ key: "chatgpt", label: "ChatGPT", color: "#10A37F" },
{ key: "other", label: "Other", color: "#525D6E" },
]
const SESSIONS_BY_DAY: {
label: string
values: Record<string, number>
}[] = [
{
label: "Mon",
values: {
cursor: 8,
claudeCode: 4,
claudeDesktop: 3,
chatgpt: 2,
other: 1,
},
},
{
label: "Tue",
values: {
cursor: 10,
claudeCode: 6,
claudeDesktop: 4,
chatgpt: 3,
other: 1,
},
},
{
label: "Wed",
values: {
cursor: 12,
claudeCode: 9,
claudeDesktop: 5,
chatgpt: 4,
other: 1,
},
},
{
label: "Thu",
values: {
cursor: 9,
claudeCode: 5,
claudeDesktop: 4,
chatgpt: 3,
other: 1,
},
},
{
label: "Fri",
values: {
cursor: 14,
claudeCode: 10,
claudeDesktop: 6,
chatgpt: 4,
other: 2,
},
},
{
label: "Sat",
values: {
cursor: 5,
claudeCode: 3,
claudeDesktop: 2,
chatgpt: 1,
other: 1,
},
},
{
label: "Sun",
values: {
cursor: 17,
claudeCode: 12,
claudeDesktop: 7,
chatgpt: 4,
other: 1,
},
},
]
const RECENT_ANSWERS = [
{
question: "What did we decide about pricing?",
answer: "Pro stays at $20/mo. No overages, top-up only.",
who: "You",
when: "2h ago",
},
{
question: "What's blocking the Acme deal?",
answer: "MSA review — Jane drafting a net-30 counter.",
who: "You",
when: "4h ago",
},
{
question: "Who owns the onboarding rewrite?",
answer: "Design team. Ship target Friday.",
who: "Sarah Kim",
when: "Yesterday",
},
]
const WEEKLY_DIGEST = {
range: "Mon – Fri",
headline:
"Your brain captured 642 items, surfaced 12 decisions, and answered 184 questions across the team.",
bullets: [
{
label: "5 deadlines coming up next week",
tint: "text-[#4BA0FA]",
},
{
label: "2 conflicts surfaced between Notion and Slack",
tint: "text-[#FF8A47]",
},
{
label: "Sarah Kim led contributions with 14 docs",
tint: "text-[#A1A1AA]",
},
],
}
const TEAM_THIS_WEEK = [
{
name: "Sarah Kim",
email: "sarah@acme.com",
contributions: 14,
summary: "14 docs · 3 spaces",
color: "#FF8A47",
},
{
name: "Jane Doe",
email: "jane@acme.com",
contributions: 11,
summary: "11 docs · 2 spaces",
color: "#4BA0FA",
},
{
name: "Mahesh S.",
email: "mahesh@acme.com",
contributions: 9,
summary: "9 docs · 1 space",
color: "#A1A1AA",
},
{
name: "Alex Chen",
email: "alex@acme.com",
contributions: 7,
summary: "7 docs · 2 spaces",
color: "#10A37F",
},
]
const TEAM_RECENT = [
{ who: "Sarah Kim", what: "added a memo to acme-deal-q2", when: "14m" },
{ who: "Jane Doe", what: "updated the Acme MSA draft", when: "1h" },
{ who: "You", what: 'asked "What did we decide about pricing?"', when: "2h" },
{
who: "Sarah Kim",
what: "connected Notion to Sales space",
when: "Yesterday",
},
]
const ACTIVITY = [
{
when: "2m",
text: "23 new docs ingested from Drive",
source: "drive",
},
{
when: "14m",
text: "Jane added a memo to acme-deal-q2",
source: "notion",
},
{
when: "1h",
text: "Decision surfaced: pricing locked at $20 Pro",
source: "slack",
},
{
when: "3h",
text: "14 Notion pages updated by Sarah Kim",
source: "notion",
},
{
when: "5h",
text: "New entity formed: pricing-v3",
source: "system",
},
{
when: "Yesterday",
text: "MSA contract uploaded · auto-tagged contracts/MSA",
source: "drive",
},
]
const _RECENT_ITEMS: Record<string, { title: string; meta: string }[]> = {
drive: [
{ title: "Q2 Roadmap.docx", meta: "Edited 1h ago · Jane" },
{ title: "Acme · MSA draft v3.pdf", meta: "Added 2h ago" },
{ title: "Pricing model.xlsx", meta: "Edited 4h ago · Mahesh" },
{ title: "Customer feedback Q1.docx", meta: "Yesterday" },
{ title: "Brand assets / logos", meta: "2 days ago" },
],
notion: [
{ title: "Engineering / Weekly sync", meta: "Updated 12m ago" },
{ title: "Sales / Acme account", meta: "Updated 1h ago" },
{ title: "Brand / Voice & tone", meta: "Yesterday" },
{ title: "Onboarding rewrite spec", meta: "2 days ago" },
],
gmail: [
{ title: "Re: Acme contract review", meta: "Just now · jane@acme.com" },
{ title: "Customer feedback digest", meta: "10m ago" },
{ title: "Re: Pricing decision", meta: "1h ago" },
{ title: "Onboarding ship plan", meta: "3h ago" },
],
}
export default function BrainHomePage() {
return (
<Suspense fallback={null}>
<BrainHomeInner />
</Suspense>
)
}
function BrainHomeInner() {
const searchParams = useSearchParams()
const empty = searchParams?.get("empty") === "1"
return (
<div
className={cn(
"relative min-h-dvh bg-[#05080D] text-[#fafafa]",
dmSansClassName(),
)}
>
<div className="pointer-events-none fixed inset-0 z-0">
<AnimatedGradientBackground
animateFromBottom={false}
topPosition="20%"
/>
<div className="absolute inset-0 bg-[#05080D]/55" aria-hidden />
<div
id="brain-home-grid"
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.22)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_55%,transparent_100%)]"
/>
</div>
<Header onAddMemory={() => {}} onOpenSearch={() => {}} />
<main className="relative z-10 px-6 md:px-10 py-8 pb-[180px]">
<div className="max-w-[1280px] mx-auto space-y-6">
<StatsStrip stats={STATS} empty={empty} />
<div className="grid lg:grid-cols-[1fr_320px] gap-6">
<div className="space-y-8 min-w-0">
<BrainPulseBlock empty={empty} />
<BrainActivityChart empty={empty} />
<ActivityFeedBlock empty={empty} />
</div>
<aside className="lg:sticky lg:top-6 lg:self-start h-fit space-y-4">
<TeamActivityRail empty={empty} />
<SourcesHealthRail empty={empty} />
<RecommendedRail />
</aside>
</div>
</div>
</main>
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-20 h-56 bg-gradient-to-t from-[#05080D] via-[#05080D]/95 to-transparent" />
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-30">
<div className="pointer-events-auto">
<HomeChatComposer onStartChat={() => {}} />
</div>
</div>
</div>
)
}
function StatsStrip({ stats, empty }: { stats: Stat[]; empty?: boolean }) {
const EMPTY_STATS: Stat[] = [
{
label: "Items ingested",
value: "—",
delta: "Connect a source",
deltaTint: "neutral",
},
{
label: "Agent sessions",
value: "—",
delta: "Install an agent",
deltaTint: "neutral",
},
{
label: "Active contributors",
value: "1",
delta: "Just you so far",
deltaTint: "neutral",
},
{
label: "Top space",
value: "—",
delta: "Nothing connected",
deltaTint: "neutral",
},
]
const visible = empty ? EMPTY_STATS : stats
return (
<section
className="rounded-[16px] bg-[#1B1F24] grid grid-cols-2 md:grid-cols-4 divide-y md:divide-y-0 md:divide-x divide-white/[0.04]"
style={modalCardStyle}
>
{visible.map((s) => (
<div key={s.label} className="px-5 py-4">
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
{s.label}
</p>
<p
className={cn(
"text-[22px] font-semibold mt-1.5 tabular-nums leading-none",
empty && s.value === "—" ? "text-[#525D6E]" : "text-[#fafafa]",
dmSans125ClassName(),
)}
>
{s.value}
</p>
{s.delta && (
<p
className={cn(
"text-[11px] font-medium mt-1.5",
s.deltaTint === "up"
? "text-[#4BA0FA]"
: s.deltaTint === "down"
? "text-[#FF8A47]"
: "text-[#737373]",
)}
>
{s.delta}
</p>
)}
</div>
))}
</section>
)
}
function BrainActivityChart({ empty }: { empty?: boolean }) {
if (empty) {
return (
<section
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6 relative overflow-hidden"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px left-0 right-0 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
}}
/>
<p
className={cn(
"text-[16px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Sessions by agent
</p>
<p className="text-[12px] text-[#737373] font-medium mt-0.5">
0 sessions yet — once you install Cursor, Claude Code, or any
MCP-capable agent, usage shows up here.
</p>
<div className="mt-6 flex items-end gap-2 opacity-30" aria-hidden>
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((d) => (
<div key={d} className="flex-1 flex flex-col items-center gap-2">
<div
className="w-full rounded-[4px] bg-[#1F2937]"
style={{ height: "4px" }}
/>
<span className="text-[10px] text-[#525D6E] font-medium">
{d}
</span>
</div>
))}
</div>
<button
type="button"
className="mt-5 text-[12px] text-[#4BA0FA] hover:text-[#7eb9fc] transition-colors font-medium inline-flex items-center gap-1.5"
>
Install an agent <ArrowRight className="size-3.5" />
</button>
</section>
)
}
const totalsByAgent = AGENT_SERIES.map((s) => ({
...s,
total: SESSIONS_BY_DAY.reduce((sum, d) => sum + (d.values[s.key] ?? 0), 0),
}))
const grandTotal = totalsByAgent.reduce((sum, s) => sum + s.total, 0)
const dayTotals = SESSIONS_BY_DAY.map((d) =>
AGENT_SERIES.reduce((sum, s) => sum + (d.values[s.key] ?? 0), 0),
)
const max = Math.max(...dayTotals)
return (
<section
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6 relative overflow-hidden"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px left-0 right-0 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
}}
/>
<div className="flex flex-wrap items-end justify-between gap-3 mb-5">
<div>
<p
className={cn(
"text-[16px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Sessions by agent
</p>
<p className="text-[12px] text-[#737373] font-medium mt-0.5">
{grandTotal} agent sessions, last 7 days — which tool the team
reached for.
</p>
</div>
<div className="flex flex-wrap items-center gap-3 text-[11px] font-medium">
{totalsByAgent.map((s) => (
<span
key={s.key}
className="inline-flex items-center gap-1.5 text-[#A1A1AA]"
>
<span
aria-hidden
className="size-2 rounded-full"
style={{ background: s.color }}
/>
{s.label}{" "}
<span className="text-[#fafafa] tabular-nums">{s.total}</span>
</span>
))}
</div>
</div>
<div className="flex items-end gap-2">
{SESSIONS_BY_DAY.map((d, dayIdx) => {
const BAR_AREA_PX = 140
const totalForDay = dayTotals[dayIdx] ?? 0
const barHeightPx = Math.max(
6,
Math.round((totalForDay / max) * BAR_AREA_PX),
)
return (
<div
key={d.label}
className="flex-1 flex flex-col items-center gap-2"
>
<div
className="w-full flex items-end"
style={{ height: `${BAR_AREA_PX}px` }}
>
<div
className="w-full rounded-[4px] overflow-hidden flex flex-col"
style={{ height: `${barHeightPx}px` }}
title={`${d.label}: ${totalForDay} sessions`}
>
{AGENT_SERIES.map((series, sIdx) => {
const val = d.values[series.key] ?? 0
if (val === 0) return null
const segHeight =
totalForDay > 0 ? (val / totalForDay) * 100 : 0
return (
<div
key={series.key}
style={{
height: `${segHeight}%`,
background: series.color,
}}
className={cn(
sIdx === 0 && "rounded-t-[4px]",
sIdx === AGENT_SERIES.length - 1 && "rounded-b-[4px]",
)}
title={`${series.label} · ${val}`}
/>
)
})}
</div>
</div>
<span className="text-[10px] text-[#737373] font-medium tabular-nums">
{d.label}
</span>
</div>
)
})}
</div>
</section>
)
}
function _TabButton({
active,
onClick,
icon,
label,
count,
}: {
active: boolean
onClick: () => void
icon: React.ReactNode
label: string
count: number
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex h-8 shrink-0 items-center gap-2 rounded-full px-3 text-[12px] font-medium transition-colors",
active
? "bg-white/[0.10] text-[#fafafa]"
: "text-[#A1A1AA] hover:text-[#fafafa] hover:bg-white/[0.04]",
)}
>
{icon}
<span className="leading-none">{label}</span>
<span
className={cn(
"text-[10px] font-semibold tabular-nums leading-none",
active ? "text-[#A1A1AA]" : "text-[#525D6E]",
)}
>
{count.toLocaleString()}
</span>
</button>
)
}
function SectionHeader({ title, cta }: { title: string; cta?: string }) {
return (
<div className="flex items-end justify-between mb-3 px-1">
<p className="text-[11px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
{title}
</p>
{cta && (
<button
type="button"
className="text-[12px] text-[#737373] hover:text-[#fafafa] transition-colors font-medium"
>
{cta}
</button>
)}
</div>
)
}
function BrainPulseBlock({ empty }: { empty?: boolean }) {
return (
<section className="grid lg:grid-cols-[2fr_3fr] gap-3 items-stretch">
<RecentAnswersCard empty={empty} />
<WeeklyDigestCard empty={empty} />
</section>
)
}
function RecentAnswersCard({ empty }: { empty?: boolean }) {
return (
<div
className="rounded-[14px] bg-[#1B1F24] p-4 flex flex-col"
style={modalCardStyle}
>
<div className="flex items-center justify-between mb-3">
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
Recent answers
</p>
{!empty && (
<button
type="button"
className="text-[10px] text-[#737373] hover:text-[#fafafa] transition-colors font-medium"
>
See all →
</button>
)}
</div>
{empty ? (
<div className="flex-1 flex flex-col items-start justify-center py-4 gap-1.5">
<p className="text-[13px] text-[#fafafa] font-medium">
No questions asked yet.
</p>
<p className="text-[11px] text-[#737373] font-medium leading-[1.5]">
Ask the brain something below — answers and the team's history will
show up here.
</p>
</div>
) : (
<ul className="space-y-2.5">
{RECENT_ANSWERS.map((a) => (
<li
key={a.question}
className="border-l border-[#2261CA33] pl-2.5 hover:border-[#4BA0FA] transition-colors"
>
<p className="text-[12px] text-[#fafafa] font-semibold leading-snug truncate">
{a.question}
</p>
<p className="text-[11px] text-[#A1A1AA] leading-[1.4] font-medium mt-0.5 line-clamp-1">
→ {a.answer}
</p>
<p className="text-[10px] text-[#737373] font-medium mt-1">
{a.who} · {a.when}
</p>
</li>
))}
</ul>
)}
</div>
)
}
function WeeklyDigestCard({ empty }: { empty?: boolean }) {
if (empty) {
return (
<div
className="rounded-[14px] bg-[#1B1F24] p-4 md:p-5 flex flex-col relative overflow-hidden"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px left-0 right-0 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
}}
/>
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold mb-3">
This week
</p>
<p
className={cn(
"text-[14px] font-semibold text-[#fafafa] leading-snug",
dmSans125ClassName(),
)}
>
Your brain is just getting started.
</p>
<p className="text-[12px] text-[#737373] font-medium leading-[1.5] mt-2">
Once sources are connected and the team starts asking questions,
you'll see decisions, deadlines, and contributor highlights here.
</p>
<button
type="button"
className="mt-auto pt-4 self-start text-[11px] text-[#4BA0FA] hover:text-[#7eb9fc] transition-colors font-medium inline-flex items-center gap-1.5"
>
Connect a source <ArrowRight className="size-3" />
</button>
</div>
)
}
return (
<div
className="rounded-[14px] bg-[#1B1F24] p-4 md:p-5 flex flex-col relative overflow-hidden"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px left-0 right-0 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
}}
/>
<div className="flex items-end justify-between mb-3">
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
This week
</p>
<p className="text-[10px] uppercase tracking-[0.12em] text-[#525D6E] font-semibold">
{WEEKLY_DIGEST.range}
</p>
</div>
<p
className={cn(
"text-[14px] font-semibold text-[#fafafa] leading-snug",
dmSans125ClassName(),
)}
>
{WEEKLY_DIGEST.headline.split(/(\d+)/).map((piece, i) =>
/^\d+$/.test(piece) ? (
<span key={i} className="text-[#4BA0FA]">
{piece}
</span>
) : (
<span key={i}>{piece}</span>
),
)}
</p>
<ul className="mt-3 space-y-1.5">
{WEEKLY_DIGEST.bullets.map((b) => (
<li key={b.label} className="flex items-start gap-2">
<span
aria-hidden
className={cn("mt-1.5 size-1 rounded-full bg-current", b.tint)}
/>
<span className={cn("text-[12px] font-medium", b.tint)}>
{b.label}
</span>
</li>
))}
</ul>
<button
type="button"
className="mt-auto pt-3 self-start text-[11px] text-[#A1A1AA] hover:text-[#fafafa] transition-colors font-medium inline-flex items-center gap-1.5"
>
Open weekly digest <ArrowRight className="size-3" />
</button>
</div>
)
}
function ActivityFeedBlock({ empty }: { empty?: boolean }) {
if (empty) {
return (
<section>
<SectionHeader title="Activity feed" />
<div
className="rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-4 py-6 flex items-start gap-3"
style={inputBevelStyle}
>
<div className="size-7 rounded-[8px] bg-[#0F1217] flex items-center justify-center shrink-0">
<Sparkles className="size-3.5 text-[#525D6E]" />
</div>
<div className="min-w-0">
<p className="text-[13px] text-[#fafafa] font-medium">
Nothing happening yet.
</p>
<p className="text-[12px] text-[#737373] font-medium mt-1 leading-[1.5]">
As sources sync and the team starts using the brain, every event
will show up here in real time.
</p>
</div>
</div>
</section>
)
}
const sourceIcon = (s: string) => {
if (s === "drive") return <GoogleDrive className="size-3.5" />
if (s === "notion") return <Notion className="size-3.5" />
if (s === "slack")
return <MessageSquare className="size-3.5 text-[#4BA0FA]" />
return <Sparkles className="size-3.5 text-[#8B8B8B]" />
}
return (
<section>
<SectionHeader title="Activity feed" cta="Show more →" />
<div
className="rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] divide-y divide-white/[0.04]"
style={inputBevelStyle}
>
{ACTIVITY.map((a, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<div className="size-7 rounded-[8px] bg-[#0F1217] flex items-center justify-center shrink-0">
{sourceIcon(a.source)}
</div>
<p className="flex-1 text-[13px] text-[#fafafa] font-medium">
{a.text}
</p>
<span className="text-[11px] text-[#737373] font-medium tabular-nums shrink-0">
{a.when}
</span>
</div>
))}
</div>
</section>
)
}
function TeamActivityRail({ empty }: { empty?: boolean }) {
if (empty) {
return (
<section
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6"
style={modalCardStyle}
>
<p
className={cn(
"text-[16px] font-semibold leading-tight",
dmSans125ClassName(),
)}
>
Just you so far
</p>
<p className="text-[11px] text-[#737373] font-medium mt-0.5 leading-[1.5]">
A brain gets sharper as more people contribute. Invite teammates to
see who's adding what.
</p>
<button
type="button"
className="mt-5 w-full inline-flex items-center justify-center gap-1.5 h-9 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] text-[12px] font-medium text-[#fafafa] hover:bg-[#14161A] transition-colors"
>
<Plus className="size-3.5" />
Invite teammates
</button>
</section>
)
}
return (
<section
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6"
style={modalCardStyle}
>
<div className="flex items-center justify-between mb-1">
<p
className={cn(
"text-[16px] font-semibold leading-tight",
dmSans125ClassName(),
)}
>
Team activity
</p>
<span className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
This week
</span>
</div>
<p className="text-[11px] text-[#737373] font-medium">
What your team's been adding to the brain.
</p>
<div className="mt-5 space-y-2.5">
{TEAM_THIS_WEEK.map((member) => (
<div
key={member.email}
className="flex items-center gap-3 rounded-[10px] px-2 py-1.5 hover:bg-white/[0.03] transition-colors"
>
<div
className="size-8 rounded-full flex items-center justify-center text-[11px] font-semibold text-[#0F1217] shrink-0"
style={{ background: member.color }}
>
{member.name[0]}
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] text-[#fafafa] font-medium truncate">
{member.name}
</p>
<p className="text-[11px] text-[#737373] font-medium truncate">
{member.summary}
</p>
</div>
<span className="text-[12px] text-[#A1A1AA] tabular-nums font-semibold shrink-0">
{member.contributions}
</span>
</div>
))}
</div>
<div className="mt-5 pt-4 border-t border-white/[0.04]">
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold mb-2.5">
Recent
</p>
<ul className="space-y-2">
{TEAM_RECENT.map((event, i) => (
<li
key={i}
className="text-[12px] text-[#A1A1AA] leading-[1.5] font-medium"
>
<span className="text-[#fafafa]">{event.who}</span> {event.what}
<span className="text-[#525D6E]"> · {event.when}</span>
</li>
))}
</ul>
</div>
<button
type="button"
className="mt-5 w-full inline-flex items-center justify-center gap-1.5 h-9 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] text-[12px] font-medium text-[#fafafa] hover:bg-[#14161A] transition-colors"
>
<Plus className="size-3.5" />
Invite teammates
</button>
</section>
)
}

View file

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

File diff suppressed because it is too large Load diff

View file

@ -13,6 +13,7 @@ import { useQueryState } from "nuqs"
import { Header, PublicHeader } from "@/components/header"
import { MobileBottomNav } from "@/components/bottom-nav"
import { ChatSidebar, HomeChatComposer } from "@/components/chat"
import type { ChatAttachmentDraft } from "@/components/chat/attachments"
import { DashboardView } from "@/components/dashboard-view"
import { MemoriesGrid } from "@/components/memories-grid"
import { GraphLayoutView } from "@/components/graph-layout-view"
@ -24,6 +25,7 @@ import { ShortcutsDetail } from "@/components/integrations/shortcuts-detail"
import { RaycastDetail } from "@/components/integrations/raycast-detail"
import { PluginsDetail } from "@/components/integrations/plugins-detail"
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
import { OnboardingConfetti } from "@/components/onboarding-brain/onboarding-confetti"
import { AddDocumentModal } from "@/components/add-document"
import { DocumentModal } from "@/components/document-modal"
import { DocumentsCommandPalette } from "@/components/documents-command-palette"
@ -106,7 +108,7 @@ function ViewErrorFallback() {
export default function NewPage() {
const isMobile = useIsMobile()
const { user, session } = useAuth()
const { user, session, isSessionPending } = useAuth()
const { selectedProject, selectedProjects, setSelectedProject } = useProject()
const selectedProjectTag = selectedProjects[0]
@ -166,6 +168,9 @@ export default function NewPage() {
const [queuedChatProject, setQueuedChatProject] = useState<string | null>(
null,
)
const [queuedChatAttachments, setQueuedChatAttachments] = useState<
ChatAttachmentDraft[] | null
>(null)
const [queuedHighlightContent, setQueuedHighlightContent] = useState<
string | null
>(null)
@ -494,6 +499,7 @@ export default function NewPage() {
setQueuedChatModel(null)
setQueuedChatReasoningEffort(null)
setQueuedChatProject(null)
setQueuedChatAttachments(null)
setQueuedMessageSource("highlight")
void setViewMode("chat")
},
@ -506,12 +512,14 @@ export default function NewPage() {
model: ModelId,
projectId: string,
reasoningEffort: ReasoningEffort,
attachments?: ChatAttachmentDraft[],
) => {
setQueuedHighlightContent(null)
setQueuedChatSeed(message)
setQueuedChatModel(model)
setQueuedChatReasoningEffort(reasoningEffort)
setQueuedChatProject(projectId)
setQueuedChatAttachments(attachments ?? null)
setQueuedMessageSource("home")
void setViewMode("chat")
},
@ -523,6 +531,7 @@ export default function NewPage() {
setQueuedChatModel(null)
setQueuedChatReasoningEffort(null)
setQueuedChatProject(null)
setQueuedChatAttachments(null)
setQueuedHighlightContent(null)
setQueuedMessageSource("highlight")
}, [])
@ -572,17 +581,20 @@ export default function NewPage() {
const isDashboardShell =
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
const isGraphMode = viewMode === "graph"
const showBottomNav = isMobile && !!session
const showBottomNav = isMobile && !!session && !isChatView
const isPublicIntegrations =
!session && !isSessionPending && viewMode === "integrations"
return (
<HotkeysProvider>
<OnboardingConfetti />
<div
className={cn(
"relative flex min-h-dvh flex-col bg-[#05080D]",
isGraphMode && "h-dvh overflow-hidden",
(isGraphMode || isChatView) && "h-dvh overflow-hidden",
showBottomNav &&
!isGraphMode &&
"pb-[calc(5.5rem+env(safe-area-inset-bottom))]",
"pb-[calc(4rem+env(safe-area-inset-bottom))]",
)}
>
{showNovaBackdrop && (
@ -598,7 +610,9 @@ export default function NewPage() {
/>
</div>
)}
{!session && viewMode === "mcp" ? (
{isPublicIntegrations ? (
<PublicHeader variant="integrations" />
) : !session && viewMode === "mcp" ? (
<PublicHeader />
) : (
<Header
@ -642,6 +656,7 @@ export default function NewPage() {
queuedHighlightContent={queuedHighlightContent}
onConsumeQueuedMessage={consumeQueuedChat}
queuedMessageSource={queuedMessageSource}
queuedAttachments={queuedChatAttachments}
initialSelectedModel={queuedChatModel}
initialReasoningEffort={queuedChatReasoningEffort}
initialChatProject={queuedChatProject}
@ -649,7 +664,10 @@ export default function NewPage() {
</div>
) : viewMode === "integrations" ? (
<div className="min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0">
<IntegrationsView />
<IntegrationsView
publicMode={isPublicIntegrations}
onOpenDocument={handleOpenDocument}
/>
</div>
) : viewMode === "mcp" ? (
<MCPDetailView
@ -684,7 +702,7 @@ export default function NewPage() {
onBack={() => void setViewMode("integrations")}
/>
) : viewMode === "graph" ? (
<div className="min-h-0 min-w-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<GraphLayoutView onOpenDocument={handleOpenDocumentById} />
</div>
) : viewMode === "list" ? (
@ -761,7 +779,7 @@ export default function NewPage() {
className={cn(
"pointer-events-none fixed inset-x-0 z-30",
showBottomNav
? "bottom-[4.25rem]"
? "bottom-[calc(4rem+env(safe-area-inset-bottom))]"
: "bottom-0 bg-gradient-to-t from-black via-black/40 to-transparent pt-12",
)}
>

View file

@ -1,756 +1,23 @@
"use client"
import { Logo } from "@ui/assets/Logo"
import { UserProfileMenu } from "@/components/user-profile-menu"
import { useAuth } from "@lib/auth-context"
import NovaOrb from "@/components/nova/nova-orb"
import { useState, useEffect, useRef, useMemo } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import Account from "@/components/settings/account"
import Billing from "@/components/settings/billing"
import Integrations from "@/components/settings/integrations"
import ConnectionsMCP from "@/components/settings/connections-mcp"
import Support from "@/components/settings/support"
import { ErrorBoundary } from "@/components/error-boundary"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { useIsMobile } from "@hooks/use-mobile"
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
import { analytics } from "@/lib/analytics"
import {
LogOut,
RotateCcw,
Trash2,
Sun,
LoaderIcon,
User as UserIcon,
Zap,
HelpCircle,
CreditCard,
ShieldAlert,
ChevronRight,
ChevronsUpDown,
Check,
Building2,
} from "lucide-react"
import { authClient } from "@lib/auth"
import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import { useResetOrganization } from "@/hooks/use-reset-organization"
import { useDeleteUserAccount } from "@/hooks/use-account-settings"
import { useCustomer } from "autumn-js/react"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
import {
PLAN_DISPLAY_NAMES,
useTokenUsage,
type PlanType,
} from "@/hooks/use-token-usage"
import { parseHashToTab } from "@/components/settings/settings-content"
const TABS = [
"account",
"billing",
"integrations",
"connections",
"support",
] as const
type SettingsTab = (typeof TABS)[number]
type NavItem = {
id: SettingsTab
label: string
description: string
icon: React.ReactNode
}
const NAV_ITEMS: NavItem[] = [
{
id: "account",
label: "Account",
description: "Your profile and organization",
icon: <UserIcon className="size-[18px]" />,
},
{
id: "billing",
label: "Billing",
description: "Plan, usage and payments",
icon: <CreditCard className="size-[18px]" />,
},
{
id: "integrations",
label: "Integrations",
description: "Save, sync and search across tools",
icon: <Sun className="size-[18px]" />,
},
{
id: "connections",
label: "Connections & MCP",
description: "Drive, Notion, OneDrive, MCP",
icon: <Zap className="size-[18px]" />,
},
{
id: "support",
label: "Support & Help",
description: "Get help or share feedback",
icon: <HelpCircle className="size-[18px]" />,
},
]
function parseHashToTab(hash: string): SettingsTab {
const cleaned = hash.replace("#", "").toLowerCase()
return TABS.includes(cleaned as SettingsTab)
? (cleaned as SettingsTab)
: "account"
}
const ORG_PLAN_BADGE_STYLES: Record<PlanType, string> = {
free: "bg-[#2E353D] font-mono font-medium tracking-[0.12em] text-[#A3A3A3]",
pro: "bg-[#4BA0FA] font-bold tracking-[0.36px] text-[#00171A]",
max: "bg-[#1E7FE0] font-bold tracking-[0.36px] text-[#00171A]",
scale: "bg-[#0054AD] font-bold tracking-[0.36px] text-[#FAFAFA]",
enterprise: "bg-[#FAFAFA] font-bold tracking-[0.36px] text-[#0D121A]",
}
function OrgPlanBadge({ plan }: { plan: PlanType }) {
return (
<span
className={cn(
dmSans125ClassName(),
"inline-flex h-[18px] min-w-[42px] shrink-0 items-center justify-center rounded-[3px] px-1.5 text-[10px] uppercase",
ORG_PLAN_BADGE_STYLES[plan],
)}
>
{PLAN_DISPLAY_NAMES[plan]}
</span>
)
}
function resolveOrgPlan(
orgId: string,
isCurrent: boolean,
currentPlan: PlanType,
planByOrgId: Map<string, PlanType>,
): PlanType {
const fromSummary = planByOrgId.get(orgId)
if (fromSummary) return fromSummary
if (isCurrent) return currentPlan
return "free"
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div className="px-3 pt-3 pb-1.5 text-[10.5px] font-semibold tracking-[0.14em] text-white/30 uppercase">
{children}
</div>
)
}
function IdentityCard({ displayName }: { displayName: string }) {
const firstName = displayName?.split(" ")[0] || ""
return (
<div className="relative flex items-center justify-center h-[140px]">
<NovaOrb size={150} className="blur-[3px]!" />
<div className="absolute inset-0 flex items-center justify-center z-10">
<Logo className="h-7 shrink-0 text-white" />
<div
className={cn(
"flex flex-col items-start justify-center ml-3.5",
dmSansClassName(),
)}
>
<p className="text-white text-[14px] font-medium leading-none">
{firstName ? `${firstName}'s` : "Your"}
</p>
<p className="text-white font-bold text-[20px] leading-none mt-1">
supermemory
</p>
</div>
</div>
</div>
)
}
export default function SettingsPage() {
const { user, org, organizations, setActiveOrg } = useAuth()
const [activeTab, setActiveTab] = useState<SettingsTab>("account")
const hasInitialized = useRef(false)
/**
* Legacy redirect: /settings (and /settings#billing etc.) now open the
* settings modal via the ?settings=<tab> URL state on the home page.
*/
export default function SettingsRedirect() {
const router = useRouter()
const isMobile = useIsMobile()
const localStorageUsername = useLocalStorageUsername()
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false)
const [resetConfirmation, setResetConfirmation] = useState("")
const resetOrganization = useResetOrganization()
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("")
const deleteUserAccount = useDeleteUserAccount()
const [dangerMenuOpen, setDangerMenuOpen] = useState(false)
const [orgSwitcherOpen, setOrgSwitcherOpen] = useState(false)
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
const canSwitchOrg = (organizations?.length ?? 0) > 1
const autumn = useCustomer()
const { currentPlan } = useTokenUsage(autumn)
const { data: orgSummaries } = useOrgSummaries()
const planByOrgId = useMemo(() => {
const map = new Map<string, PlanType>()
for (const summary of orgSummaries ?? []) {
map.set(summary.orgId, summary.plan)
}
return map
}, [orgSummaries])
const activeOrgPlan = org?.id
? resolveOrgPlan(org.id, true, currentPlan, planByOrgId)
: currentPlan
const handleOrgSwitch = async (orgSlug: string, orgId: string) => {
if (orgId === org?.id) {
setOrgSwitcherOpen(false)
return
}
setSwitchingOrgId(orgId)
try {
await setActiveOrg(orgSlug)
window.location.reload()
} catch (error) {
console.error("Failed to switch organization:", error)
setSwitchingOrgId(null)
}
}
const handleLogout = async () => {
await authClient.signOut()
router.push("/login")
}
const handleDeleteAccount = async () => {
if (deleteEmailConfirm !== user?.email) return
deleteUserAccount.mutate(
{ confirmation: deleteEmailConfirm },
{
onSuccess: () => {
setIsDeleteDialogOpen(false)
setDeleteEmailConfirm("")
router.push("/login")
},
},
)
}
useEffect(() => {
if (hasInitialized.current) return
hasInitialized.current = true
const hash = window.location.hash
const hash = typeof window !== "undefined" ? window.location.hash : ""
const tab = parseHashToTab(hash)
setActiveTab(tab)
analytics.settingsTabChanged({ tab })
router.replace(
tab === "integrations" ? "/?view=integrations" : `/?settings=${tab}`,
)
}, [router])
if (!hash || !TABS.includes(hash.replace("#", "") as SettingsTab)) {
window.history.pushState(null, "", "#account")
}
}, [])
useEffect(() => {
const handleHashChange = () => {
const tab = parseHashToTab(window.location.hash)
setActiveTab(tab)
analytics.settingsTabChanged({ tab })
}
window.addEventListener("hashchange", handleHashChange)
return () => window.removeEventListener("hashchange", handleHashChange)
}, [])
const headerDisplayName =
user?.displayUsername ||
localStorageUsername ||
user?.name ||
user?.email?.split("@")[0] ||
""
return (
<div className="h-screen flex flex-col overflow-hidden bg-[#08090C]">
<header className="relative z-20 flex justify-between items-center gap-3 px-4 md:px-8 py-3 shrink-0">
<nav
className={cn(
"flex items-center gap-2 sm:gap-3 min-w-0 text-sm",
dmSansClassName(),
)}
aria-label="Breadcrumb"
>
<button
type="button"
onClick={() => router.push("/")}
className={cn(
"flex items-center min-w-0 rounded-lg py-1 pr-2 -ml-1 pl-1",
"hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-colors cursor-pointer text-left",
)}
>
<Logo className="h-7 shrink-0" />
<span className="ml-2 font-semibold text-white/90 tracking-tight">
supermemory
</span>
</button>
<span className="text-white/30 shrink-0" aria-hidden>
/
</span>
<span className="text-white/55 font-medium shrink-0">Settings</span>
</nav>
<div className="flex items-center gap-2 sm:gap-3 shrink-0">
{!isMobile &&
(canSwitchOrg ? (
<Popover open={orgSwitcherOpen} onOpenChange={setOrgSwitcherOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"group flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5 transition-colors",
"bg-white/[0.03] border border-white/[0.06]",
"cursor-pointer hover:bg-white/[0.06]",
dmSansClassName(),
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
<Building2 className="size-[13px]" />
</span>
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
{org?.name ?? "Personal"}
</span>
<OrgPlanBadge plan={activeOrgPlan} />
<ChevronsUpDown className="size-3.5 shrink-0 text-white/40" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
side="bottom"
sideOffset={8}
className={cn(
"w-[260px] max-h-80 overflow-y-auto p-1.5",
"bg-[#14161A] border-white/10 rounded-[14px]",
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
dmSansClassName(),
)}
>
{[...(organizations ?? [])]
.sort((a, b) => a.name.localeCompare(b.name))
.map((organization) => {
const isCurrent = organization.id === org?.id
const isSwitching = switchingOrgId === organization.id
const plan = resolveOrgPlan(
organization.id,
isCurrent,
currentPlan,
planByOrgId,
)
return (
<button
key={organization.id}
type="button"
disabled={isCurrent || isSwitching}
onClick={() =>
handleOrgSwitch(organization.slug, organization.id)
}
className={cn(
"w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left transition-colors",
isCurrent
? "bg-white/5"
: "hover:bg-white/5 cursor-pointer",
"disabled:cursor-default",
)}
>
<Building2 className="size-4 shrink-0 text-white/40" />
<span className="min-w-0 flex-1 truncate text-[13.5px] text-white">
{organization.name}
</span>
{isSwitching ? (
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
) : isCurrent ? (
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
) : null}
<OrgPlanBadge plan={plan} />
</button>
)
})}
</PopoverContent>
</Popover>
) : (
<div
className={cn(
"flex items-center gap-2 rounded-full pl-1.5 pr-2.5 py-1.5",
"bg-white/[0.03] border border-white/[0.06]",
dmSansClassName(),
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
<Building2 className="size-[13px]" />
</span>
<span className="max-w-[160px] text-left text-[13px] font-medium text-white truncate leading-none">
{org?.name ?? "Personal"}
</span>
<OrgPlanBadge plan={activeOrgPlan} />
</div>
))}
<UserProfileMenu />
</div>
</header>
<main className="flex-1 min-h-0 overflow-y-auto md:overflow-hidden">
<div className="flex flex-col md:flex-row gap-4 md:gap-6 lg:gap-10 px-4 md:px-8 pt-5 md:pt-7 pb-6 md:h-full md:w-full md:max-w-[1240px] md:mx-auto">
{/* Left rail */}
<aside className="md:flex md:h-full md:min-h-0 md:w-[280px] md:flex-col shrink-0">
{!isMobile && (
<div className="mb-4">
<IdentityCard displayName={headerDisplayName} />
</div>
)}
<nav
className={cn(
"flex",
isMobile
? "flex-row gap-2 overflow-x-auto pb-2 scrollbar-thin"
: "min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
dmSansClassName(),
)}
>
{!isMobile && <SectionLabel>Organisation</SectionLabel>}
{NAV_ITEMS.map((item) => {
const isActive = activeTab === item.id
return (
<button
key={item.id}
type="button"
onClick={() => {
window.location.hash = item.id
setActiveTab(item.id)
analytics.settingsTabChanged({ tab: item.id })
}}
className={cn(
"relative rounded-xl transition-colors flex items-center gap-3 shrink-0 group",
isMobile
? "px-3 py-2 text-sm border border-white/[0.06]"
: "text-left px-3 py-2",
isActive
? "bg-[#14161A] text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
: "text-white/60 hover:text-white hover:bg-white/[0.025]",
)}
>
{!isMobile && (
<span
aria-hidden
className={cn(
"absolute left-0 top-1/2 -translate-y-1/2 w-[3px] rounded-r-full transition-all",
isActive
? "h-5 bg-[#4BA0FA]"
: "h-0 bg-transparent group-hover:h-3 group-hover:bg-white/20",
)}
/>
)}
<span
className={cn(
"shrink-0 transition-colors",
isActive ? "text-white" : "text-white/45",
)}
>
{item.icon}
</span>
<span className="font-medium text-[14px] whitespace-nowrap">
{item.label}
</span>
</button>
)
})}
{!isMobile && <div className="flex-1" />}
<button
type="button"
onClick={handleLogout}
className={cn(
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer text-white/60 hover:text-white hover:bg-white/[0.025]",
isMobile
? "px-3 py-2 text-sm border border-white/[0.06]"
: "text-left px-3 py-2",
)}
>
<LogOut className="size-[18px] shrink-0 text-white/45" />
<span className="font-medium text-[14px] whitespace-nowrap">
Log out
</span>
</button>
<Popover open={dangerMenuOpen} onOpenChange={setDangerMenuOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer",
isMobile
? "px-3 py-2 text-sm border border-[#3A211C]"
: "text-left px-3 py-2.5 mt-1",
dangerMenuOpen
? "bg-[#1A0F0C] text-[#C73B1B]"
: "text-[#8A5247] hover:text-[#C73B1B] hover:bg-[#1A0F0C]/60",
)}
>
<ShieldAlert className="size-[18px] shrink-0" />
<span className="font-medium text-[14px] whitespace-nowrap">
Danger zone
</span>
<ChevronRight
className={cn(
"size-4 ml-auto shrink-0 transition-transform opacity-60",
dangerMenuOpen ? "-rotate-90" : "rotate-0",
)}
/>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
sideOffset={8}
className={cn(
"w-[var(--radix-popover-trigger-width)] min-w-[248px] p-1.5 bg-[#14161A] border-white/10 rounded-[14px]",
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
dmSansClassName(),
)}
>
<button
type="button"
onClick={() => {
setDangerMenuOpen(false)
setIsResetDialogOpen(true)
}}
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#A37A2E] hover:text-[#C7991B] hover:bg-[#1A1200]/60 transition-colors cursor-pointer"
>
<RotateCcw className="size-[16px] shrink-0" />
<span className="font-medium text-[13.5px]">
Reset data
</span>
</button>
<div className="my-1 h-px bg-white/[0.06]" />
<button
type="button"
onClick={() => {
setDangerMenuOpen(false)
setIsDeleteDialogOpen(true)
}}
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#C73B1B] hover:bg-[#290F0A]/60 transition-colors cursor-pointer"
>
<Trash2 className="size-[16px] shrink-0" />
<span className="font-semibold text-[13.5px]">
Delete account
</span>
</button>
</PopoverContent>
</Popover>
</nav>
</aside>
{/* Content */}
<section className="flex-1 min-w-0 flex flex-col md:overflow-y-auto md:max-w-4xl [scrollbar-gutter:stable] md:pr-2">
<ErrorBoundary
key={activeTab}
fallback={
<p className="text-muted-foreground p-4">
Something went wrong loading this section.{" "}
<button
type="button"
className="underline cursor-pointer"
onClick={() => window.location.reload()}
>
Reload
</button>
</p>
}
>
{activeTab === "account" && <Account />}
{activeTab === "billing" && <Billing />}
{activeTab === "integrations" && <Integrations />}
{activeTab === "connections" && <ConnectionsMCP />}
{activeTab === "support" && <Support />}
</ErrorBoundary>
</section>
</div>
</main>
{/* Reset data dialog */}
{(() => {
const confirmText = org?.name || user?.name || ""
return (
<Dialog
open={isResetDialogOpen}
onOpenChange={(open) => {
setIsResetDialogOpen(open)
if (!open) setResetConfirmation("")
}}
>
<DialogContent className="sm:max-w-md">
<div
className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}
>
<div className="flex flex-col gap-1.5">
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
Reset all data?
</h2>
<p className="text-sm text-[#8B8B8B]">
This permanently removes:
</p>
<ul className="text-sm text-[#8B8B8B] list-disc pl-5 space-y-0.5 mt-1">
<li>All documents and memories</li>
<li>All connections (Google Drive, Notion, etc.)</li>
<li>All custom spaces (default space stays)</li>
<li>Organization settings and filters</li>
</ul>
<p className="text-sm text-[#8B8B8B] mt-1">
Your account and billing plan stay intact.{" "}
<strong className="text-[#FAFAFA]">
This cannot be undone.
</strong>
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-[#8B8B8B]">
Type{" "}
<strong className="text-[#FAFAFA]">
{confirmText || "your name"}
</strong>{" "}
to confirm:
</p>
<input
type="text"
value={resetConfirmation}
onChange={(e) => setResetConfirmation(e.target.value)}
placeholder={confirmText || "Your name"}
autoComplete="off"
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C7991B]/50 transition-colors"
/>
</div>
<div className="flex gap-3 justify-end">
<DialogClose asChild>
<button
type="button"
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
>
Cancel
</button>
</DialogClose>
<button
type="button"
disabled={
!confirmText ||
resetConfirmation !== confirmText ||
resetOrganization.isPending
}
title={
!confirmText || resetConfirmation !== confirmText
? `Type "${confirmText || "your name"}" exactly to enable`
: undefined
}
onClick={() =>
resetOrganization.mutate(
{ confirmation: confirmText },
{
onSuccess: () => {
setIsResetDialogOpen(false)
setResetConfirmation("")
},
},
)
}
className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#1A1200] text-[#C7991B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
>
{resetOrganization.isPending ? (
<LoaderIcon className="size-[15px] animate-spin" />
) : (
<RotateCcw className="size-[15px]" />
)}
{resetOrganization.isPending
? "Resetting…"
: "Reset organization"}
</button>
</div>
</div>
</DialogContent>
</Dialog>
)
})()}
{/* Delete account dialog */}
<Dialog
open={isDeleteDialogOpen}
onOpenChange={(open) => {
setIsDeleteDialogOpen(open)
if (!open) setDeleteEmailConfirm("")
}}
>
<DialogContent className="sm:max-w-md">
<div className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}>
<div className="flex flex-col gap-1.5">
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
Delete your account?
</h2>
<p className="text-sm text-[#8B8B8B]">
Permanently deletes all your data and cancels any active
subscriptions.{" "}
<strong className="text-[#FAFAFA]">
This cannot be undone.
</strong>
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-[#8B8B8B]">
Type your email{" "}
<strong className="text-[#FAFAFA]">{user?.email}</strong> to
confirm:
</p>
<input
type="email"
value={deleteEmailConfirm}
onChange={(e) => setDeleteEmailConfirm(e.target.value)}
placeholder={user?.email ?? "your@email.com"}
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C73B1B]/50 transition-colors"
/>
</div>
<div className="flex gap-3 justify-end">
<DialogClose asChild>
<button
type="button"
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
>
Cancel
</button>
</DialogClose>
<button
type="button"
disabled={
deleteEmailConfirm !== user?.email ||
deleteUserAccount.isPending
}
title={
deleteEmailConfirm !== user?.email
? `Type ${user?.email ?? "your email"} exactly to confirm`
: undefined
}
onClick={handleDeleteAccount}
className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
>
{deleteUserAccount.isPending ? (
<LoaderIcon className="size-[15px] animate-spin" />
) : (
<Trash2 className="size-[15px]" />
)}
{deleteUserAccount.isPending ? "Deleting…" : "Delete account"}
</button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
return null
}

View file

@ -0,0 +1 @@
export { default } from "../connect/page"

View file

@ -99,7 +99,7 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
"Captures coding decisions and patterns automatically",
"Builds persistent user profile across projects",
],
icon: "/images/plugins/codex.svg",
icon: "/images/plugins/codex.png",
},
}
@ -199,6 +199,7 @@ function AuthConnectContent() {
const redirectUrl = new URL(callback)
redirectUrl.searchParams.set("apikey", data.key)
redirectUrl.searchParams.set("api_url", API_URL)
window.location.href = redirectUrl.toString()
} catch (err) {
console.error("Failed to get API key:", err)
@ -395,7 +396,7 @@ function AuthConnectContent() {
</button>
<a
href="https://app.supermemory.ai/?view=plugins"
href="https://app.supermemory.ai/settings#billing"
className={dmSans125ClassName(
"text-[12px] text-[#737373] hover:text-[#FAFAFA] transition-colors",
)}

View file

@ -31,7 +31,27 @@ export const metadata: Metadata = {
],
},
manifest: "/manifest.webmanifest",
openGraph: {
description: "Your memories, wherever you are",
images: [
{
url: "/OG.png",
width: 1800,
height: 945,
alt: "supermemory app",
},
],
siteName: "supermemory",
title: "supermemory app",
type: "website",
},
title: "supermemory app",
twitter: {
card: "summary_large_image",
description: "Your memories, wherever you are",
images: ["/OG.png"],
title: "supermemory app",
},
}
export const viewport: Viewport = {

View file

@ -328,11 +328,11 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
const projects = (queryClient.getQueryData<Project[]>(["projects"]) ||
[]) as Project[]
const handleUpgrade = async () => {
const handleUpgrade = async (planId: "api_pro" | "api_max" = "api_pro") => {
setIsUpgrading(true)
try {
const result = await autumn.attach({
planId: "api_pro",
planId,
successUrl: window.location.href,
})
if (result?.paymentUrl) {
@ -614,11 +614,21 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
</span>
)}
<Button
onClick={() => setGranolaModalOpen(true)}
disabled={!isMaxUser}
onClick={() => {
if (!isMaxUser) {
handleUpgrade("api_max")
return
}
setGranolaModalOpen(true)
}}
disabled={isUpgrading || autumn.isLoading}
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 text-[14px] font-medium px-3 py-1.5 h-8"
>
Connect
{!isMaxUser
? isUpgrading || autumn.isLoading
? "Upgrading..."
: "Upgrade"
: "Connect"}
</Button>
</>
) : (
@ -783,8 +793,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
</div>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!isMaxUser}
onClick={() => setGranolaModalOpen(true)}
disabled={isUpgrading || autumn.isLoading}
onClick={() => {
if (!isMaxUser) {
handleUpgrade("api_max")
return
}
setGranolaModalOpen(true)
}}
className="flex items-start gap-2.5 px-3 py-2.5 rounded-md cursor-pointer text-white opacity-60 hover:opacity-100 hover:bg-[#293952]/40 focus:bg-[#293952]/40 focus:opacity-100 data-disabled:opacity-40 data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent"
>
<Granola className="size-5 mt-0.5 shrink-0" />
@ -798,7 +814,9 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
)}
</span>
<span className="text-[11px] text-[#737373] leading-tight">
Meeting notes & transcripts
{isMaxUser
? "Meeting notes & transcripts"
: "Upgrade to Max"}
</span>
</div>
</DropdownMenuItem>

View file

@ -8,7 +8,7 @@ import { useHotkeys } from "react-hotkeys-hook"
import { toast } from "sonner"
export const FILE_ACCEPT =
"image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,text/markdown"
"image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,.json,.html,.htm,text/markdown,application/json,text/html"
export type FileQueueItemStatus = "pending" | "uploading" | "success" | "error"
@ -46,10 +46,15 @@ function isAcceptedFile(file: File): boolean {
".txt",
".md",
".mdx",
".json",
".html",
".htm",
])
if (allowedExt.has(ext)) return true
if (file.type.startsWith("image/")) return true
if (file.type === "text/markdown") return true
if (file.type === "application/json") return true
if (file.type === "text/html") return true
return false
}
@ -57,6 +62,18 @@ function fileQueueKey(file: File): string {
return `${file.name}:${file.size}:${file.lastModified}`
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const units = ["KB", "MB", "GB", "TB"]
let size = bytes / 1024
let i = 0
while (size >= 1024 && i < units.length - 1) {
size /= 1024
i++
}
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}`
}
export function FileContent({
data,
onDataChange,
@ -197,7 +214,7 @@ export function FileContent({
<div className="flex flex-col gap-0.5 pl-2">
<p className="text-[16px] font-medium">Upload files</p>
<p className="text-[#737373] text-xs">
Images, PDF, documents, sheets, markdown
Images, PDF, documents, sheets, markdown, HTML
</p>
</div>
<label
@ -284,7 +301,7 @@ export function FileContent({
{item.file.name}
</p>
<p className="text-[#737373] text-xs">
{(item.file.size / 1024 / 1024).toFixed(2)} MB
{formatFileSize(item.file.size)}
</p>
{item.status === "error" && item.errorMessage ? (
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">

View file

@ -2,7 +2,7 @@
import { useState } from "react"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { cn } from "@lib/utils"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon, Loader2 } from "lucide-react"
@ -10,6 +10,7 @@ import { Button } from "@ui/components/button"
import { useProjectMutations } from "@/hooks/use-project-mutations"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import { analytics } from "@/lib/analytics"
import { $fetch } from "@lib/api"
const EMOJI_LIST = [
"📁",
@ -62,6 +63,25 @@ const EMOJI_LIST = [
"🤍",
]
export const CONTEXT_PRESETS: { label: string; text: string }[] = [
{
label: "Work project",
text: "Tracks a work project — decisions, owners, deadlines, and current status.",
},
{
label: "Client",
text: "About a client — meetings, requirements, and account context.",
},
{
label: "Research",
text: "Research notes — sources, key findings, and open questions.",
},
{
label: "Personal",
text: "My personal space — notes, ideas, and things to remember.",
},
]
export function AddSpaceModal({
isOpen,
onClose,
@ -72,6 +92,8 @@ export function AddSpaceModal({
onCreated?: (containerTag: string) => void
}) {
const [spaceName, setSpaceName] = useState("")
const [spaceContext, setSpaceContext] = useState("")
const [showContext, setShowContext] = useState(false)
const [emoji, setEmoji] = useState("📁")
const [isEmojiOpen, setIsEmojiOpen] = useState(false)
const { createProjectMutation } = useProjectMutations()
@ -79,6 +101,8 @@ export function AddSpaceModal({
const handleClose = () => {
onClose()
setSpaceName("")
setSpaceContext("")
setShowContext(false)
setEmoji("📁")
}
@ -89,11 +113,18 @@ export function AddSpaceModal({
createProjectMutation.mutate(
{ name: trimmedName, emoji: emoji || undefined },
{
onSuccess: (data) => {
onSuccess: async (data) => {
analytics.spaceCreated()
if (data?.containerTag) {
onCreated?.(data.containerTag)
const tag = data?.containerTag
const context = showContext ? spaceContext.trim() : ""
if (tag && context) {
try {
await $fetch(`@patch/container-tags/${tag}`, {
body: { entityContext: context },
})
} catch {}
}
if (tag) onCreated?.(tag)
handleClose()
},
},
@ -132,21 +163,21 @@ export function AddSpaceModal({
<div className="flex flex-col gap-4">
<div className="flex justify-between items-start gap-4">
<div className="pl-1 space-y-1 flex-1">
<p
<DialogTitle
className={cn(
"font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Create new space
</p>
New space
</DialogTitle>
<p
className={cn(
"text-[#737373] font-medium text-[16px] leading-[1.35]",
dmSansClassName(),
"text-[#737373] text-[13px] leading-snug",
)}
>
Create spaces to organize your memories and documents and create
a context rich environment
Group related memories and give Nova context for this space.
</p>
</div>
<DialogPrimitive.Close
@ -222,6 +253,71 @@ export function AddSpaceModal({
/>
</div>
{!showContext ? (
<button
type="button"
onClick={() => setShowContext(true)}
className={cn(
dmSansClassName(),
"flex cursor-pointer items-center gap-1.5 self-start pl-1 text-[13px] font-medium text-[#A3A3A3] transition-colors hover:text-[#fafafa]",
)}
>
<span className="text-[16px] leading-none text-[#737373]">+</span>
<span className="inline-flex items-baseline gap-1.5">
Tell Nova what to remember
<span className="text-[9px] font-semibold uppercase tracking-[0.08em] text-[#4BA0FA]">
New
</span>
</span>
</button>
) : (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5 pl-1">
<span
className={cn(
dmSans125ClassName(),
"text-[12px] font-medium text-[#A3A3A3]",
)}
>
What to remember
</span>
<span className="ml-auto text-[11px] text-[#525966]">
Optional
</span>
</div>
<textarea
value={spaceContext}
onChange={(e) => setSpaceContext(e.target.value)}
placeholder="Tell Nova what matters here — it shapes which memories get extracted."
maxLength={750}
className={cn(
dmSansClassName(),
"min-h-[56px] w-full resize-y rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] px-4 py-3 text-[14px] leading-relaxed text-[#fafafa] placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(115,115,115,0.3)]",
)}
style={{
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)",
}}
/>
<div className="flex flex-wrap items-center gap-1.5 pl-1">
<span className="text-[11px] text-[#737373]">Try a preset</span>
{CONTEXT_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setSpaceContext(preset.text)}
className={cn(
dmSansClassName(),
"cursor-pointer rounded-full bg-[#1E232B] px-2.5 py-1 text-[11px] font-medium text-[#C8C8C8] transition-colors hover:bg-[#262c36] hover:text-white",
)}
>
{preset.label}
</button>
))}
</div>
</div>
)}
<div className="flex items-center justify-end gap-[22px]">
<button
type="button"
@ -247,7 +343,7 @@ export function AddSpaceModal({
</>
) : (
<>
<span className="text-[10px] mr-1">+</span>
<span className="mr-1 text-[16px] leading-none">+</span>
Create Space
</>
)}

View file

@ -10,7 +10,6 @@ import {
LifeBuoy,
Settings,
} from "lucide-react"
import { useRouter } from "next/navigation"
import { useQueryState } from "nuqs"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
@ -25,6 +24,7 @@ import {
import { useViewMode, type ViewMode } from "@/lib/view-mode-context"
import { feedbackParam } from "@/lib/search-params"
import NovaOrb from "@/components/nova/nova-orb"
import { useSettingsModal } from "@/components/settings/settings-modal"
const INTEGRATION_VIEWS: ViewMode[] = [
"integrations",
@ -43,7 +43,7 @@ interface BottomNavProps {
}
export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
const router = useRouter()
const { openSettings } = useSettingsModal()
const { viewMode, setViewMode } = useViewMode()
const [, setFeedbackOpen] = useQueryState("feedback", feedbackParam)
@ -55,11 +55,11 @@ export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
<nav
aria-label="Primary"
className={cn(
"fixed inset-x-0 bottom-[calc(0.75rem+env(safe-area-inset-bottom))] z-40 flex justify-center px-3 md:hidden",
"fixed inset-x-0 bottom-0 z-40 border-t border-white/[0.08] bg-[#0A0E14]/85 shadow-[0_-8px_24px_rgba(0,0,0,0.35)] backdrop-blur-xl md:hidden",
dmSansClassName(),
)}
>
<div className="flex w-full items-center justify-around rounded-full border border-[#161F2C] bg-muted/95 px-2.5 py-2 shadow-[0_10px_30px_rgba(0,0,0,0.55)] backdrop-blur-xl">
<div className="flex items-center justify-around px-1 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
<NavTab
label="Home"
icon={Home}
@ -76,10 +76,10 @@ export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
type="button"
aria-label="Open chat"
onClick={() => void setViewMode("chat")}
className="group relative flex size-11 shrink-0 items-center justify-center self-center rounded-full outline-none transition-transform hover:scale-[1.03] focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/50"
className="group relative flex size-11 shrink-0 items-center justify-center rounded-full shadow-[0_0_18px_rgba(75,160,250,0.35)] outline-none transition-transform hover:scale-[1.04] focus-visible:ring-2 focus-visible:ring-[#4BA0FA]/60 active:scale-95"
>
<NovaOrb
size={42}
size={40}
className="pointer-events-none blur-[1px]! transition-transform group-hover:scale-105"
/>
</button>
@ -127,7 +127,7 @@ export function MobileBottomNav({ onAddMemory, onOpenSearch }: BottomNavProps) {
<MoreItem
icon={Settings}
label="Settings"
onClick={() => router.push("/settings")}
onClick={() => openSettings()}
/>
</DropdownMenuContent>
</DropdownMenu>
@ -172,13 +172,13 @@ function NavTabButton({
aria-current={active ? "page" : undefined}
onClick={onClick}
className={cn(
"flex shrink-0 flex-col items-center gap-1 rounded-full px-3 py-1.5 outline-none transition-colors",
"flex flex-1 flex-col items-center gap-1 rounded-lg py-1 outline-none transition-colors active:scale-95",
active ? "text-white" : "text-[#737373] hover:text-white",
)}
{...props}
>
{children}
<span className="text-[10px] font-medium leading-none">{label}</span>
<span className="text-[11px] font-medium leading-none">{label}</span>
</button>
)
}

View file

@ -0,0 +1,466 @@
export type Stat = {
label: string
value: string
delta?: string
deltaTint?: "up" | "down" | "neutral"
}
export const STATS: Stat[] = [
{
label: "Items ingested",
value: "2,272",
delta: "+148 this week",
deltaTint: "up",
},
{
label: "Agent sessions",
value: "184",
delta: "+42 vs last wk",
deltaTint: "up",
},
{
label: "Active contributors",
value: "4",
delta: "of 6 invited",
deltaTint: "neutral",
},
{
label: "Top space",
value: "Gmail",
delta: "1,843 items",
deltaTint: "neutral",
},
]
export type AgentSeries = { key: string; label: string; color: string }
export const AGENT_SERIES: AgentSeries[] = [
{ key: "cursor", label: "Cursor", color: "#4BA0FA" },
{ key: "claudeCode", label: "Claude Code", color: "#FF8A47" },
{ key: "claudeDesktop", label: "Claude Desktop", color: "#B19CFF" },
{ key: "chatgpt", label: "ChatGPT", color: "#10A37F" },
{ key: "other", label: "Other", color: "#525D6E" },
]
export const SESSIONS_BY_DAY: {
label: string
values: Record<string, number>
}[] = [
{
label: "Mon",
values: {
cursor: 8,
claudeCode: 4,
claudeDesktop: 3,
chatgpt: 2,
other: 1,
},
},
{
label: "Tue",
values: {
cursor: 10,
claudeCode: 6,
claudeDesktop: 4,
chatgpt: 3,
other: 1,
},
},
{
label: "Wed",
values: {
cursor: 12,
claudeCode: 9,
claudeDesktop: 5,
chatgpt: 4,
other: 1,
},
},
{
label: "Thu",
values: {
cursor: 9,
claudeCode: 5,
claudeDesktop: 4,
chatgpt: 3,
other: 1,
},
},
{
label: "Fri",
values: {
cursor: 14,
claudeCode: 10,
claudeDesktop: 6,
chatgpt: 4,
other: 2,
},
},
{
label: "Sat",
values: {
cursor: 5,
claudeCode: 3,
claudeDesktop: 2,
chatgpt: 1,
other: 1,
},
},
{
label: "Sun",
values: {
cursor: 17,
claudeCode: 12,
claudeDesktop: 7,
chatgpt: 4,
other: 1,
},
},
]
export const RECENT_ANSWERS = [
{
question: "What did we decide about pricing?",
answer: "Pro stays at $20/mo. No overages, top-up only.",
who: "You",
when: "2h ago",
},
{
question: "What's blocking the Acme deal?",
answer: "MSA review — Jane drafting a net-30 counter.",
who: "You",
when: "4h ago",
},
{
question: "Who owns the onboarding rewrite?",
answer: "Design team. Ship target Friday.",
who: "Sarah Kim",
when: "Yesterday",
},
]
export const WEEKLY_DIGEST = {
range: "Mon – Fri",
headline:
"Your brain captured 642 items, surfaced 12 decisions, and answered 184 questions across the team.",
bullets: [
{ label: "5 deadlines coming up next week", tint: "text-[#4BA0FA]" },
{
label: "2 conflicts surfaced between Notion and Slack",
tint: "text-[#FF8A47]",
},
{
label: "Sarah Kim led contributions with 14 docs",
tint: "text-[#A1A1AA]",
},
],
}
export const TEAM_THIS_WEEK = [
{
name: "Sarah Kim",
email: "sarah@acme.com",
contributions: 14,
summary: "14 docs · 3 spaces",
color: "#FF8A47",
},
{
name: "Jane Doe",
email: "jane@acme.com",
contributions: 11,
summary: "11 docs · 2 spaces",
color: "#4BA0FA",
},
{
name: "Mahesh S.",
email: "mahesh@acme.com",
contributions: 9,
summary: "9 docs · 1 space",
color: "#A1A1AA",
},
{
name: "Alex Chen",
email: "alex@acme.com",
contributions: 7,
summary: "7 docs · 2 spaces",
color: "#10A37F",
},
]
export const TEAM_RECENT = [
{ who: "Sarah Kim", what: "added a memo to acme-deal-q2", when: "14m" },
{ who: "Jane Doe", what: "updated the Acme MSA draft", when: "1h" },
{ who: "You", what: 'asked "What did we decide about pricing?"', when: "2h" },
{
who: "Sarah Kim",
what: "connected Notion to Sales space",
when: "Yesterday",
},
]
export type ActivityEvent = {
when: string
text: string
source: "drive" | "notion" | "slack" | "system" | "answer" | "decision"
}
export const ACTIVITY: ActivityEvent[] = [
{ when: "2m", text: "23 new docs ingested from Drive", source: "drive" },
{ when: "14m", text: "Jane added a memo to acme-deal-q2", source: "notion" },
{
when: "1h",
text: "Decision surfaced: pricing locked at $20 Pro",
source: "slack",
},
{
when: "3h",
text: "14 Notion pages updated by Sarah Kim",
source: "notion",
},
{ when: "5h", text: "New entity formed: pricing-v3", source: "system" },
{
when: "Yesterday",
text: "MSA contract uploaded · auto-tagged contracts/MSA",
source: "drive",
},
]
export type TimelineKind =
| "answer"
| "decision"
| "ingest"
| "mention"
| "system"
export type TimelineEvent = {
kind: TimelineKind
when: string
title: string
detail?: string
source?: string
who?: string
}
export const TIMELINE_EVENTS: TimelineEvent[] = [
{
kind: "answer",
when: "2m ago",
title: "What did we decide about pricing?",
detail: "Pro stays at $20/mo. No overages, top-up only.",
source: "You asked",
},
{
kind: "ingest",
when: "14m ago",
title: "23 new docs ingested from Drive",
detail: "Q2 roadmap, Acme MSA draft v3, customer feedback Q1, +20 more.",
source: "Drive",
},
{
kind: "decision",
when: "1h ago",
title: "Decision: pricing locked at $20 Pro",
detail:
"Decided in #leadership thread to keep Pro at $20/mo. No overages — top-up only.",
source: "Slack · #leadership",
},
{
kind: "answer",
when: "4h ago",
title: "What's blocking the Acme deal?",
detail: "MSA review — Jane drafting a net-30 counter.",
source: "You asked",
},
{
kind: "mention",
when: "Today",
title: "onboarding-rewrite mentioned in 3 docs",
detail: "Owner: design team · Ship target: Friday.",
source: "Drive",
who: "Mahesh",
},
{
kind: "system",
when: "5h ago",
title: "New entity formed: pricing-v3",
detail: "Linked across 4 sources · auto-tagged by the brain.",
},
{
kind: "ingest",
when: "Yesterday",
title: "MSA contract uploaded · auto-tagged contracts/MSA",
source: "Drive",
},
{
kind: "answer",
when: "Yesterday",
title: "Who owns the onboarding rewrite?",
detail: "Design team. Ship target Friday.",
source: "Sarah Kim asked",
},
{
kind: "ingest",
when: "Yesterday",
title: "14 Notion pages updated by Sarah Kim",
source: "Notion",
who: "Sarah Kim",
},
]
export type SourceStatus = "healthy" | "syncing" | "warning" | "error"
export type ConnectedSource = {
id: string
name: string
iconKey: "drive" | "notion" | "gmail" | "onedrive" | "github"
status: SourceStatus
delta: string
when: string
statusMessage?: string
}
export const CONNECTED_SOURCES: ConnectedSource[] = [
{
id: "drive",
name: "Google Drive",
iconKey: "drive",
status: "healthy",
delta: "+24 items",
when: "12m ago",
},
{
id: "notion",
name: "Company Notion",
iconKey: "notion",
status: "warning",
delta: "Sync stalled",
when: "4h ago",
statusMessage: "Re-auth · token expired",
},
{
id: "gmail",
name: "Gmail",
iconKey: "gmail",
status: "syncing",
delta: "Ingesting…",
when: "Just now",
},
]
export type RecommendedSource = {
id: string
name: string
iconKey: "slack" | "linear" | "granola" | "github" | "calendar"
reason: string
}
export const RECOMMENDED_SOURCES: RecommendedSource[] = [
{
id: "slack",
name: "Slack",
iconKey: "slack",
reason: "Decisions and threads — most teams connect this.",
},
{
id: "github",
name: "GitHub",
iconKey: "github",
reason: "PRs, issues, READMEs from your repos.",
},
{
id: "linear",
name: "Linear",
iconKey: "linear",
reason: "Track project decisions and blockers.",
},
]
export type LiveSession = {
name: string
avatarColor: string
status: "live" | "idle" | "away"
tool: string
toolColor: string
doing?: string
elapsed?: string
}
export const LIVE_SESSIONS: LiveSession[] = [
{
name: "Sarah Kim",
avatarColor: "#FF8A47",
status: "live",
tool: "Cursor",
toolColor: "#4BA0FA",
doing: "Refactoring auth flow with brain context",
elapsed: "3m elapsed",
},
{
name: "Jane Doe",
avatarColor: "#4BA0FA",
status: "live",
tool: "Claude Code",
toolColor: "#FF8A47",
doing: "MSA review · pulling Acme history",
elapsed: "18m elapsed",
},
{
name: "You",
avatarColor: "#A1A1AA",
status: "idle",
tool: "Cursor",
toolColor: "#4BA0FA",
elapsed: "Idle · 12m",
},
]
export const TODAY_SESSIONS_SUMMARY: {
name: string
avatarColor: string
tool: string
count: number
lastAt: string
}[] = [
{
name: "Alex Chen",
avatarColor: "#10A37F",
tool: "ChatGPT",
count: 4,
lastAt: "2h ago",
},
{
name: "Mahesh S.",
avatarColor: "#A1A1AA",
tool: "Claude Desktop",
count: 7,
lastAt: "3h ago",
},
{
name: "Sarah Kim",
avatarColor: "#FF8A47",
tool: "Cursor",
count: 11,
lastAt: "Now",
},
]
export type ChatMessage = { role: "user" | "brain"; text: string }
export const SAMPLE_CHAT: ChatMessage[] = [
{
role: "user",
text: "What did we decide about pricing this quarter?",
},
{
role: "brain",
text: "Pro is locked at $20/mo. No overages — top-up only. Decided in the #leadership Slack thread on May 28.",
},
{
role: "user",
text: "Who's working on the Acme MSA?",
},
{
role: "brain",
text: "Jane Doe is drafting a net-30 counter to Acme's net-60 ask. Latest draft is in Drive (Acme · MSA draft v3.pdf).",
},
]

View file

@ -0,0 +1,671 @@
"use client"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import { GoogleDrive, Notion } from "@ui/assets/icons"
import {
AlertCircle,
ArrowRight,
Calendar,
CheckCircle2,
FileText,
Github,
Loader2,
MessageSquare,
Plus,
Quote,
Sparkles,
Target,
} from "lucide-react"
import {
ACTIVITY,
AGENT_SERIES,
CONNECTED_SOURCES,
RECENT_ANSWERS,
RECOMMENDED_SOURCES,
SESSIONS_BY_DAY,
STATS,
TEAM_RECENT,
TEAM_THIS_WEEK,
WEEKLY_DIGEST,
type ActivityEvent,
type ConnectedSource,
type RecommendedSource,
type Stat,
} from "./data"
function GmailIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 256 193"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M58.182 192.05V93.14L27.507 65.077 0 49.504v125.091c0 9.658 7.825 17.455 17.455 17.455z"
fill="#4285F4"
/>
<path
d="M197.818 192.05h40.727c9.659 0 17.455-7.826 17.455-17.455V49.505l-31.156 17.837-27.026 25.798z"
fill="#34A853"
/>
<path
d="m58.182 93.14-4.174-38.647 4.174-36.989L128 69.868l69.818-52.364 4.668 33.95-4.668 41.685L128 145.504z"
fill="#EA4335"
/>
<path
d="M197.818 17.504V93.14L256 49.504V26.231c0-21.585-24.64-33.89-41.89-20.945z"
fill="#FBBC04"
/>
<path
d="m0 49.504 26.759 20.07L58.182 93.14V17.504L41.89 5.286C24.61-7.66 0 4.646 0 26.23z"
fill="#C5221F"
/>
</svg>
)
}
export const modalCardStyle = {
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}
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)",
}
export function StatsStrip({ stats = STATS }: { stats?: Stat[] }) {
return (
<section
className="rounded-[16px] bg-[#1B1F24] grid grid-cols-2 md:grid-cols-4 divide-y md:divide-y-0 md:divide-x divide-white/[0.04]"
style={modalCardStyle}
>
{stats.map((s) => (
<div key={s.label} className="px-5 py-4">
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
{s.label}
</p>
<p
className={cn(
"text-[22px] font-semibold text-[#fafafa] mt-1.5 tabular-nums leading-none",
dmSans125ClassName(),
)}
>
{s.value}
</p>
{s.delta && (
<p
className={cn(
"text-[11px] font-medium mt-1.5",
s.deltaTint === "up"
? "text-[#4BA0FA]"
: s.deltaTint === "down"
? "text-[#FF8A47]"
: "text-[#737373]",
)}
>
{s.delta}
</p>
)}
</div>
))}
</section>
)
}
export function SectionHeader({ title, cta }: { title: string; cta?: string }) {
return (
<div className="flex items-end justify-between mb-3 px-1">
<p className="text-[11px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
{title}
</p>
{cta && (
<button
type="button"
className="text-[12px] text-[#737373] hover:text-[#fafafa] transition-colors font-medium"
>
{cta}
</button>
)}
</div>
)
}
export function RecentAnswersCard() {
return (
<div
className="rounded-[14px] bg-[#1B1F24] p-4 flex flex-col"
style={modalCardStyle}
>
<div className="flex items-center justify-between mb-3">
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
Recent answers
</p>
<button
type="button"
className="text-[10px] text-[#737373] hover:text-[#fafafa] transition-colors font-medium"
>
See all →
</button>
</div>
<ul className="space-y-2.5">
{RECENT_ANSWERS.map((a) => (
<li
key={a.question}
className="border-l border-[#2261CA33] pl-2.5 hover:border-[#4BA0FA] transition-colors"
>
<p className="text-[12px] text-[#fafafa] font-semibold leading-snug truncate">
{a.question}
</p>
<p className="text-[11px] text-[#A1A1AA] leading-[1.4] font-medium mt-0.5 line-clamp-1">
→ {a.answer}
</p>
<p className="text-[10px] text-[#737373] font-medium mt-1">
{a.who} · {a.when}
</p>
</li>
))}
</ul>
</div>
)
}
export function WeeklyDigestCard() {
return (
<div
className="rounded-[14px] bg-[#1B1F24] p-4 md:p-5 flex flex-col relative overflow-hidden"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px left-0 right-0 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
}}
/>
<div className="flex items-end justify-between mb-3">
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
This week
</p>
<p className="text-[10px] uppercase tracking-[0.12em] text-[#525D6E] font-semibold">
{WEEKLY_DIGEST.range}
</p>
</div>
<p
className={cn(
"text-[14px] font-semibold text-[#fafafa] leading-snug",
dmSans125ClassName(),
)}
>
{WEEKLY_DIGEST.headline.split(/(\d+)/).map((piece, i) =>
/^\d+$/.test(piece) ? (
<span key={i} className="text-[#4BA0FA]">
{piece}
</span>
) : (
<span key={i}>{piece}</span>
),
)}
</p>
<ul className="mt-3 space-y-1.5">
{WEEKLY_DIGEST.bullets.map((b) => (
<li key={b.label} className="flex items-start gap-2">
<span
aria-hidden
className={cn("mt-1.5 size-1 rounded-full bg-current", b.tint)}
/>
<span className={cn("text-[12px] font-medium", b.tint)}>
{b.label}
</span>
</li>
))}
</ul>
<button
type="button"
className="mt-auto pt-3 self-start text-[11px] text-[#A1A1AA] hover:text-[#fafafa] transition-colors font-medium inline-flex items-center gap-1.5"
>
Open weekly digest <ArrowRight className="size-3" />
</button>
</div>
)
}
export function BrainPulseBlock() {
return (
<section className="grid lg:grid-cols-[2fr_3fr] gap-3 items-stretch">
<RecentAnswersCard />
<WeeklyDigestCard />
</section>
)
}
export function BrainActivityChart() {
const totalsByAgent = AGENT_SERIES.map((s) => ({
...s,
total: SESSIONS_BY_DAY.reduce((sum, d) => sum + (d.values[s.key] ?? 0), 0),
}))
const grandTotal = totalsByAgent.reduce((sum, s) => sum + s.total, 0)
const dayTotals = SESSIONS_BY_DAY.map((d) =>
AGENT_SERIES.reduce((sum, s) => sum + (d.values[s.key] ?? 0), 0),
)
const max = Math.max(...dayTotals)
return (
<section
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6 relative overflow-hidden"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px left-0 right-0 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
}}
/>
<div className="flex flex-wrap items-end justify-between gap-3 mb-5">
<div>
<p
className={cn(
"text-[16px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Sessions by agent
</p>
<p className="text-[12px] text-[#737373] font-medium mt-0.5">
{grandTotal} agent sessions, last 7 days — which tool the team
reached for.
</p>
</div>
<div className="flex flex-wrap items-center gap-3 text-[11px] font-medium">
{totalsByAgent.map((s) => (
<span
key={s.key}
className="inline-flex items-center gap-1.5 text-[#A1A1AA]"
>
<span
aria-hidden
className="size-2 rounded-full"
style={{ background: s.color }}
/>
{s.label}{" "}
<span className="text-[#fafafa] tabular-nums">{s.total}</span>
</span>
))}
</div>
</div>
<div className="flex items-end gap-2">
{SESSIONS_BY_DAY.map((d, dayIdx) => {
const BAR_AREA_PX = 140
const totalForDay = dayTotals[dayIdx] ?? 0
const barHeightPx = Math.max(
6,
Math.round((totalForDay / max) * BAR_AREA_PX),
)
return (
<div
key={d.label}
className="flex-1 flex flex-col items-center gap-2"
>
<div
className="w-full flex items-end"
style={{ height: `${BAR_AREA_PX}px` }}
>
<div
className="w-full rounded-[4px] overflow-hidden flex flex-col"
style={{ height: `${barHeightPx}px` }}
title={`${d.label}: ${totalForDay} sessions`}
>
{AGENT_SERIES.map((series, sIdx) => {
const val = d.values[series.key] ?? 0
if (val === 0) return null
const segHeight =
totalForDay > 0 ? (val / totalForDay) * 100 : 0
return (
<div
key={series.key}
style={{
height: `${segHeight}%`,
background: series.color,
}}
className={cn(
sIdx === 0 && "rounded-t-[4px]",
sIdx === AGENT_SERIES.length - 1 && "rounded-b-[4px]",
)}
title={`${series.label} · ${val}`}
/>
)
})}
</div>
</div>
<span className="text-[10px] text-[#737373] font-medium tabular-nums">
{d.label}
</span>
</div>
)
})}
</div>
</section>
)
}
function activitySourceIcon(s: ActivityEvent["source"]) {
if (s === "drive") return <GoogleDrive className="size-3.5" />
if (s === "notion") return <Notion className="size-3.5" />
if (s === "slack")
return <MessageSquare className="size-3.5 text-[#4BA0FA]" />
if (s === "answer") return <Sparkles className="size-3.5 text-[#4BA0FA]" />
if (s === "decision")
return <CheckCircle2 className="size-3.5 text-[#4BA0FA]" />
return <Sparkles className="size-3.5 text-[#8B8B8B]" />
}
export function ActivityFeedBlock() {
return (
<section>
<SectionHeader title="Activity feed" cta="Show more →" />
<div
className="rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] divide-y divide-white/[0.04]"
style={inputBevelStyle}
>
{ACTIVITY.map((a, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-3">
<div className="size-7 rounded-[8px] bg-[#0F1217] flex items-center justify-center shrink-0">
{activitySourceIcon(a.source)}
</div>
<p className="flex-1 text-[13px] text-[#fafafa] font-medium">
{a.text}
</p>
<span className="text-[11px] text-[#737373] font-medium tabular-nums shrink-0">
{a.when}
</span>
</div>
))}
</div>
</section>
)
}
export function TeamActivityRail() {
return (
<section
className="rounded-[20px] bg-[#1B1F24] p-5 md:p-6"
style={modalCardStyle}
>
<div className="flex items-center justify-between mb-1">
<p
className={cn(
"text-[16px] font-semibold leading-tight",
dmSans125ClassName(),
)}
>
Team activity
</p>
<span className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold">
This week
</span>
</div>
<p className="text-[11px] text-[#737373] font-medium">
What your team's been adding to the brain.
</p>
<div className="mt-5 space-y-2.5">
{TEAM_THIS_WEEK.map((member) => (
<div
key={member.email}
className="flex items-center gap-3 rounded-[10px] px-2 py-1.5 hover:bg-white/[0.03] transition-colors"
>
<div
className="size-8 rounded-full flex items-center justify-center text-[11px] font-semibold text-[#0F1217] shrink-0"
style={{ background: member.color }}
>
{member.name[0]}
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] text-[#fafafa] font-medium truncate">
{member.name}
</p>
<p className="text-[11px] text-[#737373] font-medium truncate">
{member.summary}
</p>
</div>
<span className="text-[12px] text-[#A1A1AA] tabular-nums font-semibold shrink-0">
{member.contributions}
</span>
</div>
))}
</div>
<div className="mt-5 pt-4 border-t border-white/[0.04]">
<p className="text-[10px] uppercase tracking-[0.12em] text-[#737373] font-semibold mb-2.5">
Recent
</p>
<ul className="space-y-2">
{TEAM_RECENT.map((event, i) => (
<li
key={i}
className="text-[12px] text-[#A1A1AA] leading-[1.5] font-medium"
>
<span className="text-[#fafafa]">{event.who}</span> {event.what}
<span className="text-[#525D6E]"> · {event.when}</span>
</li>
))}
</ul>
</div>
<button
type="button"
className="mt-5 w-full inline-flex items-center justify-center gap-1.5 h-9 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] text-[12px] font-medium text-[#fafafa] hover:bg-[#14161A] transition-colors"
>
<Plus className="size-3.5" />
Invite teammates
</button>
</section>
)
}
export function BrainHomeBackground() {
return (
<div className="pointer-events-none fixed inset-0 z-0">
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(ellipse 80% 60% at 50% 0%, rgba(75,160,250,0.08) 0%, rgba(34,97,202,0.04) 35%, transparent 70%)",
}}
/>
<div className="absolute inset-0 bg-[#05080D]/55" aria-hidden />
<div
id="brain-home-grid"
className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.18)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_55%,transparent_100%)]"
/>
</div>
)
}
export function Quotation({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center gap-1 text-[#737373]">
<Quote className="size-3" />
{children}
</span>
)
}
function connectedSourceIcon(key: ConnectedSource["iconKey"]) {
if (key === "drive") return <GoogleDrive className="size-4" />
if (key === "notion") return <Notion className="size-4" />
if (key === "gmail") return <GmailIcon className="size-4" />
if (key === "github") return <Github className="size-4 text-[#fafafa]" />
return <FileText className="size-4 text-[#8B8B8B]" />
}
function sourceStatusMeta(status: ConnectedSource["status"]) {
if (status === "healthy")
return {
dot: "bg-[#4BA0FA]",
tint: "text-[#4BA0FA]",
icon: <CheckCircle2 className="size-3" />,
}
if (status === "syncing")
return {
dot: "bg-[#A1A1AA]",
tint: "text-[#A1A1AA]",
icon: <Loader2 className="size-3 animate-spin" />,
}
if (status === "warning")
return {
dot: "bg-[#FF8A47]",
tint: "text-[#FF8A47]",
icon: <AlertCircle className="size-3" />,
}
return {
dot: "bg-[#FF5C5C]",
tint: "text-[#FF5C5C]",
icon: <AlertCircle className="size-3" />,
}
}
export function SourcesHealthRail({ empty }: { empty?: boolean }) {
if (empty) {
return (
<section className="rounded-[16px] border border-white/[0.05] bg-white/[0.02] p-4">
<p
className={cn(
"text-[14px] font-semibold leading-tight",
dmSans125ClassName(),
)}
>
Sources health
</p>
<p className="text-[11px] text-[#737373] font-medium mt-0.5 leading-[1.5]">
Nothing connected yet — Drive, Notion, Gmail and more are ready when
you are.
</p>
<button
type="button"
className="mt-3 w-full inline-flex items-center justify-center gap-1.5 h-8 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] text-[12px] font-medium text-[#fafafa] hover:bg-[#14161A] transition-colors"
>
<Plus className="size-3.5" />
Connect a source
</button>
</section>
)
}
const issueCount = CONNECTED_SOURCES.filter(
(s) => s.status === "warning" || s.status === "error",
).length
return (
<section className="rounded-[16px] border border-white/[0.05] bg-white/[0.02] p-4">
<div className="flex items-center justify-between mb-1">
<p
className={cn(
"text-[14px] font-semibold leading-tight",
dmSans125ClassName(),
)}
>
Sources health
</p>
{issueCount > 0 ? (
<span className="text-[10px] uppercase tracking-[0.12em] text-[#FF8A47] font-semibold">
{issueCount} need attention
</span>
) : (
<span className="text-[10px] uppercase tracking-[0.12em] text-[#4BA0FA] font-semibold">
All healthy
</span>
)}
</div>
<p className="text-[11px] text-[#737373] font-medium">
Is the brain getting fed?
</p>
<ul className="mt-3 space-y-0.5">
{CONNECTED_SOURCES.map((s) => {
const meta = sourceStatusMeta(s.status)
return (
<li
key={s.id}
className="flex items-center gap-2.5 rounded-[8px] px-1.5 py-2 hover:bg-white/[0.03] transition-colors"
>
<div className="size-6 flex items-center justify-center shrink-0">
{connectedSourceIcon(s.iconKey)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="text-[12px] text-[#fafafa] font-medium truncate">
{s.name}
</p>
<span className="text-[10px] text-[#737373] font-medium tabular-nums shrink-0">
{s.when}
</span>
</div>
<p
className={cn(
"text-[11px] font-medium mt-0.5 inline-flex items-center gap-1",
meta.tint,
)}
>
{meta.icon}
<span className="truncate">{s.statusMessage ?? s.delta}</span>
</p>
</div>
</li>
)
})}
</ul>
</section>
)
}
function recommendedIcon(key: RecommendedSource["iconKey"]) {
if (key === "slack")
return <MessageSquare className="size-4 text-[#4BA0FA]" />
if (key === "github") return <Github className="size-4 text-[#fafafa]" />
if (key === "linear") return <Target className="size-4 text-[#fafafa]" />
if (key === "granola") return <Sparkles className="size-4 text-[#FF8A47]" />
if (key === "calendar") return <Calendar className="size-4 text-[#A1A1AA]" />
return <FileText className="size-4 text-[#8B8B8B]" />
}
export function RecommendedRail() {
return (
<section className="rounded-[16px] border border-white/[0.05] bg-white/[0.02] p-4">
<p
className={cn(
"text-[14px] font-semibold leading-tight",
dmSans125ClassName(),
)}
>
Add more to the brain
</p>
<p className="text-[11px] text-[#737373] font-medium mt-0.5">
Teams like yours connect these next.
</p>
<ul className="mt-3 space-y-0.5">
{RECOMMENDED_SOURCES.map((r) => (
<li
key={r.id}
className="group flex items-start gap-2.5 rounded-[8px] px-1.5 py-2 hover:bg-white/[0.03] transition-colors cursor-pointer"
>
<div className="size-6 flex items-center justify-center shrink-0">
{recommendedIcon(r.iconKey)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="text-[12px] text-[#fafafa] font-medium truncate">
{r.name}
</p>
<ArrowRight className="size-3 text-[#525D6E] group-hover:text-[#fafafa] transition-colors shrink-0" />
</div>
<p className="text-[11px] text-[#737373] font-medium leading-[1.4] mt-0.5 line-clamp-2">
{r.reason}
</p>
</div>
</li>
))}
</ul>
</section>
)
}

View file

@ -0,0 +1,82 @@
export const CHAT_ATTACHMENT_ACCEPT =
"image/*,.pdf,application/pdf,.doc,.docx,.txt,.md,.mdx,.markdown,text/markdown"
export const CHAT_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024
const SUPPORTED_EXTENSIONS = new Set([
".pdf",
".doc",
".docx",
".txt",
".md",
".mdx",
".markdown",
])
export type ChatAttachment = {
id: string
documentId?: string
filename: string
mediaType: string
size: number
saveToMemory: boolean
status: "ready" | "processing" | "failed"
url?: string
contentPreview?: string
}
export type ChatAttachmentDraftStatus =
| "queued"
| "uploading"
| "uploaded"
| "error"
export type ChatAttachmentDraft = {
id: string
file: File
saveToMemory: boolean
status: ChatAttachmentDraftStatus
errorMessage?: string
uploaded?: ChatAttachment
}
export type ChatAttachmentMessageMetadata = {
attachments?: ChatAttachment[]
}
export function isAcceptedChatAttachment(file: File): boolean {
if (file.size > CHAT_ATTACHMENT_MAX_BYTES) return false
const name = file.name.toLowerCase()
const ext = name.includes(".") ? name.slice(name.lastIndexOf(".")) : ""
if (SUPPORTED_EXTENSIONS.has(ext)) return true
if (file.type.startsWith("image/")) return true
if (file.type === "application/pdf") return true
if (file.type === "text/markdown") return true
return false
}
export function chatAttachmentKey(file: File): string {
return `${file.name}:${file.size}:${file.lastModified}`
}
export function createChatAttachmentDraft(file: File): ChatAttachmentDraft {
return {
id: crypto.randomUUID(),
file,
saveToMemory: true,
status: "queued",
}
}
export function formatAttachmentSize(size: number): string {
if (size < 1024) return `${size} B`
const kb = size / 1024
if (kb < 1024) return `${kb.toFixed(1)} KB`
return `${(kb / 1024).toFixed(1)} MB`
}
export function getChatMessageAttachments(metadata: unknown): ChatAttachment[] {
const attachments = (metadata as ChatAttachmentMessageMetadata | undefined)
?.attachments
return Array.isArray(attachments) ? attachments : []
}

View file

@ -8,6 +8,14 @@ import { cn } from "@lib/utils"
import type { ModelId } from "@/lib/models"
import { SpaceSelector } from "@/components/space-selector"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import { toast } from "sonner"
import {
chatAttachmentKey,
CHAT_ATTACHMENT_ACCEPT,
createChatAttachmentDraft,
type ChatAttachmentDraft,
isAcceptedChatAttachment,
} from "./attachments"
import { ReasoningSelector } from "./reasoning-selector"
import { getDefaultReasoningEffort, type ReasoningEffort } from "@/lib/models"
@ -20,13 +28,17 @@ export function HomeChatComposer({
model: ModelId,
projectId: string,
reasoningEffort: ReasoningEffort,
attachments?: ChatAttachmentDraft[],
) => void
className?: string
}) {
const [input, setInput] = useState("")
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
const [attachmentDrafts, setAttachmentDrafts] = useState<
ChatAttachmentDraft[]
>([])
const [selectedModel, setSelectedModel] = useState<ModelId>("grok-4.3")
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
getDefaultReasoningEffort("gemini-2.5-pro"),
getDefaultReasoningEffort("grok-4.3"),
)
const { selectedProject } = useProject()
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
@ -40,15 +52,18 @@ export function HomeChatComposer({
const send = useCallback(() => {
const t = input.trim()
if (!t) return
if (!t && attachmentDrafts.length === 0) return
onStartChat(
t,
selectedModel,
chatSpaceProjects[0] ?? selectedProject,
reasoningEffort,
attachmentDrafts,
)
setInput("")
setAttachmentDrafts([])
}, [
attachmentDrafts,
chatSpaceProjects,
input,
onStartChat,
@ -57,6 +72,51 @@ export function HomeChatComposer({
selectedProject,
])
const handleAddAttachmentFiles = useCallback(
(files: FileList | File[]) => {
const incoming = Array.from(files)
const accepted = incoming.filter(isAcceptedChatAttachment)
const rejected = incoming.length - accepted.length
if (rejected > 0) {
toast.error(
rejected === 1
? "One attachment is not supported or is over 50MB"
: `${rejected} attachments are not supported or are over 50MB`,
)
}
if (accepted.length === 0) return
const existingKeys = new Set(
attachmentDrafts.map((item) => chatAttachmentKey(item.file)),
)
const nextItems: ChatAttachmentDraft[] = []
let duplicateCount = 0
for (const file of accepted) {
const key = chatAttachmentKey(file)
if (existingKeys.has(key)) {
duplicateCount++
continue
}
existingKeys.add(key)
nextItems.push(createChatAttachmentDraft(file))
}
if (duplicateCount > 0) {
toast.message(
duplicateCount === 1
? "Skipped duplicate attachment"
: `Skipped ${duplicateCount} duplicate attachments`,
)
}
if (nextItems.length === 0) return
setAttachmentDrafts((prev) => [...prev, ...nextItems])
},
[attachmentDrafts],
)
const handleRemoveAttachment = useCallback((id: string) => {
setAttachmentDrafts((prev) => prev.filter((item) => item.id !== id))
}, [])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
@ -66,7 +126,7 @@ export function HomeChatComposer({
return (
<div className={cn(className)}>
<div className="mx-auto w-full max-w-[720px] px-4 pt-1 pb-[max(1.25rem,calc(env(safe-area-inset-bottom)+1rem))] md:pb-6">
<div className="mx-auto w-full max-w-[720px] px-4 pt-1 pb-2 md:pb-6">
<ChatInput
value={input}
onChange={(e) => setInput(e.target.value)}
@ -74,27 +134,34 @@ export function HomeChatComposer({
onStop={() => {}}
onKeyDown={handleKeyDown}
isResponding={false}
attachments={attachmentDrafts}
onAddAttachmentFiles={handleAddAttachmentFiles}
onRemoveAttachment={handleRemoveAttachment}
canSend={input.trim().length > 0 || attachmentDrafts.length > 0}
attachmentAccept={CHAT_ATTACHMENT_ACCEPT}
showStatusStrip={false}
stackedToolbar={
<>
<ChatModelSelector
selectedModel={selectedModel}
onModelChange={handleModelChange}
minimal
/>
<ReasoningSelector
value={reasoningEffort}
onChange={setReasoningEffort}
/>
<SpaceSelector
selectedProjects={chatSpaceProjects}
onValueChange={setChatSpaceProjects}
variant="insideOut"
includeAuto
hideCount
triggerClassName="h-auto min-h-0 max-w-[min(160px,35vw)] rounded-full border border-[#161F2C] bg-[#000000] px-3 py-1.5 shadow-none hover:bg-[#05080D]"
/>
</>
<ChatModelSelector
selectedModel={selectedModel}
onModelChange={handleModelChange}
minimal
/>
}
toolbarTrailing={
<ReasoningSelector
value={reasoningEffort}
onChange={setReasoningEffort}
/>
}
toolbarEnd={
<SpaceSelector
selectedProjects={chatSpaceProjects}
onValueChange={setChatSpaceProjects}
variant="insideOut"
includeAuto
hideCount
triggerClassName="h-auto min-h-0 max-w-[min(160px,35vw)] gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 shadow-none hover:bg-white/5"
/>
}
/>
</div>

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,28 @@
"use client"
import { BrainIcon, ChevronUpIcon, ZapIcon } from "lucide-react"
import {
BrainIcon,
CheckIcon,
ChevronUpIcon,
Loader2Icon,
PaperclipIcon,
RotateCcwIcon,
XIcon,
ZapIcon,
} from "lucide-react"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import NovaOrb from "@/components/nova/nova-orb"
import { SuperLoader } from "@/components/superloader"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { type ReactNode, useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { SendButton, StopButton } from "./actions"
import {
CHAT_ATTACHMENT_ACCEPT,
type ChatAttachmentDraft,
formatAttachmentSize,
} from "../attachments"
import { type ModelId, modelNames, type ReasoningEffort } from "@/lib/models"
export interface QueuedChatMessagePreview {
@ -31,8 +47,19 @@ interface ChatInputProps {
onExpandedChange?: (expanded: boolean) => void
/** Model + space controls on one row with send; textarea full-width above */
stackedToolbar?: ReactNode
/** Trailing controls rendered after the attach button (e.g. reasoning) */
toolbarTrailing?: ReactNode
/** Controls rendered just left of the send button (e.g. space selector) */
toolbarEnd?: ReactNode
/** Nova status row + chain-of-thought toggle (off for e.g. home composer) */
showStatusStrip?: boolean
attachments?: ChatAttachmentDraft[]
onAddAttachmentFiles?: (files: FileList | File[]) => void
onRemoveAttachment?: (id: string) => void
onRetryAttachment?: (id: string) => void
canSend?: boolean
attachmentAccept?: string
disableFileDropZone?: boolean
}
export default function ChatInput({
@ -49,17 +76,28 @@ export default function ChatInput({
chainOfThoughtComponent,
onExpandedChange,
stackedToolbar,
toolbarTrailing,
toolbarEnd,
showStatusStrip = true,
attachments = [],
onAddAttachmentFiles,
onRemoveAttachment,
onRetryAttachment,
canSend,
attachmentAccept = CHAT_ATTACHMENT_ACCEPT,
disableFileDropZone = false,
}: ChatInputProps) {
const [isMultiline, setIsMultiline] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const isSendDisabled = !value.trim() || sendDisabled
const fileInputRef = useRef<HTMLInputElement>(null)
const dragDepthRef = useRef(0)
const canSubmit = canSend ?? value.trim().length > 0
const isSendDisabled = !canSubmit || sendDisabled
const hasQueuedPreview = queuedMessages.length > 0
const resolvedSendDisabledTooltip =
sendDisabled && value.trim()
? sendDisabledTooltip
: "Type a message to send"
sendDisabled && canSubmit ? sendDisabledTooltip : "Type a message to send"
useEffect(() => {
if (!showStatusStrip && isExpanded) {
@ -81,12 +119,110 @@ export default function ChatInput({
setIsMultiline(textarea.scrollHeight > 52)
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (files?.length) onAddAttachmentFiles?.(files)
e.target.value = ""
}
const canAttachFiles = Boolean(onAddAttachmentFiles) && !isResponding
const hasDraggedFiles = (e: React.DragEvent) =>
Array.from(e.dataTransfer.types).includes("Files")
const handleDragEnter = (e: React.DragEvent) => {
if (!hasDraggedFiles(e)) return
e.preventDefault()
e.stopPropagation()
dragDepthRef.current += 1
if (canAttachFiles) setIsDraggingFiles(true)
}
const handleDragOver = (e: React.DragEvent) => {
if (!hasDraggedFiles(e)) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = canAttachFiles ? "copy" : "none"
}
const handleDragLeave = (e: React.DragEvent) => {
if (!hasDraggedFiles(e)) return
e.preventDefault()
e.stopPropagation()
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) setIsDraggingFiles(false)
}
const handleDrop = (e: React.DragEvent) => {
if (!hasDraggedFiles(e)) return
e.preventDefault()
e.stopPropagation()
dragDepthRef.current = 0
setIsDraggingFiles(false)
const files = e.dataTransfer.files
if (canAttachFiles && files.length) onAddAttachmentFiles?.(files)
}
const showAttachments = attachments.length > 0
const attachmentTray = showAttachments ? (
<div className="scrollbar-none flex max-w-full gap-2 overflow-x-auto px-0 pb-1 sm:px-1">
{attachments.map((attachment) => {
return (
<AttachmentPreviewChip
key={attachment.id}
attachment={attachment}
onRemove={onRemoveAttachment}
onRetry={onRetryAttachment}
/>
)
})}
</div>
) : null
const attachmentButton = onAddAttachmentFiles ? (
<>
<input
ref={fileInputRef}
type="file"
multiple
accept={attachmentAccept}
onChange={handleFileSelect}
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isResponding}
className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-white/45 transition-colors hover:bg-white/5 hover:text-white/80 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Attach files"
title="Attach files"
>
<PaperclipIcon className="size-4" />
</button>
</>
) : null
const dropOverlay =
!disableFileDropZone && isDraggingFiles ? (
<div className="pointer-events-none absolute inset-1 z-10 grid place-items-center rounded-lg border border-dashed border-[#4B5563] bg-black/70 text-sm font-medium text-fg-primary backdrop-blur-sm">
Drop files to attach
</div>
) : null
const dropZoneProps = disableFileDropZone
? {}
: {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
}
return (
<motion.div
className={cn("relative z-20!")}
animate={{
padding: showStatusStrip ? (isExpanded ? "16px" : "0") : "0",
margin: showStatusStrip ? (isExpanded ? "0" : "16px") : "0",
margin: "0",
borderRadius: showStatusStrip
? isExpanded
? "0 0 12px 12px"
@ -133,10 +269,16 @@ export default function ChatInput({
disabled={!chainOfThoughtComponent}
>
<div className="flex items-center gap-3">
<NovaOrb size={24} className="blur-[1px]! z-10" />
<p className={cn("text-[#525D6E]", dmSansClassName())}>
{activeStatus || "Waiting for input..."}
</p>
{isResponding ? (
<SuperLoader label={activeStatus || "Thinking…"} />
) : (
<>
<NovaOrb size={24} className="blur-[1px]! z-10" />
<p className={cn("text-[#525D6E]", dmSansClassName())}>
{activeStatus || "Waiting for input..."}
</p>
</>
)}
</div>
{chainOfThoughtComponent && (
<ChevronUpIcon
@ -195,7 +337,13 @@ export default function ChatInput({
</>
) : null}
{stackedToolbar ? (
<div className="relative z-30 flex flex-col gap-2 rounded-xl bg-surface-card/60 backdrop-blur-md p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10">
<fieldset
aria-label="Chat input with file drop zone"
{...dropZoneProps}
className="relative z-30 m-0 flex min-w-0 flex-col gap-2 rounded-xl border-0 bg-surface-card/60 p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-md transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10"
>
{dropOverlay}
{attachmentTray}
<textarea
ref={textareaRef}
value={value}
@ -207,12 +355,15 @@ export default function ChatInput({
rows={1}
/>
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{stackedToolbar}
{attachmentButton}
{toolbarTrailing}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{toolbarEnd}
{isResponding && <StopButton onClick={onStop} />}
{(!isResponding || value.trim()) && (
{(!isResponding || canSubmit) && (
<SendButton
onClick={onSend}
disabled={isSendDisabled}
@ -221,14 +372,18 @@ export default function ChatInput({
)}
</div>
</div>
</div>
</fieldset>
) : (
<div
<fieldset
aria-label="Chat input with file drop zone"
{...dropZoneProps}
className={cn(
"flex items-end gap-2 rounded-xl bg-surface-card/60 backdrop-blur-md p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10",
"relative m-0 flex min-w-0 flex-col gap-2 rounded-xl border-0 bg-surface-card/60 p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-md transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10",
isMultiline && "flex-col",
)}
>
{dropOverlay}
{attachmentTray}
<textarea
ref={textareaRef}
value={value}
@ -239,9 +394,10 @@ export default function ChatInput({
style={{ minHeight: "36px" }}
rows={1}
/>
<div className="flex items-center gap-1.5 transition-all duration-200">
<div className="flex w-full items-center justify-end gap-2 transition-all duration-200">
{attachmentButton}
{isResponding && <StopButton onClick={onStop} />}
{(!isResponding || value.trim()) && (
{(!isResponding || canSubmit) && (
<SendButton
onClick={onSend}
disabled={isSendDisabled}
@ -249,8 +405,197 @@ export default function ChatInput({
/>
)}
</div>
</div>
</fieldset>
)}
</motion.div>
)
}
function AttachmentPreviewChip({
attachment,
onRemove,
onRetry,
}: {
attachment: ChatAttachmentDraft
onRemove?: (id: string) => void
onRetry?: (id: string) => void
}) {
const [objectUrl, setObjectUrl] = useState<string | null>(null)
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
const isUploading = attachment.status === "uploading"
const isUploaded = attachment.status === "uploaded"
const isError = attachment.status === "error"
const isImage = attachment.file.type.startsWith("image/")
const extension = getAttachmentExtension(attachment.file)
useEffect(() => {
if (!isImage) {
setObjectUrl(null)
return
}
const url = URL.createObjectURL(attachment.file)
setObjectUrl(url)
return () => URL.revokeObjectURL(url)
}, [attachment.file, isImage])
const statusLabel = isError
? attachment.errorMessage || "Upload failed"
: isUploading
? "Uploading..."
: isUploaded
? "Uploaded"
: formatAttachmentSize(attachment.file.size)
return (
<>
<div
className={cn(
"group relative flex h-11 w-[min(280px,calc(100vw-4.5rem))] shrink-0 items-center gap-2 overflow-hidden rounded-xl border border-[#1A1D22] bg-[#050607] px-2 text-sm text-fg-primary shadow-[0_6px_18px_rgba(0,0,0,0.22)] transition-colors hover:border-[#30343B] hover:bg-[#080A0D] focus-within:border-[#30343B] sm:w-auto sm:min-w-[220px] sm:max-w-[300px] sm:hover:border-[#2261CA66] sm:hover:bg-[#041127] sm:focus-within:border-[#2261CA66] sm:focus-within:bg-[#041127]",
isImage && objectUrl && "cursor-pointer",
isError && "border-red-400/40 bg-red-950/20 hover:border-red-400/50",
)}
title={statusLabel}
>
<button
type="button"
onClick={() => isImage && objectUrl && setIsPreviewOpen(true)}
disabled={!isImage || !objectUrl}
className={cn(
"relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-[#242832] bg-black",
isImage && objectUrl && "cursor-pointer hover:border-[#4B5563]",
(!isImage || !objectUrl) && "cursor-default",
)}
aria-label={
isImage ? `Preview ${attachment.file.name}` : attachment.file.name
}
>
{isImage && objectUrl ? (
<img
src={objectUrl}
alt=""
className="size-full object-cover"
draggable={false}
/>
) : (
<DocumentFileGlyph label={extension} />
)}
{isUploading || isUploaded ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
{isUploading ? (
<Loader2Icon className="size-3.5 animate-spin text-[#C8D1DC]" />
) : (
<CheckIcon className="size-3.5 text-emerald-400" />
)}
</div>
) : null}
</button>
<div className="min-w-0 flex-1 pr-1">
<div
className="truncate font-medium leading-none text-fg-primary"
title={attachment.file.name}
>
{attachment.file.name}
</div>
<div className="mt-1 truncate text-[11px] leading-none text-fg-faint">
{statusLabel}
</div>
</div>
<div className="hidden shrink-0 items-center gap-1 opacity-0 transition-opacity sm:flex sm:group-hover:opacity-100 sm:group-focus-within:opacity-100">
{isError ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onRetry?.(attachment.id)
}}
className="flex size-7 shrink-0 items-center justify-center rounded-md border border-[#242832] bg-black text-fg-faint transition-colors hover:border-[#3A4049] hover:bg-[#111418] hover:text-fg-primary"
aria-label={`Retry ${attachment.file.name}`}
title={statusLabel}
>
<RotateCcwIcon className="size-3.5" />
</button>
) : null}
</div>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onRemove?.(attachment.id)
}}
disabled={isUploading}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-fg-faint transition-colors hover:bg-[#111418] hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Remove ${attachment.file.name}`}
>
<XIcon className="size-4" />
</button>
</div>
<Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}>
<DialogContent
showCloseButton={false}
className="w-[calc(100vw-32px)] max-w-none gap-0 overflow-hidden rounded-xl border border-[#1D222A] bg-[#050607] p-0 text-fg-primary shadow-[0_24px_80px_rgba(0,0,0,0.65)] sm:w-[min(92vw,980px)] sm:max-w-[980px]"
>
<DialogTitle className="sr-only">
Preview {attachment.file.name}
</DialogTitle>
<div className="flex min-h-0 flex-col">
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_32px] items-center gap-2 border-[#171B22] border-b px-3 py-2 sm:px-4 sm:py-3">
<div className="min-w-0">
<p className="truncate font-medium text-fg-primary text-sm">
{attachment.file.name}
</p>
<p className="mt-0.5 text-[11px] text-fg-faint">
{formatAttachmentSize(attachment.file.size)}
</p>
</div>
<button
type="button"
onClick={() => setIsPreviewOpen(false)}
className="flex size-8 items-center justify-center rounded-md text-fg-faint transition-colors hover:bg-[#111418] hover:text-fg-primary focus:outline-none focus:ring-2 focus:ring-[#3374FF]/40"
>
<XIcon className="size-4" />
<span className="sr-only">Close preview</span>
</button>
</div>
<div className="grid h-[min(58dvh,380px)] place-items-center bg-black px-4 py-5 sm:h-[min(76dvh,680px)] sm:px-6 sm:py-6">
{objectUrl ? (
<img
src={objectUrl}
alt={attachment.file.name}
className="block max-h-full max-w-full rounded-lg object-contain"
draggable={false}
/>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}
function DocumentFileGlyph({ label }: { label: string }) {
return (
<div className="relative flex size-6 items-end justify-center rounded-[4px] border border-[#30343B] bg-[#07090C] pb-1">
<div className="absolute right-0 top-0 size-2.5 border-[#30343B] border-b border-l bg-black" />
<span className="max-w-[22px] truncate text-[8px] font-bold uppercase leading-none text-[#AAB2BD]">
{label}
</span>
</div>
)
}
function getAttachmentExtension(file: File): string {
const name = file.name
const dotIndex = name.lastIndexOf(".")
if (dotIndex > -1 && dotIndex < name.length - 1) {
const extension = name.slice(dotIndex + 1).toLowerCase()
if (extension === "md" || extension === "markdown") return "MD"
if (extension === "pdf") return "PDF"
return extension.slice(0, 3)
}
if (file.type === "text/markdown") return "MD"
if (file.type.includes("pdf")) return "PDF"
if (file.type.startsWith("text/")) return "TXT"
return "FILE"
}

View file

@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"
import type { UIMessage } from "@ai-sdk/react"
import { Streamdown } from "streamdown"
import {
@ -52,48 +52,205 @@ type SourceUrlPart = {
title?: string
}
function WebSourcesGroup({ sources }: { sources: SourceUrlPart[] }) {
function sourceHost(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "")
} catch {
return url
}
}
function faviconUrl(host: string): string {
return `https://www.google.com/s2/favicons?sz=64&domain=${host}`
}
function isWebSearchPart(part: { type: string; toolName?: string }): boolean {
if (part.type === "dynamic-tool") {
return isWebSearchToolName(part.toolName ?? "")
}
if (part.type.startsWith("tool-")) {
return isWebSearchToolName(part.type.slice("tool-".length))
}
return false
}
function CitationLink({
href,
label,
source,
}: {
href: string
label: string
source?: SourceUrlPart
}) {
const url = source?.url ?? href
const host = sourceHost(url)
const rawTitle = source?.title?.trim()
const hasTitle =
!!rawTitle && rawTitle !== host && !/^https?:\/\//i.test(rawTitle)
let path = ""
try {
path = new URL(url).pathname.replace(/\/$/, "")
} catch {}
return (
<span className="group/cite relative inline">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 no-underline hover:text-blue-300"
>
{label}
</a>
<span className="pointer-events-none absolute bottom-full left-1/2 z-[1000] hidden -translate-x-1/2 pb-1.5 group-hover/cite:block">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="pointer-events-auto block w-64 overflow-hidden rounded-lg border border-white/10 bg-[#0B0F16]/95 p-2.5 no-underline shadow-[0_12px_32px_rgba(0,0,0,0.4)] backdrop-blur-xl"
>
<span className="flex items-center gap-2">
<img
src={faviconUrl(host)}
alt=""
className="size-4 shrink-0 rounded bg-white/10"
loading="lazy"
referrerPolicy="no-referrer"
/>
<span className="truncate text-xs font-medium text-white/85">
{host}
</span>
</span>
{hasTitle ? (
<span className="mt-1.5 block line-clamp-2 text-xs leading-snug text-white/60">
{rawTitle}
</span>
) : path ? (
<span className="mt-1 block truncate text-[11px] text-white/35">
{path}
</span>
) : null}
</a>
</span>
</span>
)
}
function makeMarkdownComponents(sources: SourceUrlPart[]) {
return {
a: ({ href, children }: { href?: string; children?: ReactNode }) => {
const label =
typeof children === "string"
? children
: Array.isArray(children)
? children.join("")
: ""
const match = label.match(/^\[?(\d+)\]?$/)
if (match && href) {
const n = Number(match[1])
const source = sources.find((s) => s.url === href) ?? sources[n - 1]
return <CitationLink href={href} label={label} source={source} />
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
{children}
</a>
)
},
}
}
function WebSourcesPill({ sources }: { sources: SourceUrlPart[] }) {
const [expanded, setExpanded] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!expanded) return
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setExpanded(false)
}
}
document.addEventListener("mousedown", onDown)
return () => document.removeEventListener("mousedown", onDown)
}, [expanded])
if (sources.length === 0) return null
const faviconHosts: string[] = []
for (const s of sources) {
const host = sourceHost(s.url)
if (!faviconHosts.includes(host)) faviconHosts.push(host)
if (faviconHosts.length >= 3) break
}
const count = sources.length
return (
<div className="rounded-lg border border-[#1E2128] bg-[#0D121A] text-xs my-1 overflow-hidden">
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className={cn(
"flex items-center gap-2 w-full px-3 py-2 cursor-pointer hover:bg-[#141922] transition-colors",
expanded && "border-b border-[#1E2128]",
)}
onClick={() => setExpanded((v) => !v)}
className="flex cursor-pointer items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.04] py-1 pr-2.5 pl-1.5 text-xs text-white/65 transition-colors hover:bg-white/[0.08] hover:text-white/80"
aria-expanded={expanded}
aria-label={`${count} web ${count === 1 ? "source" : "sources"}`}
>
<GlobeIcon className="size-3 shrink-0 text-emerald-400" />
<span className="font-medium text-emerald-400">
Web sources
<span className="text-white/40 font-normal ml-1">
({sources.length})
</span>
<span className="flex -space-x-1.5">
{faviconHosts.length > 0 ? (
faviconHosts.map((host) => (
<img
key={host}
src={faviconUrl(host)}
alt=""
className="size-4 rounded-full border border-[#0B0F16] bg-white/10 object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
))
) : (
<GlobeIcon className="size-3.5 text-emerald-400" />
)}
</span>
{expanded ? (
<ChevronDownIcon className="size-3 text-white/30 shrink-0 ml-auto" />
) : (
<ChevronRightIcon className="size-3 text-white/30 shrink-0 ml-auto" />
)}
<span>{count === 1 ? "1 source" : `${count} sources`}</span>
</button>
{expanded && (
<ul className="px-3 py-2 space-y-1.5 list-none">
{sources.map((s) => (
<li key={s.sourceId}>
<a
href={s.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline break-all"
>
{s.title?.trim() || s.url}
</a>
</li>
))}
</ul>
<div className="absolute bottom-full left-0 z-[1000] mb-2 w-[min(22rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/10 bg-[#0B0F16]/95 p-1.5 shadow-[0_12px_32px_rgba(0,0,0,0.4)] backdrop-blur-xl">
<ul className="max-h-72 list-none space-y-0.5 overflow-y-auto">
{sources.map((s) => {
const host = sourceHost(s.url)
return (
<li key={s.sourceId}>
<a
href={s.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-2 rounded-lg px-2 py-1.5 transition-colors hover:bg-white/[0.06]"
>
<img
src={faviconUrl(host)}
alt=""
className="mt-0.5 size-4 shrink-0 rounded-full bg-white/10"
loading="lazy"
referrerPolicy="no-referrer"
/>
<span className="min-w-0">
<span className="block truncate text-white/80">
{s.title?.trim() || host}
</span>
<span className="block truncate text-[11px] text-white/40">
{host}
</span>
</span>
</a>
</li>
)
})}
</ul>
</div>
)}
</div>
)
@ -210,6 +367,23 @@ function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
if (toolName === "bash") {
return <BashToolDisplay part={part} />
}
if (isWebSearchToolName(toolName)) {
if (part.state === "output-available") return null
if (part.state === "error" || part.state === "output-error") {
return (
<div className="my-1 flex items-center gap-2 text-xs text-red-400/80">
<GlobeIcon className="size-3.5 shrink-0" />
<span>Web search failed</span>
</div>
)
}
return (
<div className="my-1 flex items-center gap-2 text-xs text-white/50">
<Loader2 className="size-3.5 shrink-0 animate-spin text-emerald-400/80" />
<span>Searching the web…</span>
</div>
)
}
const meta =
TOOL_META[toolName] ??
(isWebSearchToolName(toolName)
@ -342,6 +516,27 @@ export function AgentMessage({
.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ")
const webSources = (() => {
const seen = new Set<string>()
const out: SourceUrlPart[] = []
for (const part of message.parts) {
if (part.type !== "source-url") continue
const source = part as SourceUrlPart
if (seen.has(source.url)) continue
seen.add(source.url)
out.push(source)
}
return out
})()
const hasAssistantText = message.parts.some(
(p) => p.type === "text" && (p as { text?: string }).text?.trim(),
)
const sourceKey = webSources.map((s) => s.url).join("|")
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed by stable source urls
const markdownComponents = useMemo(
() => makeMarkdownComponents(webSources),
[sourceKey],
)
const responseModelLabel = responseModel
? `${modelNames[responseModel].name} ${modelNames[responseModel].version}`
: null
@ -358,24 +553,10 @@ export function AgentMessage({
{message.parts.map((part, partIndex) => {
if (part.type === "source-url") {
if (
partIndex > 0 &&
message.parts[partIndex - 1]?.type === "source-url"
) {
return null
}
const sources: SourceUrlPart[] = []
for (let j = partIndex; j < message.parts.length; j++) {
const p = message.parts[j]
if (!p || p.type !== "source-url") break
sources.push(p as SourceUrlPart)
}
return (
<WebSourcesGroup
key={`${message.id}-web-sources-${partIndex}`}
sources={sources}
/>
)
return null
}
if (isWebSearchPart(part)) {
return null
}
if (part.type === "source-document") {
const doc = part as {
@ -400,12 +581,30 @@ export function AgentMessage({
)
}
if (part.type === "text") {
// Skip fragments mid-run — source-url citations split one answer into
// many text parts; rendering each separately tears markdown (lists etc.).
let prev = partIndex - 1
while (prev >= 0 && message.parts[prev]?.type === "source-url") {
prev--
}
if (prev >= 0 && message.parts[prev]?.type === "text") {
return null
}
let runText = ""
for (let j = partIndex; j < message.parts.length; j++) {
const p = message.parts[j]
if (p?.type === "text") runText += p.text
else if (p?.type === "source-url") continue
else break
}
return (
<div
key={`${message.id}-${partIndex}`}
className="text-sm text-white/90 chat-markdown-content"
>
<Streamdown>{part.text}</Streamdown>
<Streamdown components={markdownComponents}>
{runText}
</Streamdown>
</div>
)
}
@ -448,29 +647,41 @@ export function AgentMessage({
})}
</div>
</div>
<div className="flex min-h-7 items-center gap-2">
<MessageActions
messageId={message.id}
messageText={messageText}
isLastMessage={isLastAgentMessage}
isHovered={isHovered}
copiedMessageId={copiedMessageId}
messageFeedback={messageFeedback}
onCopy={onCopy}
onLike={onLike}
onDislike={onDislike}
/>
{responseModelLabel && (
<span
className={cn(
"text-[10px] leading-none text-white/25 transition-opacity duration-200",
isHovered ? "opacity-100" : "opacity-0",
)}
>
{responseModelLabel}
</span>
)}
</div>
{hasAssistantText && (
<div className="flex min-h-7 items-center gap-2">
<MessageActions
messageId={message.id}
messageText={messageText}
isLastMessage={isLastAgentMessage}
isHovered={isHovered}
copiedMessageId={copiedMessageId}
messageFeedback={messageFeedback}
onCopy={onCopy}
onLike={onLike}
onDislike={onDislike}
/>
{webSources.length > 0 && (
<div
className={cn(
"transition-opacity duration-200",
isHovered || isLastAgentMessage ? "opacity-100" : "opacity-0",
)}
>
<WebSourcesPill sources={webSources} />
</div>
)}
{responseModelLabel && (
<span
className={cn(
"text-[10px] leading-none text-white/25 transition-opacity duration-200",
isHovered ? "opacity-100" : "opacity-0",
)}
>
{responseModelLabel}
</span>
)}
</div>
)}
</div>
)
}

View file

@ -2,7 +2,7 @@
import { memo, useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Copy, Check, PencilIcon, PencilOffIcon } from "lucide-react"
import { Check, Copy, FileIcon, PencilIcon, PencilOffIcon } from "lucide-react"
import type { UIMessage } from "@ai-sdk/react"
import ChatModelSelector from "../model-selector"
import { ReasoningSelector } from "../reasoning-selector"
@ -12,6 +12,7 @@ import {
type ModelId,
type ReasoningEffort,
} from "@/lib/models"
import { formatAttachmentSize, getChatMessageAttachments } from "../attachments"
interface UserMessageProps {
message: UIMessage
@ -45,6 +46,7 @@ export const UserMessage = memo(function UserMessage({
.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ")
const attachments = getChatMessageAttachments(message.metadata)
const startEditing = () => {
setDraft(text)
@ -71,7 +73,31 @@ export const UserMessage = memo(function UserMessage({
}, [isEditing])
return (
<div className="flex flex-col items-end w-full">
<div className="flex w-full flex-col items-end">
{attachments.length > 0 ? (
<div className="mb-2 flex w-full max-w-[80%] flex-col items-end gap-1.5">
{attachments.map((attachment) => (
<div
key={attachment.id}
className="flex max-w-full min-w-0 items-center gap-2 rounded-lg border border-[#303949] bg-[#0D121A]/80 px-2.5 py-2 text-left shadow-[0_6px_18px_rgba(0,0,0,0.18)]"
>
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-[#111A27]">
<FileIcon className="size-3.5 text-[#8FC3FF]" />
</div>
<div className="min-w-0">
<div className="truncate text-xs font-medium text-white">
{attachment.filename}
</div>
<div className="truncate text-[11px] text-white/50">
{formatAttachmentSize(attachment.size)}
{" | "}
{attachment.saveToMemory ? "Saved" : "Chat only"}
</div>
</div>
</div>
))}
</div>
) : null}
<AnimatePresence mode="popLayout" initial={false}>
{isEditing ? (
<motion.div
@ -126,7 +152,9 @@ export const UserMessage = memo(function UserMessage({
transition={{ duration: 0.18, ease: "easeOut" }}
className="max-w-[80%] origin-top-right rounded-[12px] bg-[#1B1F24] p-3 px-[14px]"
>
<p className="text-sm text-white">{text}</p>
{text ? (
<p className="text-sm text-white whitespace-pre-wrap">{text}</p>
) : null}
</motion.div>
)}
</AnimatePresence>

View file

@ -1,6 +1,7 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { dmSansClassName } from "@/lib/fonts"
@ -44,8 +45,7 @@ export default function ChatModelSelector({
const selectedModel = selectedModelProp ?? internalModel
const currentModelData = modelNames[selectedModel]
const selectedModelLabel = `${currentModelData.name} ${currentModelData.version}`
const selectedItemClass =
"border border-[#267BF1]/35 bg-[#0A1A3A] text-white shadow-[inset_0_0_0_1px_rgba(75,160,250,0.08)]"
const selectedItemClass = "bg-white/[0.06] text-white"
const handleModelSelect = (modelId: ModelId) => {
if (onModelChange) {
@ -61,18 +61,23 @@ export default function ChatModelSelector({
<button
type="button"
className={cn(
"flex max-w-[min(100%,220px)] min-w-0 shrink cursor-pointer items-center gap-1.5 rounded-full border border-white/15 bg-black px-3 py-1.5 text-[13px] text-white transition-colors hover:border-white/30 hover:bg-white/5",
"flex max-w-[min(100%,220px)] min-w-0 shrink cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-[13px] text-white/80 transition-colors hover:bg-white/5",
dmSansClassName(),
)}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-label={`Model: ${selectedModelLabel}`}
>
<p className="min-w-0 truncate text-left text-white">
<p className="min-w-0 truncate text-left text-white/80">
{currentModelData.name}{" "}
<span className="text-white/55">{currentModelData.version}</span>
<span className="text-white/40">{currentModelData.version}</span>
</p>
<ChevronDownIcon className="size-3.5 shrink-0 text-white/55" />
<ChevronDownIcon
className={cn(
"size-3.5 shrink-0 text-white/40 transition-transform duration-200",
isOpen && "rotate-180",
)}
/>
</button>
) : (
<Button
@ -89,7 +94,12 @@ export default function ChatModelSelector({
{currentModelData.name}{" "}
<span className="text-white/55">{currentModelData.version}</span>
</p>
<ChevronDownIcon className="size-4 text-white/55" />
<ChevronDownIcon
className={cn(
"size-4 text-white/55 transition-transform duration-200",
isOpen && "rotate-180",
)}
/>
</Button>
)
@ -103,66 +113,82 @@ export default function ChatModelSelector({
>
{trigger}
{isOpen && (
<div
className={cn(
"isolate absolute left-0 z-[1000] w-[min(18rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/15 bg-black p-1 shadow-[0_18px_48px_rgba(0,0,0,0.55)]",
dropdownDirection === "up" ? "bottom-full mb-2" : "top-full mt-2",
)}
>
<div className="space-y-1">
{models.map((model) => {
const modelData = modelNames[model.id]
const isSelected = selectedModel === model.id
return (
<button
key={model.id}
type="button"
className={cn(
"flex w-full cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left transition-colors",
isSelected
? selectedItemClass
: "text-white hover:bg-white/10",
)}
onClick={() => handleModelSelect(model.id)}
onKeyDown={(e) =>
e.key === "Enter" && handleModelSelect(model.id)
}
>
<div className="min-w-0 flex-1">
<div
className={cn(
"truncate text-sm font-medium",
isSelected ? "text-white" : "text-white",
)}
>
{modelData.name}{" "}
<span
<AnimatePresence>
{isOpen && (
<motion.div
initial={{
opacity: 0,
scale: 0.96,
y: dropdownDirection === "up" ? 6 : -6,
}}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{
opacity: 0,
scale: 0.96,
y: dropdownDirection === "up" ? 6 : -6,
}}
transition={{ duration: 0.16, ease: [0.16, 1, 0.3, 1] }}
className={cn(
"isolate absolute left-0 z-[1000] w-[min(18rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/10 bg-[#0B0F16]/95 p-1.5 shadow-[0_12px_32px_rgba(0,0,0,0.4)] backdrop-blur-xl",
dropdownDirection === "up"
? "bottom-full mb-2 origin-bottom"
: "top-full mt-2 origin-top",
)}
>
<div className="space-y-1">
{models.map((model) => {
const modelData = modelNames[model.id]
const isSelected = selectedModel === model.id
return (
<button
key={model.id}
type="button"
className={cn(
"flex w-full cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors",
isSelected
? selectedItemClass
: "text-white hover:bg-white/[0.06]",
)}
onClick={() => handleModelSelect(model.id)}
onKeyDown={(e) =>
e.key === "Enter" && handleModelSelect(model.id)
}
>
<div className="min-w-0 flex-1">
<div
className={cn(
isSelected ? "text-[#8DBDFF]" : "text-white/55",
"truncate text-sm font-medium",
isSelected ? "text-white" : "text-white",
)}
>
{modelData.version}
</span>
{modelData.name}{" "}
<span
className={cn(
isSelected ? "text-[#8DBDFF]" : "text-white/40",
)}
>
{modelData.version}
</span>
</div>
<div
className={cn(
"mt-0.5 truncate text-xs",
isSelected ? "text-white/50" : "text-white/40",
)}
>
{model.description}
</div>
</div>
<div
className={cn(
"mt-0.5 truncate text-xs",
isSelected ? "text-white/60" : "text-white/45",
)}
>
{model.description}
</div>
</div>
{isSelected && (
<CheckIcon className="size-4 shrink-0 text-[#8DBDFF]" />
)}
</button>
)
})}
</div>
</div>
)}
{isSelected && (
<CheckIcon className="size-4 shrink-0 text-[#8DBDFF]" />
)}
</button>
)
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View file

@ -1,13 +1,7 @@
"use client"
import { useEffect, useRef, useState } from "react"
import {
BrainIcon,
CheckIcon,
ChevronDownIcon,
MoreHorizontalIcon,
ZapIcon,
} from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { BrainIcon, ZapIcon } from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { reasoningOptions, type ReasoningEffort } from "@/lib/models"
@ -20,124 +14,60 @@ interface ReasoningSelectorProps {
dropdownDirection?: "up" | "down"
}
const iconVariants = {
initial: (toThinking: boolean) => ({
y: toThinking ? "-110%" : "110%",
opacity: 0,
}),
animate: { y: "0%", opacity: 1 },
exit: (toThinking: boolean) => ({
y: toThinking ? "110%" : "-110%",
opacity: 0,
}),
}
export function ReasoningSelector({
value,
onChange,
variant = "pill",
disabled = false,
dropdownDirection = "up",
}: ReasoningSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const selected = reasoningOptions.find((option) => option.id === value)
const SelectedIcon = value === "thinking" ? BrainIcon : ZapIcon
const selectedLabel = selected?.label ?? "Reasoning"
const selectedItemClass =
"border border-[#267BF1]/35 bg-[#0A1A3A] text-white shadow-[inset_0_0_0_1px_rgba(75,160,250,0.08)]"
useEffect(() => {
if (!isOpen) return
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [isOpen])
const handleSelect = (next: ReasoningEffort) => {
onChange(next)
setIsOpen(false)
}
const toThinking = value === "thinking"
const Icon = toThinking ? BrainIcon : ZapIcon
const selectedLabel =
reasoningOptions.find((option) => option.id === value)?.label ?? "Reasoning"
const nextValue: ReasoningEffort = toThinking ? "instant" : "thinking"
return (
<div
ref={containerRef}
<button
type="button"
disabled={disabled}
onClick={() => onChange(nextValue)}
className={cn(
"relative flex shrink-0 items-center",
isOpen ? "z-[1000]" : "z-10",
"group flex shrink-0 cursor-pointer items-center rounded-md px-1.5 py-1 text-xs text-white/80 transition-colors hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-50",
dmSansClassName(),
)}
title={`Reasoning: ${selectedLabel} — click to switch`}
aria-label={`Reasoning: ${selectedLabel}. Click to switch.`}
>
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen((open) => !open)}
className={cn(
"cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-50",
variant === "icon"
? "rounded p-1.5 hover:bg-white/10"
: "flex size-9 items-center justify-center gap-1.5 rounded-full border border-white/15 bg-black px-0 py-1.5 text-xs text-white hover:border-white/30 hover:bg-white/5 sm:size-auto sm:justify-start sm:px-2.5",
dmSansClassName(),
)}
title={`Reasoning: ${selectedLabel}`}
aria-label={`Reasoning: ${selectedLabel}`}
aria-expanded={isOpen}
>
{variant === "icon" ? (
<MoreHorizontalIcon className="size-3.5 text-white/50 hover:text-white/80" />
) : (
<>
<SelectedIcon className="size-3.5 shrink-0 text-white/65" />
<span className="hidden text-white sm:inline">
{selected?.label}
</span>
<ChevronDownIcon className="hidden size-3.5 shrink-0 text-white/55 sm:block" />
</>
)}
</button>
{isOpen && (
<div
className={cn(
"isolate absolute left-0 z-[1000] w-[min(14rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/15 bg-black p-1 shadow-[0_18px_48px_rgba(0,0,0,0.55)]",
dropdownDirection === "up" ? "bottom-full mb-2" : "top-full mt-2",
)}
>
<div className="space-y-1">
{reasoningOptions.map((option) => {
const Icon = option.id === "thinking" ? BrainIcon : ZapIcon
const isSelected = option.id === value
return (
<button
key={option.id}
type="button"
onClick={() => handleSelect(option.id)}
className={cn(
"flex w-full cursor-pointer items-center gap-2.5 rounded-lg border border-transparent px-3 py-2.5 text-left transition-colors",
isSelected
? selectedItemClass
: "text-white hover:bg-white/10",
)}
>
<Icon
className={cn(
"size-4 shrink-0",
isSelected ? "text-[#8DBDFF]" : "text-white/65",
)}
/>
<div className="min-w-0 flex-1">
<div
className={cn(
"text-sm font-medium",
isSelected ? "text-white" : "text-white",
)}
>
{option.label}
</div>
</div>
{isSelected && (
<CheckIcon className="size-4 shrink-0 text-[#8DBDFF]" />
)}
</button>
)
})}
</div>
</div>
)}
</div>
<span className="relative flex size-3.5 shrink-0 items-center justify-center overflow-hidden">
<AnimatePresence initial={false} mode="popLayout" custom={toThinking}>
<motion.span
key={value}
custom={toThinking}
variants={iconVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="absolute inset-0 flex items-center justify-center"
>
<Icon className="size-3.5 text-white/45 transition-colors group-hover:text-white/70" />
</motion.span>
</AnimatePresence>
</span>
<span className="ml-0 max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-200 group-hover:ml-1 group-hover:max-w-[88px] group-hover:opacity-100">
{selectedLabel}
</span>
</button>
)
}

View file

@ -40,6 +40,7 @@ import {
type Profession,
} from "@/hooks/use-personalization"
import { normalizePluginClientId } from "@/lib/plugin-catalog"
import { detectPluginSpace } from "@/lib/plugin-space"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -443,6 +444,23 @@ function hasClaudeCodeContainer(document: DocumentWithMemories): boolean {
)
}
function getPluginClientFromSpace(
document: DocumentWithMemories,
): string | null {
const containerTags =
(document as { containerTags?: string[] }).containerTags ?? []
const memorySpaceTags = (document.memoryEntries ?? [])
.map((entry) => entry.spaceContainerTag)
.filter((tag): tag is string => !!tag)
for (const tag of [...containerTags, ...memorySpaceTags]) {
const plugin = detectPluginSpace(tag)
if (plugin) return normalizePluginClientId(plugin.pluginId)
}
return null
}
function getPluginClientFromDocument(
document: DocumentWithMemories,
): string | null {
@ -462,6 +480,9 @@ function getPluginClientFromDocument(
if (hasClaudeCodeContainer(document)) return "claude_code"
const pluginClientFromSpace = getPluginClientFromSpace(document)
if (pluginClientFromSpace) return pluginClientFromSpace
const content = getDocumentText(document)
const title = document.title ?? ""
if (
@ -689,6 +710,26 @@ function parseToolUsage(
}
// Attach latest document info to plugin items where available from MCP documents
for (const [pluginId, docInfo] of latestDocPerPlugin) {
const itemKey = `plugin_${pluginId}`
if (toolMap.has(itemKey)) continue
const catalog = PLUGIN_DISPLAY_CATALOG[pluginId]
toolMap.set(itemKey, {
id: itemKey,
name: catalog?.name ?? docInfo.title,
type: "Plugin",
icon: catalog?.icon ?? null,
lastUsedAt: docInfo.at,
hasBeenUsed: true,
connectedAt: null,
lastDocumentTitle: null,
lastDocumentId: null,
lastDocumentPreview: null,
lastDocument: null,
})
}
for (const [, item] of toolMap) {
if (item.type === "Plugin" && !item.lastDocumentTitle) {
const pluginId = item.id.replace(/^plugin_/, "")

View file

@ -11,36 +11,37 @@ type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
function getFileTypeInfo(document: DocumentWithMemories): {
fileName?: string
extension: string
color?: string
} {
const type = document.type?.toLowerCase()
const mimeType = document.metadata?.mimeType as string | undefined
const mimeType = (document.metadata?.mimeType ??
document.metadata?.sm_internal_fileType) as string | undefined
const fileName = (document.metadata?.sm_internal_fileName ??
document.metadata?.fileName) as string | undefined
const nameExt = fileName?.includes(".")
? fileName.slice(fileName.lastIndexOf(".")).toLowerCase()
: undefined
if (mimeType) {
if (mimeType === "application/pdf") {
return { extension: ".pdf", color: "#FF7673" }
}
if (mimeType.startsWith("image/")) {
const ext = mimeType.split("/")[1] || "jpg"
return { extension: `.${ext}` }
}
if (mimeType.startsWith("video/")) {
const ext = mimeType.split("/")[1] || "mp4"
return { extension: `.${ext}` }
}
if (nameExt === ".pdf" || mimeType === "application/pdf" || type === "pdf") {
return { fileName, extension: ".pdf", color: "#FF7673" }
}
switch (type) {
case "pdf":
return { extension: ".pdf", color: "#FF7673" }
case "image":
return { extension: ".jpg" }
case "video":
return { extension: ".mp4" }
default:
return { extension: ".file" }
if (mimeType?.startsWith("image/") || type === "image") {
const ext = nameExt || `.${mimeType?.split("/")[1] || "jpg"}`
return { fileName, extension: ext }
}
if (mimeType?.startsWith("video/") || type === "video") {
const ext = nameExt || `.${mimeType?.split("/")[1] || "mp4"}`
return { fileName, extension: ext }
}
if (nameExt === ".html" || nameExt === ".htm" || mimeType === "text/html") {
return { fileName, extension: nameExt || ".html", color: "#FF8A4C" }
}
if (nameExt) {
return { fileName, extension: nameExt }
}
return { fileName, extension: ".file" }
}
export const FilePreview = memo(function FilePreview({
@ -50,10 +51,11 @@ export const FilePreview = memo(function FilePreview({
}) {
const [imageError, setImageError] = useState(false)
const [retryKey, setRetryKey] = useState(0)
const { extension, color } = getFileTypeInfo(document)
const { fileName, extension, color } = getFileTypeInfo(document)
const type = document.type?.toLowerCase()
const mimeType = document.metadata?.mimeType as string | undefined
const mimeType = (document.metadata?.mimeType ??
document.metadata?.sm_internal_fileType) as string | undefined
const isImage =
(mimeType?.startsWith("image/") || type === "image") &&
document.url &&
@ -102,17 +104,23 @@ export const FilePreview = memo(function FilePreview({
</div>
) : (
<div className="p-3">
<div className="flex items-center gap-1 mb-2">
<div className="flex items-center gap-1 mb-2 min-w-0">
<DocumentIcon
type={document.type}
url={document.url}
className="size-4"
fileName={fileName}
mimeType={mimeType}
className="size-4 shrink-0"
/>
<p
className={cn(dmSansClassName(), "text-[11px] font-semibold")}
className={cn(
dmSansClassName(),
"text-[11px] font-semibold truncate",
)}
style={{ color: color }}
title={fileName}
>
{extension}
{fileName || extension}
</p>
</div>
{document.content && (

View file

@ -28,15 +28,7 @@ export function PluginPreview({ parsed }: { parsed: ParsedPluginDocument }) {
)}
{parsed.pluginLabel}
</span>
<p className="text-[11px] text-[#737373] truncate">
{parsed.formatLabel}
</p>
</div>
{parsed.identifierValue && (
<p className="text-[10px] text-[#737373] truncate max-w-[45%]">
{parsed.identifierValue}
</p>
)}
</div>
<div className="space-y-[6px]">
<p

View file

@ -15,7 +15,7 @@ import {
NotionDoc,
PDF,
} from "@ui/assets/icons"
import { Globe, FileText, Image } from "lucide-react"
import { Globe, FileText, FileCode, Image } from "lucide-react"
import { cn } from "@lib/utils"
function MCPIcon({ className }: { className?: string }) {
@ -144,13 +144,60 @@ export interface DocumentIconProps {
type: string | null | undefined
source?: string | null
url?: string | null
fileName?: string | null
mimeType?: string | null
className?: string
}
function fileExtensionIcon(
ext: string | undefined,
mimeType: string | null | undefined,
iconClassName: string,
): React.ReactNode | null {
if (ext === ".html" || ext === ".htm" || mimeType === "text/html") {
return <FileCode className={iconClassName} style={{ color: "#FF8A4C" }} />
}
switch (ext) {
case ".pdf":
return <PDF className={iconClassName} />
case ".doc":
case ".docx":
return (
<span style={{ color: BRAND_COLORS.word }}>
<MicrosoftWord className={iconClassName} />
</span>
)
case ".xls":
case ".xlsx":
case ".csv":
return (
<span style={{ color: BRAND_COLORS.excel }}>
<MicrosoftExcel className={iconClassName} />
</span>
)
case ".ppt":
case ".pptx":
return (
<span style={{ color: BRAND_COLORS.powerpoint }}>
<MicrosoftPowerpoint className={iconClassName} />
</span>
)
case ".md":
case ".mdx":
case ".txt":
case ".json":
return <TextDocumentIcon className={iconClassName} />
default:
return null
}
}
export function DocumentIcon({
type,
source,
url,
fileName,
mimeType,
className,
}: DocumentIconProps) {
const iconClassName = cn("size-4", className)
@ -163,6 +210,22 @@ export function DocumentIcon({
return <YouTubeIcon className={iconClassName} />
}
// Uploaded files get a type icon, never the URL favicon of their storage host
if (fileName || mimeType) {
const lower = fileName?.toLowerCase()
const ext = lower?.includes(".")
? lower.slice(lower.lastIndexOf("."))
: undefined
const fileIcon = fileExtensionIcon(ext, mimeType, iconClassName)
if (fileIcon) return fileIcon
if (mimeType?.startsWith("image/")) {
return <Image className={iconClassName} style={{ color: "#FAFAFA" }} />
}
if (!type || type === "unknown" || type === "text") {
return <FileText className={iconClassName} style={{ color: "#FAFAFA" }} />
}
}
if (
type === "webpage" ||
type === "url" ||

View file

@ -1,31 +1,60 @@
"use client"
import { useCallback, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { cn } from "@lib/utils"
import { getCachedFileBlob } from "@/lib/file-cache"
interface ImagePreviewProps {
url: string
title?: string | null
documentId?: string | null
}
export function ImagePreview({ url, title }: ImagePreviewProps) {
export function ImagePreview({ url, title, documentId }: ImagePreviewProps) {
const [imageError, setImageError] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [retryKey, setRetryKey] = useState(0)
const [activeSrc, setActiveSrc] = useState(url)
const objectUrlRef = useRef<string | null>(null)
useEffect(() => {
return () => {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = null
}
}
}, [])
// On first failure, wait briefly then force a re-render with a new key to
// retry the fetch (covers transient R2 timing issues).
// On second failure, give up and show the error state.
const handleImageError = useCallback(() => {
if (retryKey === 0) {
setTimeout(() => setRetryKey(1), 500)
return
}
if (retryKey === 1 && documentId) {
getCachedFileBlob(documentId).then((blob) => {
if (blob) {
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
}
const objUrl = URL.createObjectURL(blob)
objectUrlRef.current = objUrl
setActiveSrc(objUrl)
setRetryKey(2)
} else {
setImageError(true)
setIsLoading(false)
}
})
return
}
setImageError(true)
setIsLoading(false)
}, [retryKey])
}, [retryKey, documentId])
if (imageError || !url) {
if (imageError || !activeSrc) {
return (
<div className="flex items-center justify-center h-full text-[#737373]">
<p>Failed to load image</p>
@ -43,7 +72,7 @@ export function ImagePreview({ url, title }: ImagePreviewProps) {
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: `url(${url})`,
backgroundImage: `url(${activeSrc})`,
filter: "blur(100px)",
transform: "scale(1.1)",
opacity: isLoading ? 0.5 : 1,
@ -52,7 +81,7 @@ export function ImagePreview({ url, title }: ImagePreviewProps) {
<div className="absolute inset-0 bg-black/30" />
<img
key={retryKey}
src={url}
src={activeSrc}
alt={title || "Image preview"}
className={cn(
"relative max-w-full max-h-full size-auto object-contain z-10",

View file

@ -88,7 +88,13 @@ export function DocumentContent({
switch (contentType) {
case "image":
return <ImagePreview url={document.url ?? ""} title={document.title} />
return (
<ImagePreview
url={document.url ?? ""}
title={document.title}
documentId={document.id}
/>
)
case "tweet":
return (

View file

@ -1,23 +1,55 @@
"use client"
import { Document, Page, pdfjs } from "react-pdf"
import { useCallback, useMemo, useState } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import "react-pdf/dist/Page/AnnotationLayer.css"
import "react-pdf/dist/Page/TextLayer.css"
import { getCachedFileBlob } from "@/lib/file-cache"
// Configure PDF.js worker to use local package
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString()
type FileSource = string | { url: string; withCredentials: boolean } | null
interface PdfViewerProps {
url: string | null | undefined
documentId?: string | null
}
export function PdfViewer({ url, documentId }: PdfViewerProps) {
const fileSource = useMemo(() => {
const [cachedUrl, setCachedUrl] = useState<string | null>(null)
const [cacheChecked, setCacheChecked] = useState(false)
const objectUrlRef = useRef<string | null>(null)
useEffect(() => {
let revoked = false
if (!documentId) {
setCacheChecked(true)
return
}
getCachedFileBlob(documentId).then((blob) => {
if (revoked) return
if (blob) {
const objUrl = URL.createObjectURL(blob)
objectUrlRef.current = objUrl
setCachedUrl(objUrl)
}
setCacheChecked(true)
})
return () => {
revoked = true
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current)
objectUrlRef.current = null
}
}
}, [documentId])
const remoteFileSource: FileSource = useMemo(() => {
if (!url) return null
try {
if (new URL(url).hostname === "www.googleapis.com" && documentId) {
@ -32,12 +64,34 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
return url
}, [url, documentId])
const backendProxySource: FileSource = useMemo(() => {
if (!documentId) return null
const base =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
return { url: `${base}/v3/file-proxy/${documentId}`, withCredentials: true }
}, [documentId])
const [numPages, setNumPages] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [retryKey, setRetryKey] = useState(0)
const [failedSources, setFailedSources] = useState(0)
if (!url) {
const fileSource = useMemo((): FileSource => {
if (cachedUrl) return cachedUrl
if (failedSources === 0) return remoteFileSource
if (failedSources === 1 && backendProxySource) return backendProxySource
return null
}, [cachedUrl, failedSources, remoteFileSource, backendProxySource])
if (!cacheChecked) {
return (
<div className="flex items-center justify-center h-full text-gray-400">
Loading PDF…
</div>
)
}
if (!url && !cachedUrl) {
return (
<div className="flex items-center justify-center h-full text-gray-400">
No PDF URL provided
@ -51,24 +105,26 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
setError(null)
}
// On first failure, wait briefly then force a re-mount of the Document
// component to retry (covers transient R2 timing issues).
// On second failure, give up and show the error state.
const onDocumentLoadError = useCallback(
(err: Error) => {
if (retryKey === 0) {
setTimeout(() => {
setRetryKey(1)
setLoading(true)
setError(null)
}, 500)
return
}
function onDocumentLoadError(err: Error) {
if (cachedUrl) {
setError(err.message || "Failed to load PDF")
setLoading(false)
},
[retryKey],
)
return
}
const nextFailed = failedSources + 1
const hasMoreSources =
(nextFailed === 1 && backendProxySource !== null) || nextFailed < 1
if (hasMoreSources) {
setFailedSources(nextFailed)
setLoading(true)
setError(null)
} else {
setError(err.message || "Failed to load PDF")
setLoading(false)
}
}
return (
<div className="flex flex-col size-full overflow-hidden scrollbar-thin">
@ -82,35 +138,33 @@ export function PdfViewer({ url, documentId }: PdfViewerProps) {
Error: {error}
</div>
)}
<div className="flex-1 overflow-auto w-full">
<Document
key={retryKey}
file={
fileSource ||
"https://corsproxy.io/?" +
encodeURIComponent("http://www.pdf995.com/samples/pdf.pdf")
}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
loading={null}
className="w-full"
>
{numPages && (
<div className="flex flex-col items-center gap-4 py-4 w-full">
{Array.from(new Array(numPages), (_, index) => (
<Page
key={`page_${index + 1}`}
pageNumber={index + 1}
renderTextLayer
renderAnnotationLayer
className="shadow-lg"
width={630}
/>
))}
</div>
)}
</Document>
</div>
{fileSource && (
<div className="flex-1 overflow-auto w-full">
<Document
key={`${failedSources}-${cachedUrl ? "cache" : "remote"}`}
file={fileSource}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
loading={null}
className="w-full"
>
{numPages && (
<div className="flex flex-col items-center gap-4 py-4 w-full">
{Array.from(new Array(numPages), (_, index) => (
<Page
key={`page_${index + 1}`}
pageNumber={index + 1}
renderTextLayer
renderAnnotationLayer
className="shadow-lg"
width={630}
/>
))}
</div>
)}
</Document>
</div>
)}
</div>
)
}

View file

@ -47,24 +47,6 @@ function PluginHeader({ parsed }: { parsed: ParsedPluginDocument }) {
>
{parsed.pluginLabel}
</span>
<span
className={cn(
dmSansClassName(),
"inline-flex items-center rounded-full border border-[#1F242C] bg-[#0F1318] px-2.5 py-1 text-[11px] font-medium text-[#B7BDC7]",
)}
>
{parsed.formatLabel}
</span>
{parsed.identifierLabel && parsed.identifierValue && (
<span
className={cn(
dmSansClassName(),
"inline-flex items-center rounded-full border border-[#1F242C] bg-[#0F1318] px-2.5 py-1 text-[11px] font-medium text-[#8E97A3]",
)}
>
{parsed.identifierLabel}: {parsed.identifierValue}
</span>
)}
</div>
<p
className={cn(

View file

@ -27,7 +27,7 @@ import type { UseMutationResult } from "@tanstack/react-query"
import { toast } from "sonner"
import { useIsMobile } from "@hooks/use-mobile"
import { parsePluginDocument } from "@/lib/plugin-document"
import { PluginDetails } from "./plugin-details"
import { useFullDocumentContent } from "@/hooks/use-full-document"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -199,16 +199,25 @@ export function DocumentModal({
const isMobile = useIsMobile()
const { updateMutation, deleteMutation } = useDocumentMutations({ onClose })
const fullContent = useFullDocumentContent(_document?.id, isOpen)
const effectiveDocument = useMemo(
() =>
_document && fullContent !== null
? { ..._document, content: fullContent }
: _document,
[_document, fullContent],
)
const { initialEditorContent, initialEditorString } = useMemo(() => {
const content = _document?.content as string | null | undefined
const content = effectiveDocument?.content as string | null | undefined
return {
initialEditorContent: content ?? undefined,
initialEditorString: content ?? "",
}
}, [_document?.content])
}, [effectiveDocument?.content])
const pluginDocument = useMemo(
() => parsePluginDocument(_document),
[_document],
() => parsePluginDocument(effectiveDocument),
[effectiveDocument],
)
const [draftContentString, setDraftContentString] =
@ -270,13 +279,8 @@ export function DocumentModal({
],
)
const hasPluginInsights =
pluginDocument &&
pluginDocument.kind !== "claude-code-doc" &&
pluginDocument.kind !== "openclaw-session"
const hasDocumentInsights = Boolean(
hasPluginInsights ||
_document?.summary ||
_document?.summary ||
pluginDocument?.summary ||
(_document?.memoryEntries && _document.memoryEntries.length > 0),
)
@ -287,7 +291,7 @@ export function DocumentModal({
className="bg-[#14161A] rounded-[14px] overflow-hidden flex min-h-0 flex-1 flex-col shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)] relative"
>
<DocumentContent
document={_document}
document={effectiveDocument}
textEditorProps={textEditorProps}
pluginDocument={pluginDocument}
/>
@ -303,7 +307,6 @@ export function DocumentModal({
dmSansClassName(),
)}
>
{hasPluginInsights && <PluginDetails parsed={pluginDocument} />}
{_document && (_document.summary || pluginDocument?.summary) && (
<DocumentSummary
memoryEntries={_document.memoryEntries}

View file

@ -1,83 +0,0 @@
import Image from "next/image"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import type { ParsedPluginDocument } from "@/lib/plugin-document"
function DetailPill({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-[12px] border border-[#1E232B] bg-[#0F1318] px-3 py-2">
<p
className={cn(
dmSansClassName(),
"text-[10px] uppercase tracking-[0.08em] text-[#737373]",
)}
>
{label}
</p>
<p className="mt-1 text-[13px] text-[#F3F4F6] break-all">{value}</p>
</div>
)
}
export function PluginDetails({ parsed }: { parsed: ParsedPluginDocument }) {
return (
<div className="bg-[#14161A] p-3 rounded-[14px] space-y-3 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]">
<div className="flex items-center justify-between gap-2">
<p className="text-[16px] font-semibold text-[#FAFAFA] leading-[125%]">
Details
</p>
<span
className={cn(
dmSansClassName(),
"inline-flex items-center gap-1.5 text-[12px] font-medium text-[#FAFAFA]",
)}
>
{parsed.pluginIconSrc && (
<Image
src={parsed.pluginIconSrc}
alt=""
width={16}
height={16}
className="rounded-[3px]"
aria-hidden
/>
)}
{parsed.pluginLabel}
</span>
</div>
<div className="grid grid-cols-1 gap-2">
<DetailPill label="Format" value={parsed.formatLabel} />
{parsed.identifierLabel && parsed.identifierValue && (
<DetailPill
label={parsed.identifierLabel}
value={parsed.identifierValue}
/>
)}
{parsed.clientLabel && parsed.clientValue && (
<DetailPill label={parsed.clientLabel} value={parsed.clientValue} />
)}
</div>
{parsed.artifacts.length > 0 && (
<div className="space-y-2">
<p
className={cn(
dmSansClassName(),
"text-[11px] font-medium uppercase tracking-[0.08em] text-[#737373]",
)}
>
Outputs
</p>
<div className="space-y-2">
{parsed.artifacts.map((artifact, index) => (
<DetailPill
key={`${artifact.label}-${artifact.value}-${index}`}
label={artifact.label}
value={artifact.value}
/>
))}
</div>
</div>
)}
</div>
)
}

View file

@ -14,6 +14,7 @@ import { useIsMobile } from "@hooks/use-mobile"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { SearchIcon, Settings, Home, Plus, Code2, Loader2 } from "lucide-react"
import { DocumentIcon } from "@/components/document-icon"
import { useSettingsModal } from "@/components/settings/settings-modal"
import { $fetch } from "@lib/api"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
@ -52,6 +53,7 @@ export function DocumentsCommandPalette({
}: DocumentsCommandPaletteProps) {
const isMobile = useIsMobile()
const router = useRouter()
const { openSettings } = useSettingsModal()
const queryClient = useQueryClient()
const [search, setSearch] = useState("")
const [selectedIndex, setSelectedIndex] = useState(0)
@ -86,7 +88,7 @@ export function DocumentsCommandPalette({
id: "settings",
label: "Go to Settings",
icon: <Settings className="size-4 text-[#737373]" />,
action: () => close(() => router.push("/settings")),
action: () => close(() => openSettings()),
},
...(onAddMemory
? [

View file

@ -0,0 +1,206 @@
"use client"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Loader2, XIcon } from "lucide-react"
import { useEffect, useState } from "react"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { useSpaceContext, useUpdateSpace } from "@/hooks/use-space-context"
import { CONTEXT_PRESETS } from "@/components/add-space-modal"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
const INPUT_SHADOW =
"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 EditSpaceModalProps = {
containerTag: string
currentName: string
currentEmoji?: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function EditSpaceModal({
containerTag,
currentName,
currentEmoji = "📁",
open,
onOpenChange,
}: EditSpaceModalProps) {
const isDefault = !containerTag || containerTag === DEFAULT_PROJECT_ID
const { data, isLoading } = useSpaceContext(containerTag, open)
const update = useUpdateSpace()
const [name, setName] = useState(currentName)
const [context, setContext] = useState("")
useEffect(() => {
if (!open) return
setName(data?.name ?? currentName ?? "")
setContext(data?.entityContext ?? "")
}, [open, data?.name, data?.entityContext, currentName])
const handleSave = () => {
update.mutate(
{
containerTag,
name: isDefault ? undefined : name.trim() || undefined,
entityContext: context.trim() ? context.trim() : null,
},
{ onSuccess: () => onOpenChange(false) },
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
"w-[90%]! max-w-[500px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-4 rounded-[22px]",
dmSansClassName(),
)}
style={{
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",
}}
showCloseButton={false}
>
<div className="flex flex-col gap-4">
<div className="flex justify-between items-start gap-4">
<div className="pl-1 space-y-1 flex-1">
<DialogTitle
className={cn(
"font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Edit space
</DialogTitle>
<p
className={cn(
dmSansClassName(),
"text-[#737373] text-[13px] leading-snug",
)}
>
Rename this space and tell Nova what to remember in it.
</p>
</div>
<DialogPrimitive.Close
className="bg-[#0D121A] size-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
data-slot="dialog-close"
>
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex gap-[6px] items-center">
<div
className="bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex size-[45px] shrink-0 items-center justify-center rounded-[12px] p-3"
style={{ boxShadow: INPUT_SHADOW }}
aria-hidden
>
<span className="text-xl">{currentEmoji}</span>
</div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isDefault || update.isPending}
placeholder="Space name"
maxLength={120}
className={cn(
"flex-1 bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-4 py-3 rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(115,115,115,0.3)] disabled:opacity-60",
dmSansClassName(),
)}
style={{ boxShadow: INPUT_SHADOW }}
/>
</div>
{isDefault && (
<span className="pl-1 text-[11px] text-[#525966]">
The default space can't be renamed.
</span>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5 pl-1">
<span
className={cn(
dmSans125ClassName(),
"text-[12px] font-medium text-[#A3A3A3]",
)}
>
What to remember
</span>
<span className="ml-auto text-[11px] text-[#525966]">
Optional
</span>
</div>
<textarea
value={context}
onChange={(e) => setContext(e.target.value)}
disabled={isLoading || update.isPending}
placeholder="Tell Nova what matters here — it shapes which memories get extracted."
maxLength={750}
className={cn(
dmSansClassName(),
"min-h-[56px] w-full resize-y rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] px-4 py-3 text-[14px] leading-relaxed text-[#fafafa] placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(115,115,115,0.3)] disabled:opacity-60",
)}
style={{ boxShadow: INPUT_SHADOW }}
/>
<div className="flex flex-wrap items-center gap-1.5 pl-1">
<span className="text-[11px] text-[#737373]">Try a preset</span>
{CONTEXT_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => setContext(preset.text)}
className={cn(
dmSansClassName(),
"cursor-pointer rounded-full bg-[#1E232B] px-2.5 py-1 text-[11px] font-medium text-[#C8C8C8] transition-colors hover:bg-[#262c36] hover:text-white",
)}
>
{preset.label}
</button>
))}
</div>
</div>
<div className="flex items-center justify-end gap-[22px]">
<button
type="button"
onClick={() => onOpenChange(false)}
disabled={update.isPending}
className={cn(
"text-[#737373] font-medium text-[14px] cursor-pointer transition-colors hover:text-[#999]",
dmSansClassName(),
)}
>
Cancel
</button>
<Button
variant="insideOut"
onClick={handleSave}
disabled={update.isPending}
className="px-4 py-[10px] rounded-full"
>
{update.isPending ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Saving…
</>
) : (
"Save"
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -22,11 +22,14 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
const searchParams = useSearchParams()
const { session, organizations, isRestoring } = useAuth()
const isMcpPublicPage = searchParams.get("view") === "mcp"
const isPublicAppPage =
pathname === "/" &&
["integrations", "mcp"].includes(searchParams.get("view") ?? "")
const isGuestPublicAppPage = isPublicAppPage && !session
const isOnboarding = pathname.startsWith("/onboarding")
useEffect(() => {
if (isMcpPublicPage) return
if (isGuestPublicAppPage) return
if (isRestoring) return
if (!session) {
router.replace(
@ -44,11 +47,11 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
isRestoring,
isOnboarding,
router,
isMcpPublicPage,
isGuestPublicAppPage,
])
const showLoading =
!isMcpPublicPage &&
!isGuestPublicAppPage &&
(isRestoring ||
(!session && !isRestoring) ||
(session && organizations === null) ||

View file

@ -35,15 +35,14 @@ export const GraphLayoutView = memo(function GraphLayoutView({
}, [setIsShareModalOpen])
return (
<div className="relative h-full min-h-[calc(100dvh-8.5rem)] w-full md:min-h-0">
<div className="relative flex h-full min-h-0 flex-1 flex-col w-full">
{/* Full-width graph */}
<div className="absolute inset-0">
<div className="relative min-h-0 flex-1">
<MemoryGraph
containerTags={effectiveContainerTags}
variant="consumer"
highlightDocumentIds={allHighlightDocumentIds}
highlightsVisible
maxNodes={undefined}
canvasRef={canvasRef}
onOpenDocument={onOpenDocument}
/>

View file

@ -30,7 +30,6 @@ import {
} from "@ui/components/dropdown-menu"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
import { useProject } from "@/stores"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { SpaceSelector } from "./space-selector"
import { useIsMobile } from "@hooks/use-mobile"
@ -44,6 +43,7 @@ import { useCustomer } from "autumn-js/react"
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"
interface HeaderProps {
onAddMemory?: () => void
@ -74,7 +74,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
(orgSummaries ?? []).map((s) => [s.orgId, s.plan] as const),
)
const { selectedProjects, setSelectedProjects } = useProject()
const router = useRouter()
const { openSettings } = useSettingsModal()
const isMobile = useIsMobile()
const [feedbackOpen, setFeedbackOpen] = useQueryState(
"feedback",
@ -207,6 +207,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
selectedProjects={selectedProjects}
onValueChange={setSelectedProjects}
enableDelete
enableEdit
/>
</>
)}
@ -421,7 +422,7 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
Feedback
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push("/settings")}
onClick={() => openSettings()}
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"
>
<Settings className="size-4 text-[#737373]" />
@ -504,7 +505,41 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
)
}
export function PublicHeader() {
export function PublicHeader({
variant = "default",
}: {
variant?: "default" | "integrations"
}) {
if (variant === "integrations") {
return (
<div className="relative z-10 flex shrink-0 items-center justify-between gap-2 p-2.5 md:p-3">
<Link
href="/?view=integrations"
className="flex items-center gap-2 transition-opacity hover:opacity-90"
>
<Logo className="h-6 md:h-7" />
<p className="text-base leading-none font-medium text-white/90 sm:text-lg">
supermemory
</p>
</Link>
<Link href="/login?redirect=%2F%3Fview%3Dintegrations">
<button
type="button"
className={cn(
"flex h-10 cursor-pointer items-center gap-2 rounded-full px-4 text-[14px] font-medium text-white transition-opacity hover:opacity-95 sm:px-5 sm:text-[15px]",
"bg-[linear-gradient(100deg,#426BFF_0%,#2D1CFF_100%)] shadow-[inset_0_1px_0_rgba(255,255,255,0.16)]",
dmSansClassName(),
)}
>
<Logo className="h-4 w-5 shrink-0" />
Log in with Supermemory
</button>
</Link>
</div>
)
}
return (
<div className="relative z-10 flex shrink-0 items-center justify-between gap-2 p-2.5 md:p-3">
<Link

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,15 @@ import { hasActivePlan } from "@lib/queries"
import { useCustomer } from "autumn-js/react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { BookOpen, Check, ChevronDown, Loader, X, Zap } from "lucide-react"
import {
BookOpen,
Check,
ChevronDown,
ExternalLink,
Loader,
X,
Zap,
} from "lucide-react"
import Image from "next/image"
import { type ReactNode, useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
@ -17,6 +25,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import {
PLUGIN_CATALOG,
isFreeTierPlugin,
normalizePluginClientId,
type InstallStep,
type PluginInfo,
} from "@/lib/plugin-catalog"
@ -27,9 +36,20 @@ interface ConnectedPlugin {
keyId: string
pluginId: string
createdAt: string
lastRequest?: string | null
keyStart?: string | null
}
type ListedApiKey = {
id: string
name?: string | null
createdAt: string
enabled?: boolean
lastRequest: string | null
metadata: string | Record<string, unknown> | null
start?: string | null
}
function SectionHeader({ children }: { children: ReactNode }) {
return (
<p
@ -121,13 +141,77 @@ function DisconnectButton({ onConfirm }: { onConfirm: () => void }) {
)
}
function ConnectedPill({
function toDate(value: string | Date | null | undefined): Date | null {
if (!value) return null
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function formatRelativeTime(value: string | Date | null | undefined): string {
const date = toDate(value)
if (!date) return "Never"
const absMs = Math.abs(Date.now() - date.getTime())
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (absMs < minute) return "Just now"
if (absMs < hour) return `${Math.round(absMs / minute)}m ago`
if (absMs < day) return `${Math.round(absMs / hour)}h ago`
return `${Math.round(absMs / day)}d ago`
}
function formatDate(value: string | Date | null | undefined): string {
const date = toDate(value)
if (!date) return "Unknown"
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
}
function maskKey(start: string | null | undefined): string {
if (!start) return "sm_********"
return `${start}********`
}
function keyPrefix(key: ListedApiKey): string | null {
return key.start ?? (key.name?.startsWith("sm_") ? key.name : null)
}
function DetailStat({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0">
<p
className={cn(
dmSans125ClassName(),
"text-[10px] font-medium uppercase tracking-wide text-[#737373]",
)}
>
{label}
</p>
<p
className={cn(
dmSans125ClassName(),
"mt-1 truncate text-[13px] font-medium text-[#FAFAFA]",
)}
>
{value}
</p>
</div>
)
}
function ActivePill({
plugin,
connectedKeys,
onRevoke,
}: {
plugin: PluginInfo
connectedKeys: ConnectedPlugin[]
onRevoke: (keyId: string) => void
}) {
const primaryKey = connectedKeys[0]
return (
<Popover>
<PopoverTrigger asChild>
@ -140,7 +224,7 @@ function ConnectedPill({
)}
>
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
Connected
ACTIVE
<ChevronDown className="size-3 text-[#737373]" />
</button>
</PopoverTrigger>
@ -148,13 +232,67 @@ function ConnectedPill({
align="end"
className={cn(
dmSans125ClassName(),
"w-[260px] rounded-xl border border-white/10 bg-[#1B1F24] p-2 text-[#FAFAFA]",
"w-[min(380px,calc(100vw-32px))] rounded-xl border border-white/10 bg-[#1B1F24] p-4 text-[#FAFAFA]",
)}
>
<div className="flex items-center gap-3">
<PluginIconBox src={plugin.icon} alt={plugin.name} />
<div className="min-w-0 flex-1">
<p
className={cn(
dmSans125ClassName(),
"truncate text-[15px] font-semibold text-[#FAFAFA]",
)}
>
{plugin.name}
</p>
<p className="mt-0.5 text-[12px] text-[#A1A1AA]">Active</p>
</div>
<span className="size-3 rounded-full bg-[#37D67A]" />
</div>
<div className="mt-4 grid grid-cols-1 gap-3 border-y border-white/[0.08] py-4 sm:grid-cols-3">
<DetailStat label="API key" value={maskKey(primaryKey?.keyStart)} />
<DetailStat
label="Last active"
value={formatRelativeTime(primaryKey?.lastRequest)}
/>
<DetailStat
label="Connected"
value={formatRelativeTime(primaryKey?.createdAt)}
/>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
{plugin.docsUrl && (
<a
href={plugin.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-[13px] text-[#4BA0FA] transition-colors hover:text-[#86C5FF]"
>
Docs <ExternalLink className="size-3" />
</a>
)}
{plugin.githubUrl && (
<a
href={plugin.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-[13px] text-[#4BA0FA] transition-colors hover:text-[#86C5FF]"
>
GitHub <ExternalLink className="size-3" />
</a>
)}
<span className="ml-auto text-[11px] text-[#737373]">
Connected {formatDate(primaryKey?.createdAt)}
</span>
</div>
<p
className={cn(
dmSans125ClassName(),
"px-2 pb-1.5 pt-1 text-[11px] font-medium uppercase tracking-wide text-[#737373]",
"px-2 pb-1.5 pt-4 text-[11px] font-medium uppercase tracking-wide text-[#737373]",
)}
>
{connectedKeys.length > 1
@ -188,22 +326,26 @@ function PluginRow({
plugin,
pluginId,
connectedKeys,
needsSetup,
needsProUpgrade,
isConnecting,
actionsDisabled,
onConnect,
onUpgrade,
onRevoke,
onFinishSetup,
}: {
plugin: PluginInfo
pluginId: string
connectedKeys: ConnectedPlugin[]
needsSetup: boolean
needsProUpgrade: boolean
isConnecting: boolean
actionsDisabled: boolean
onConnect: (id: string) => void
onUpgrade: () => void
onRevoke: (keyId: string) => void
onFinishSetup: (id: string) => void
}) {
const isConnected = connectedKeys.length > 0
return (
@ -240,7 +382,16 @@ function PluginRow({
<div className="col-start-2 flex min-w-0 shrink-0 items-center gap-2 sm:col-start-auto sm:gap-4">
{plugin.docsUrl && <DocsLink href={plugin.docsUrl} />}
{isConnected ? (
<ConnectedPill connectedKeys={connectedKeys} onRevoke={onRevoke} />
<ActivePill
plugin={plugin}
connectedKeys={connectedKeys}
onRevoke={onRevoke}
/>
) : needsSetup ? (
<PillButton onClick={() => onFinishSetup(pluginId)}>
<span className="size-[7px] rounded-full bg-[#EAB308]" />
Finish setup
</PillButton>
) : needsProUpgrade ? (
<PillButton onClick={onUpgrade}>
<Zap className="size-3.5 text-[#4BA0FA]" /> Upgrade
@ -307,6 +458,9 @@ export function PluginsDetail() {
const queryClient = useQueryClient()
const [tierFilter, setTierFilter] = useState<TierFilter>("all")
const [connectingPlugin, setConnectingPlugin] = useState<string | null>(null)
const [finishSetupPluginId, setFinishSetupPluginId] = useState<string | null>(
null,
)
const [newKey, setNewKey] = useState<{
open: boolean
key: string
@ -332,21 +486,51 @@ export function PluginsDetail() {
queryKey: ["plugins"],
})
const { data: apiKeys = [], refetch: refetchKeys } = useQuery({
enabled: !!org?.id,
queryFn: async () => {
if (!org?.id) return []
const data = await authClient.apiKey.list({
fetchOptions: { query: { metadata: { organizationId: org.id } } },
})
return data.filter((key) => key.metadata?.organizationId === org.id)
const { data: apiKeys = [], refetch: refetchKeys } = useQuery<ListedApiKey[]>(
{
enabled: !!org?.id,
queryFn: async () => {
if (!org?.id) return []
const API_URL =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const res = await fetch(`${API_URL}/v3/auth/keys`, {
credentials: "include",
})
if (!res.ok) return []
const data = (await res.json()) as { keys?: ListedApiKey[] }
return data.keys ?? []
},
queryKey: ["api-keys", org?.id],
},
queryKey: ["api-keys", org?.id],
})
)
const setupPluginIds = useMemo(() => {
const ids = new Set<string>()
for (const key of apiKeys) {
if (key.enabled === false) continue
if (key.lastRequest) continue
if (!key.metadata) continue
try {
const metadata =
typeof key.metadata === "string"
? (JSON.parse(key.metadata) as {
sm_type?: string
sm_client?: string
})
: (key.metadata as { sm_type?: string; sm_client?: string })
if (metadata.sm_type === "plugin_auth" && metadata.sm_client) {
ids.add(normalizePluginClientId(metadata.sm_client))
}
} catch {}
}
return ids
}, [apiKeys])
const connectedPlugins = useMemo<ConnectedPlugin[]>(() => {
const plugins: ConnectedPlugin[] = []
for (const key of apiKeys) {
if (key.enabled === false) continue
if (!key.lastRequest) continue
if (!key.metadata) continue
try {
const metadata =
@ -358,12 +542,15 @@ export function PluginsDetail() {
: (key.metadata as { sm_type?: string; sm_client?: string })
if (metadata.sm_type === "plugin_auth" && metadata.sm_client) {
const createdAt = toDate(key.createdAt)?.toISOString()
const lastRequest = toDate(key.lastRequest)?.toISOString()
plugins.push({
id: key.id,
keyId: key.id,
pluginId: metadata.sm_client,
createdAt: key.createdAt.toISOString(),
keyStart: key.start ?? null,
pluginId: normalizePluginClientId(metadata.sm_client),
createdAt: createdAt ?? new Date().toISOString(),
lastRequest: lastRequest ?? null,
keyStart: keyPrefix(key),
})
}
} catch {}
@ -463,13 +650,18 @@ export function PluginsDetail() {
const dialogPlugin = newKey.pluginId
? PLUGIN_CATALOG[newKey.pluginId]
: undefined
const finishSetupPlugin = finishSetupPluginId
? PLUGIN_CATALOG[finishSetupPluginId]
: undefined
const pluginSteps = dialogPlugin?.installSteps ?? []
// If a step already embeds the key (an `export …="sm_…"` line), don't also
// show the bare key in its own step — that's the repetition to avoid.
// Otherwise (wizard-style installs) lead with a copy-the-key step.
// Otherwise (wizard-style installs) lead with a copy-the-key step, unless
// the plugin performs browser OAuth itself.
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
const setupSteps: InstallStep[] = stepsEmbedKey
const skipGeneratedKeyStep = stepsEmbedKey || !!dialogPlugin?.usesOAuth
const setupSteps: InstallStep[] = skipGeneratedKeyStep
? pluginSteps
: [
{
@ -512,10 +704,15 @@ export function PluginsDetail() {
connectedKeys={connectedPlugins.filter(
(p) => p.pluginId === pluginId,
)}
needsSetup={
!connectedPluginIds.has(pluginId) &&
setupPluginIds.has(pluginId)
}
needsProUpgrade={needsProUpgrade}
isConnecting={connectingPlugin === pluginId}
actionsDisabled={!!connectingPlugin}
onConnect={(id) => createPluginKeyMutation.mutate(id)}
onFinishSetup={(id) => setFinishSetupPluginId(id)}
onUpgrade={handleUpgrade}
onRevoke={handleRevoke}
/>
@ -638,6 +835,97 @@ export function PluginsDetail() {
</div>
</DialogContent>
</Dialog>
<Dialog
open={!!finishSetupPluginId}
onOpenChange={(open) => {
if (!open) setFinishSetupPluginId(null)
}}
>
<DialogContent
showCloseButton={false}
style={{
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",
}}
className={cn(
dmSans125ClassName(),
"flex max-h-[88dvh] flex-col gap-3 overflow-hidden border border-white/[0.12] bg-[#1B1F24] p-0 px-3 pt-3 pb-4 rounded-2xl md:px-4 sm:max-w-[560px] sm:rounded-[22px]",
)}
>
<DialogTitle className="sr-only">
Finish setup {finishSetupPlugin?.name ?? "plugin"}
</DialogTitle>
<div className="flex shrink-0 items-center gap-3">
{finishSetupPlugin && (
<PluginIconBox
src={finishSetupPlugin.icon}
alt={finishSetupPlugin.name}
/>
)}
<div className="min-w-0 flex-1">
<p
className={cn(
dmSans125ClassName(),
"truncate text-[16px] font-semibold leading-tight text-[#FAFAFA]",
)}
>
Finish setup {finishSetupPlugin?.name ?? "plugin"}
</p>
<p
className={cn(
dmSans125ClassName(),
"mt-0.5 truncate text-[12px] text-[#A1A1AA]",
)}
>
Complete install in the tool — status becomes active after the
first API call.
</p>
</div>
<DialogPrimitive.Close
type="button"
aria-label="Close"
className={cn(
"flex size-7 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none",
INSET,
)}
>
<X className="size-4 text-[#737373]" />
</DialogPrimitive.Close>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<div
className={cn(
"min-w-0 rounded-[14px] bg-[#14161A] p-4 sm:p-5",
INSET,
)}
>
{finishSetupPlugin?.installSteps?.length ? (
<InstallSteps steps={finishSetupPlugin.installSteps} />
) : (
<p className="text-[13px] text-[#A1A1AA]">
Open {finishSetupPlugin?.name ?? "the plugin"} and finish
authentication.
</p>
)}
</div>
</div>
<div className="flex shrink-0 items-center justify-end">
<DialogPrimitive.Close asChild>
<button
type="button"
className={cn(
dmSans125ClassName(),
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
INSET,
)}
>
<Check className="size-3.5 text-[#4BA0FA]" /> Done
</button>
</DialogPrimitive.Close>
</div>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -23,14 +23,18 @@ import Image from "next/image"
import { useId, useState } from "react"
import { toast } from "sonner"
type ShortcutType = "add" | "search"
function PillButton({
children,
onClick,
disabled,
className,
}: {
children: React.ReactNode
onClick?: () => void
onClick?: (e: React.MouseEvent) => void
disabled?: boolean
className?: string
}) {
return (
<button
@ -45,6 +49,7 @@ function PillButton({
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
"disabled:opacity-50 disabled:cursor-not-allowed",
dmSans125ClassName(),
className,
)}
>
{children}
@ -52,17 +57,14 @@ function PillButton({
)
}
export function ShortcutsDetail() {
export function useShortcutsConnect() {
const { org } = useAuth()
const [showApiKeyModal, setShowApiKeyModal] = useState(false)
const [showSetupDialog, setShowSetupDialog] = useState(false)
const [apiKey, setApiKey] = useState("")
const [copied, setCopied] = useState(false)
const [selectedShortcutType, setSelectedShortcutType] = useState<
"add" | "search" | null
>(null)
const apiKeyId = useId()
const [shortcutType, setShortcutType] = useState<ShortcutType | null>(null)
const handleCopyApiKey = async (key: string) => {
const copyApiKey = async (key: string) => {
try {
await navigator.clipboard.writeText(key)
setCopied(true)
@ -73,20 +75,24 @@ export function ShortcutsDetail() {
}
}
const createApiKeyMutation = useMutation({
const createKeyMutation = useMutation({
mutationFn: async () => {
if (!org?.id) throw new Error("Organization ID is required")
const res = await authClient.apiKey.create({
metadata: { organizationId: org?.id, type: "ios-shortcut" },
metadata: { organizationId: org.id, type: "ios-shortcut" },
name: `ios-${generateId().slice(0, 8)}`,
prefix: `sm_${org?.id}_`,
prefix: `sm_${org.id}_`,
})
return res.key
if (res.error)
throw new Error(res.error.message ?? "Failed to create API key")
if (!res.data?.key) throw new Error("API key missing from response")
return res.data.key
},
onSuccess: (key) => {
setApiKey(key)
setShowApiKeyModal(true)
setShowSetupDialog(true)
setCopied(false)
handleCopyApiKey(key)
copyApiKey(key)
},
onError: (error) => {
toast.error("Failed to create API key", {
@ -95,19 +101,224 @@ export function ShortcutsDetail() {
},
})
const handleShortcutClick = (type: "add" | "search") => {
setSelectedShortcutType(type)
createApiKeyMutation.mutate()
const connect = (type: ShortcutType) => {
if (createKeyMutation.isPending) return
setShortcutType(type)
createKeyMutation.mutate()
}
const handleOpenShortcut = () => {
if (selectedShortcutType === "add") {
const openShortcut = () => {
if (shortcutType === "add") {
window.open(ADD_MEMORY_SHORTCUT_URL, "_blank")
} else if (selectedShortcutType === "search") {
} else if (shortcutType === "search") {
window.open(SEARCH_MEMORY_SHORTCUT_URL, "_blank")
}
}
const dialog = (
<ShortcutsSetupDialog
apiKey={apiKey}
copied={copied}
onCopy={() => copyApiKey(apiKey)}
onOpenChange={(open) => {
setShowSetupDialog(open)
if (!open) {
setShortcutType(null)
setApiKey("")
setCopied(false)
}
}}
onOpenShortcut={openShortcut}
open={showSetupDialog}
shortcutType={shortcutType}
/>
)
return {
connect,
dialog,
isPending: createKeyMutation.isPending,
pendingType: createKeyMutation.isPending ? shortcutType : null,
}
}
export type ShortcutsConnectController = ReturnType<typeof useShortcutsConnect>
export function ShortcutsConnectButtons({
controller,
}: {
controller: ShortcutsConnectController
}) {
const { connect, isPending, pendingType } = controller
return (
<div className="flex flex-col gap-2 sm:flex-row">
<PillButton
className="h-9 flex-none"
onClick={(e) => {
e.stopPropagation()
connect("add")
}}
disabled={isPending}
>
{pendingType === "add" ? (
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
) : (
<Plus className="size-4 text-[#FAFAFA]" />
)}
<span className="text-[13px] text-[#FAFAFA] font-medium whitespace-nowrap">
{pendingType === "add" ? "Creating..." : "Add memory shortcut"}
</span>
</PillButton>
<PillButton
className="h-9 flex-none"
onClick={(e) => {
e.stopPropagation()
connect("search")
}}
disabled={isPending}
>
{pendingType === "search" ? (
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
) : (
<Search className="size-4 text-[#FAFAFA]" />
)}
<span className="text-[13px] text-[#FAFAFA] font-medium whitespace-nowrap">
{pendingType === "search" ? "Creating..." : "Search memory shortcut"}
</span>
</PillButton>
</div>
)
}
function ShortcutsSetupDialog({
apiKey,
copied,
onCopy,
onOpenChange,
onOpenShortcut,
open,
shortcutType,
}: {
apiKey: string
copied: boolean
onCopy: () => void
onOpenChange: (open: boolean) => void
onOpenShortcut: () => void
open: boolean
shortcutType: ShortcutType | null
}) {
const apiKeyId = useId()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogPortal>
<DialogContent className="bg-[#14161A] border border-white/10 text-[#FAFAFA] md:max-w-md z-100">
<DialogHeader>
<DialogTitle
className={cn(
dmSans125ClassName(),
"text-[#FAFAFA] text-lg font-semibold",
)}
>
Setup Apple Shortcut
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label
htmlFor={apiKeyId}
className={cn(
dmSans125ClassName(),
"text-sm font-medium text-[#737373]",
)}
>
Your API Key
</label>
<div className="flex items-center gap-2">
<input
id={apiKeyId}
type="text"
value={apiKey}
readOnly
className={cn(
"flex-1 bg-[#0D121A] border border-white/10 rounded-lg px-3 py-2 text-sm text-[#FAFAFA] font-mono",
dmSans125ClassName(),
)}
/>
<button
type="button"
onClick={onCopy}
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
>
{copied ? (
<Check className="size-4 text-[#4BA0FA]" />
) : (
<Copy className="size-4" />
)}
</button>
</div>
</div>
<div className="space-y-3">
<h4
className={cn(
dmSans125ClassName(),
"text-sm font-medium text-[#737373]",
)}
>
Follow these steps:
</h4>
<div className="space-y-2">
{[
'Click "Add to Shortcuts" below to open the shortcut',
"Paste your API key when prompted",
"Start using your shortcut!",
].map((text, i) => (
<div key={text} className="flex items-start gap-3">
<div className="shrink-0 size-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
{i + 1}
</div>
<p
className={cn(
dmSans125ClassName(),
"text-sm text-[#737373]",
)}
>
{text}
</p>
</div>
))}
</div>
</div>
<button
type="button"
onClick={onOpenShortcut}
disabled={!shortcutType}
className={cn(
"w-full flex items-center justify-center gap-2",
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 text-white",
"rounded-lg h-11 px-4 font-medium text-sm",
"disabled:opacity-50 disabled:cursor-not-allowed transition-colors",
dmSans125ClassName(),
)}
>
<Image
src="/images/ios-shortcuts.png"
alt="iOS Shortcuts"
width={16}
height={16}
/>
Add to Shortcuts
</button>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
)
}
export function ShortcutsDetail() {
const controller = useShortcutsConnect()
const { connect, dialog, isPending, pendingType } = controller
return (
<>
<div
@ -140,36 +351,24 @@ export function ShortcutsDetail() {
</div>
<div className="flex gap-4">
<PillButton
onClick={() => handleShortcutClick("add")}
disabled={createApiKeyMutation.isPending}
>
{createApiKeyMutation.isPending &&
selectedShortcutType === "add" ? (
<PillButton onClick={() => connect("add")} disabled={isPending}>
{pendingType === "add" ? (
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
) : (
<Plus className="size-4 text-[#FAFAFA]" />
)}
<span className="text-[14px] text-[#FAFAFA] font-medium">
{createApiKeyMutation.isPending &&
selectedShortcutType === "add"
? "Creating..."
: "Add memory shortcut"}
{pendingType === "add" ? "Creating..." : "Add memory shortcut"}
</span>
</PillButton>
<PillButton
onClick={() => handleShortcutClick("search")}
disabled={createApiKeyMutation.isPending}
>
{createApiKeyMutation.isPending &&
selectedShortcutType === "search" ? (
<PillButton onClick={() => connect("search")} disabled={isPending}>
{pendingType === "search" ? (
<Loader className="size-4 text-[#FAFAFA] animate-spin" />
) : (
<Search className="size-4 text-[#FAFAFA]" />
)}
<span className="text-[14px] text-[#FAFAFA] font-medium">
{createApiKeyMutation.isPending &&
selectedShortcutType === "search"
{pendingType === "search"
? "Creating..."
: "Search memory shortcut"}
</span>
@ -177,120 +376,7 @@ export function ShortcutsDetail() {
</div>
</div>
</div>
<Dialog
open={showApiKeyModal}
onOpenChange={(open: boolean) => {
setShowApiKeyModal(open)
if (!open) {
setSelectedShortcutType(null)
setApiKey("")
setCopied(false)
}
}}
>
<DialogPortal>
<DialogContent className="bg-[#14161A] border border-white/10 text-[#FAFAFA] md:max-w-md z-100">
<DialogHeader>
<DialogTitle
className={cn(
dmSans125ClassName(),
"text-[#FAFAFA] text-lg font-semibold",
)}
>
Setup Apple Shortcut
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label
htmlFor={apiKeyId}
className={cn(
dmSans125ClassName(),
"text-sm font-medium text-[#737373]",
)}
>
Your API Key
</label>
<div className="flex items-center gap-2">
<input
id={apiKeyId}
type="text"
value={apiKey}
readOnly
className={cn(
"flex-1 bg-[#0D121A] border border-white/10 rounded-lg px-3 py-2 text-sm text-[#FAFAFA] font-mono",
dmSans125ClassName(),
)}
/>
<button
type="button"
onClick={() => handleCopyApiKey(apiKey)}
className="p-2 rounded-lg bg-[#0D121A] border border-white/10 text-[#737373] hover:text-[#FAFAFA] transition-colors"
>
{copied ? (
<Check className="size-4 text-[#4BA0FA]" />
) : (
<Copy className="size-4" />
)}
</button>
</div>
</div>
<div className="space-y-3">
<h4
className={cn(
dmSans125ClassName(),
"text-sm font-medium text-[#737373]",
)}
>
Follow these steps:
</h4>
<div className="space-y-2">
{[
'Click "Add to Shortcuts" below to open the shortcut',
"Paste your API key when prompted",
"Start using your shortcut!",
].map((text, i) => (
<div key={text} className="flex items-start gap-3">
<div className="shrink-0 size-6 bg-[#4BA0FA]/20 text-[#4BA0FA] rounded-full flex items-center justify-center text-xs font-medium">
{i + 1}
</div>
<p
className={cn(
dmSans125ClassName(),
"text-sm text-[#737373]",
)}
>
{text}
</p>
</div>
))}
</div>
</div>
<button
type="button"
onClick={handleOpenShortcut}
disabled={!selectedShortcutType}
className={cn(
"w-full flex items-center justify-center gap-2",
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 text-white",
"rounded-lg h-11 px-4 font-medium text-sm",
"disabled:opacity-50 disabled:cursor-not-allowed transition-colors",
dmSans125ClassName(),
)}
>
<Image
src="/images/ios-shortcuts.png"
alt="iOS Shortcuts"
width={16}
height={16}
/>
Add to Shortcuts
</button>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
{dialog}
</>
)
}

View file

@ -4,6 +4,7 @@ import { memo, useMemo } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { Expand } from "lucide-react"
import { SuperLoader } from "@/components/superloader"
import { useGraphApi } from "./hooks/use-graph-api"
import { useViewMode } from "@/lib/view-mode-context"
@ -157,7 +158,12 @@ export const GraphCard = memo<GraphCardProps>(
<div className="flex-1 w-full relative overflow-hidden rounded-lg">
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center">
<div className="size-6 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
<SuperLoader
label="Loading graph..."
size={40}
colorClassName="text-[#4BA0FA]"
className="[&>span]:text-[#A8B0BD]"
/>
</div>
) : documentCount > 0 || memoryCount > 0 ? (
<StaticGraphPreview

View file

@ -1,7 +1,7 @@
"use client"
import { useInfiniteQuery } from "@tanstack/react-query"
import { useMemo } from "react"
import { useEffect, useMemo } from "react"
import { $fetch } from "@lib/api"
import type {
GraphApiDocument,
@ -9,12 +9,13 @@ import type {
MemoryRelation,
} from "@supermemory/memory-graph"
const PAGE_SIZE = 100
const PAGE_SIZE = 500
interface UseGraphApiOptions {
containerTags?: string[]
documentIds?: string[]
enabled?: boolean
maxNodes?: number
}
interface ApiMemoryEntry {
@ -59,6 +60,13 @@ interface ApiDocumentsResponse {
}
}
function getGraphNodeCount(documents: ApiDocument[]): number {
return documents.reduce(
(total, doc) => total + 1 + (doc.memoryEntries?.length ?? 0),
0,
)
}
function toGraphMemory(mem: ApiMemoryEntry): GraphApiMemory {
return {
id: mem.id,
@ -108,7 +116,7 @@ function toGraphDocument(
}
export function useGraphApi(options: UseGraphApiOptions = {}) {
const { containerTags, documentIds, enabled = true } = options
const { containerTags, documentIds, enabled = true, maxNodes } = options
const filteredDocumentIds = documentIds?.filter(Boolean)
const hasDocumentIds =
filteredDocumentIds != null && filteredDocumentIds.length > 0
@ -126,6 +134,7 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
containerTags,
[],
filteredDocumentIds,
maxNodes,
],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
@ -155,7 +164,16 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
return response.data as unknown as ApiDocumentsResponse
},
getNextPageParam: (lastPage) => {
getNextPageParam: (lastPage, allPages) => {
if (hasDocumentIds) return undefined
if (maxNodes != null) {
const loadedNodes = allPages.reduce(
(total, page) => total + getGraphNodeCount(page.documents ?? []),
0,
)
if (loadedNodes >= maxNodes) return undefined
}
const { currentPage, totalPages } = lastPage.pagination
return currentPage < totalPages ? currentPage + 1 : undefined
},
@ -163,6 +181,29 @@ export function useGraphApi(options: UseGraphApiOptions = {}) {
enabled,
})
const loadedNodeCount = useMemo(() => {
if (!data?.pages) return 0
return data.pages.reduce(
(total, page) => total + getGraphNodeCount(page.documents ?? []),
0,
)
}, [data])
useEffect(() => {
if (!enabled || hasDocumentIds) return
if (!hasNextPage || isFetchingNextPage) return
if (maxNodes != null && loadedNodeCount >= maxNodes) return
fetchNextPage()
}, [
enabled,
hasDocumentIds,
hasNextPage,
isFetchingNextPage,
loadedNodeCount,
maxNodes,
fetchNextPage,
])
const documents = useMemo(() => {
if (!data?.pages) return []
return data.pages.flatMap((page) =>

View file

@ -1,8 +1,8 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { MemoryGraph as MemoryGraphBase } from "@supermemory/memory-graph"
import type { GraphThemeColors } from "@supermemory/memory-graph"
import { SuperLoader } from "@/components/superloader"
import { useGraphApi } from "./hooks/use-graph-api"
export interface MemoryGraphWrapperProps {
@ -34,20 +34,6 @@ export function MemoryGraph({
canvasRef,
...rest
}: MemoryGraphWrapperProps) {
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver(() => {
setContainerSize({ width: el.clientWidth, height: el.clientHeight })
})
ro.observe(el)
setContainerSize({ width: el.clientWidth, height: el.clientHeight })
return () => ro.disconnect()
}, [])
const {
documents,
isLoading: apiIsLoading,
@ -59,16 +45,17 @@ export function MemoryGraph({
} = useGraphApi({
containerTags,
documentIds,
enabled: containerSize.width > 0 && containerSize.height > 0,
maxNodes,
})
const isInitialLoading = externalIsLoading || apiIsLoading
return (
<div ref={containerRef} className="size-full [&>div]:!bg-none">
<div className="absolute inset-0 [&>div]:!h-full [&>div]:!bg-none">
<MemoryGraphBase
documents={documents}
isLoading={externalIsLoading || apiIsLoading}
isLoadingMore={isLoadingMore}
onLoadMore={hasMore ? () => loadMore() : undefined}
isLoading={false}
isLoadingMore={false}
onLoadMore={hasMore && !isLoadingMore ? () => loadMore() : undefined}
hasMore={hasMore}
error={externalError || apiError}
variant={variant}
@ -85,6 +72,16 @@ export function MemoryGraph({
>
{children}
</MemoryGraphBase>
{isInitialLoading && (
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center">
<SuperLoader
label="Loading memory graph..."
size={72}
colorClassName="text-[#4BA0FA]"
className="[&>span]:text-slate-100"
/>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,53 @@
"use client"
import { useEffect, useRef } from "react"
import { useQueryState } from "nuqs"
const COLORS = ["#4BA0FA", "#FF8A47", "#B19CFF", "#10A37F", "#fafafa"]
export function OnboardingConfetti() {
const [onboarded, setOnboarded] = useQueryState("onboarded")
const fired = useRef(false)
useEffect(() => {
if (onboarded !== "1" || fired.current) return
fired.current = true
setOnboarded(null)
const reduceMotion = window.matchMedia?.(
"(prefers-reduced-motion: reduce)",
).matches
if (reduceMotion) return
let raf = 0
const run = async () => {
const confetti = (await import("canvas-confetti")).default
const end = Date.now() + 1400
const frame = () => {
confetti({
particleCount: 4,
angle: 60,
spread: 70,
startVelocity: 55,
origin: { x: 0, y: 0.7 },
colors: COLORS,
})
confetti({
particleCount: 4,
angle: 120,
spread: 70,
startVelocity: 55,
origin: { x: 1, y: 0.7 },
colors: COLORS,
})
if (Date.now() < end) raf = requestAnimationFrame(frame)
}
frame()
}
run()
return () => cancelAnimationFrame(raf)
}, [onboarded, setOnboarded])
return null
}

View file

@ -0,0 +1,151 @@
"use client"
import { LogoFull } from "@ui/assets/Logo"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { motion } from "motion/react"
import { BRAIN_STEPS, BRAIN_STEP_LABELS, type BrainStep } from "./types"
interface ShellProps {
step: BrainStep
domain?: string | null
children: React.ReactNode
}
export function BrainShell({ step, children }: ShellProps) {
const visibleSteps: BrainStep[] = BRAIN_STEPS
return (
<div
className={cn(
"relative min-h-dvh bg-[#05080D] text-[#FAFAFA] flex flex-col overflow-hidden",
dmSansClassName(),
)}
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 select-none"
style={{
background:
"radial-gradient(ellipse 80% 60% at 50% 40%, rgba(75,160,250,0.08) 0%, rgba(34,97,202,0.04) 35%, transparent 70%)",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 select-none"
style={{
backgroundImage:
"radial-gradient(circle at center, rgba(105,167,240,0.22) 1px, transparent 1px)",
backgroundSize: "28px 28px",
maskImage:
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
WebkitMaskImage:
"radial-gradient(ellipse at center, black 0%, black 40%, transparent 90%)",
}}
/>
<header className="relative z-10 grid grid-cols-[1fr_auto_1fr] items-center px-6 md:px-10 py-4 gap-4">
<LogoFull className="h-5 md:h-6 text-[#fafafa] justify-self-start" />
<StepIndicator step={step} visibleSteps={visibleSteps} />
<span aria-hidden />
</header>
<main
className={cn(
"relative z-10 flex-1 flex justify-center px-4 md:px-10 py-6 md:py-10",
step === "sources"
? "items-start overflow-y-auto"
: "items-center overflow-hidden",
)}
>
<div
className={cn(
"w-full",
step === "sources" ? "max-w-none" : "max-w-5xl",
)}
>
{children}
</div>
</main>
</div>
)
}
function StepIndicator({
step,
visibleSteps,
}: {
step: BrainStep
visibleSteps: BrainStep[]
}) {
const currentIdx = visibleSteps.indexOf(step)
return (
<div className="flex items-start">
{visibleSteps.map((s, i) => {
const isDone = i < currentIdx
const isCurrent = i === currentIdx
const isLast = i === visibleSteps.length - 1
return (
<div key={s} className="flex items-start">
<div className="flex flex-col items-center gap-1.5 min-w-[58px]">
<StepDot done={isDone} current={isCurrent} />
<span
className={cn(
"text-[11px] font-medium transition-colors leading-none",
isCurrent
? "text-[#fafafa]"
: isDone
? "text-[#737373]"
: "text-[#525D6E]",
)}
>
{BRAIN_STEP_LABELS[s]}
</span>
</div>
{!isLast && (
<div className="flex-1 h-px min-w-[28px] mt-[5px]">
<motion.div
initial={false}
animate={{
background: isDone
? "linear-gradient(to right, #4BA0FA, rgba(75,160,250,0.4))"
: "#2E353D",
}}
transition={{ duration: 0.3 }}
className="h-full w-full rounded-full"
/>
</div>
)}
</div>
)
})}
</div>
)
}
function StepDot({ done, current }: { done: boolean; current: boolean }) {
if (current) {
return (
<div className="relative size-3 flex items-center justify-center">
<motion.span
layoutId="brain-step-ring"
className="absolute inset-0 rounded-full border-2 border-[#4BA0FA]"
/>
<span className="size-1.5 rounded-full bg-[#4BA0FA]" />
</div>
)
}
if (done) {
return (
<div className="size-3 flex items-center justify-center">
<span className="size-2.5 rounded-full bg-[#4BA0FA]" />
</div>
)
}
return (
<div className="size-3 flex items-center justify-center">
<span className="size-2.5 rounded-full border border-[#2E353D]" />
</div>
)
}

View file

@ -0,0 +1,473 @@
"use client"
import { useEffect, useState } from "react"
import { motion } from "motion/react"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import { Textarea } from "@ui/components/textarea"
import {
ArrowRight,
Brain,
Building2,
LayoutGrid,
Loader2,
Plug,
Terminal,
User2,
UserPlus,
Users2,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import type { BrainMode } from "./types"
export interface AboutValues {
name: string
about: string
workspaceName: string
workspaceDomain: string
}
interface Props {
mode: BrainMode
onModeChange: (m: BrainMode) => void
domain: string | null
suggestedWorkspaceName: string
defaultName: string
avatarUrl: string | null
values: AboutValues
onChange: (next: AboutValues) => void
onContinue: () => void
submitting?: boolean
}
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 = {
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 =
"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({
mode,
onModeChange,
domain,
suggestedWorkspaceName,
defaultName,
avatarUrl,
values,
onChange,
onContinue,
submitting,
}: Props) {
// biome-ignore lint/correctness/useExhaustiveDependencies: one-time initialization when defaults become available
useEffect(() => {
const patch: Partial<AboutValues> = {}
if (!values.name && defaultName) patch.name = defaultName
if (!values.workspaceName && suggestedWorkspaceName) {
patch.workspaceName = suggestedWorkspaceName
}
if (!values.workspaceDomain && domain) {
patch.workspaceDomain = domain
}
if (Object.keys(patch).length > 0) onChange({ ...values, ...patch })
}, [defaultName, suggestedWorkspaceName, domain])
const canContinue =
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}
>
<UserAvatar
url={avatarUrl}
name={values.name || defaultName}
className="size-14 mb-5"
/>
<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="mt-6 space-y-5">
<div>
<p className={fieldLabel}>Your name</p>
<Input
value={values.name}
onChange={(e) => onChange({ ...values, name: e.target.value })}
placeholder="e.g. Mahesh"
className={inputClass}
style={inputBevelStyle}
/>
</div>
<div>
<p className={fieldLabel}>
What are you here for?{" "}
<span className="text-[#525D6E] font-medium">(optional)</span>
</p>
<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}
className={cn(
inputClass,
"h-auto resize-none py-3 leading-[1.5]",
)}
style={inputBevelStyle}
/>
</div>
</div>
</section>
<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">
{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>
<div className="flex items-center justify-end gap-[22px] px-1">
<Button
variant="insideOut"
onClick={onContinue}
disabled={!canContinue || submitting}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
{submitting ? (
<>
Creating…
<Loader2 className="size-3.5 animate-spin" />
</>
) : (
<>
Continue
<ArrowRight className="size-3.5" />
</>
)}
</Button>
</div>
</div>
)
}
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>
<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>
<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 }) {
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`,
`https://icons.duckduckgo.com/ip3/${domain}.ico`,
]
const [idx, setIdx] = useState(0)
if (idx >= sources.length) {
return <Building2 className="size-5 text-[#737373]" />
}
return (
<img
src={sources[idx]}
alt={domain}
className="size-6 object-contain"
onError={() => setIdx((i) => i + 1)}
/>
)
}
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 ModeToggle({
mode,
onChange,
}: {
mode: BrainMode
onChange: (m: BrainMode) => void
}) {
const items: { id: BrainMode; label: string }[] = [
{ id: "personal", label: "Personal" },
{ id: "team", label: "Team" },
]
return (
<div
className="relative grid grid-cols-2 items-center rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.2)] p-1 text-[13px] font-medium w-full"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
{items.map((item) => {
const isActive = mode === item.id
return (
<button
key={item.id}
type="button"
onClick={() => onChange(item.id)}
className={cn(
"relative h-8 rounded-full text-center transition-colors",
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>
</button>
)
})}
</div>
)
}
function UserAvatar({
url,
name,
className,
}: {
url: string | null
name: string
className?: string
}) {
const [errored, setErrored] = useState(false)
const initial = (name?.trim()?.[0] ?? "?").toUpperCase()
const hasImage = url && !errored
return (
<div
className={cn(
"rounded-full overflow-hidden bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center",
className,
)}
style={inputBevelStyle}
>
{hasImage ? (
<img
src={url}
alt={name}
className="size-full object-cover"
onError={() => setErrored(true)}
/>
) : (
<span className="text-[20px] font-semibold text-[#fafafa]">
{initial}
</span>
)}
</div>
)
}
type Perk = { icon: React.ReactNode; title: string; blurb: string }
function PerksList({ heading, perks }: { heading: string; 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>
)
}

View file

@ -0,0 +1,480 @@
"use client"
import { useState, useEffect } from "react"
import Image from "next/image"
import { useQueryState, parseAsString } from "nuqs"
import { Button } from "@ui/components/button"
import { MCPIcon } from "@ui/assets/icons"
import { ArrowRight, Check, Copy, EyeOff, Eye } from "lucide-react"
import { cn } from "@lib/utils"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { toast } from "sonner"
import { MCPSteps } from "@/components/mcp-modal/mcp-detail-view"
import { PLUGIN_CATALOG } from "@/lib/plugin-catalog"
interface Props {
mcpUrl: string
onContinue: () => void
}
const modalCardStyle = {
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}
const inputBevelStyle = {
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
}
type AgentCategory = "coding" | "productivity"
type Agent = {
key: string
name: string
tagline: string
category: AgentCategory
pluginId?: string
}
const AGENTS: Agent[] = [
{
key: "cursor",
name: "Cursor",
tagline: "Persistent context across coding sessions.",
category: "coding",
},
{
key: "claude-code",
name: "Claude Code",
tagline: "Memory and decisions across CLI sessions.",
category: "coding",
pluginId: "claude_code",
},
{
key: "vscode",
name: "VS Code",
tagline: "Inline context while you write.",
category: "coding",
},
{
key: "cline",
name: "Cline",
tagline: "Agentic dev tasks with your memory.",
category: "coding",
},
{
key: "codex",
name: "Codex",
tagline: "OpenAI Codex with persistent memory.",
category: "coding",
pluginId: "codex",
},
{
key: "gemini-cli",
name: "Gemini CLI",
tagline: "Gemini in your terminal, brain-aware.",
category: "coding",
},
{
key: "claude",
name: "Claude Desktop",
tagline: "Memory across every Claude conversation.",
category: "productivity",
},
{
key: "chatgpt",
name: "ChatGPT",
tagline: "Custom GPT backed by your brain.",
category: "productivity",
},
]
const CATEGORY_ORDER: { id: AgentCategory; label: string }[] = [
{ id: "coding", label: "Coding" },
{ id: "productivity", label: "Productivity" },
]
function agentIcon(agent: Agent) {
if (agent.pluginId) {
const plugin = PLUGIN_CATALOG[agent.pluginId]
if (plugin) return plugin.icon
}
const file = agent.key === "claude-code" ? "claude" : agent.key
return `/mcp-supported-tools/${file}.png`
}
export function StepIngest({ mcpUrl, onContinue }: Props) {
const [activeCategory, setActiveCategory] = useState<AgentCategory>("coding")
const [selectedKey, setSelectedKey] = useState<string>("cursor")
const [, setMcpClient] = useQueryState("mcpClient", parseAsString)
const selectedAgent = AGENTS.find((a) => a.key === selectedKey) ?? AGENTS[0]
useEffect(() => {
if (selectedAgent && !selectedAgent.pluginId) {
setMcpClient(selectedAgent.key)
} else {
setMcpClient(null)
}
}, [selectedAgent, setMcpClient])
const selectAgent = (agent: Agent) => {
setSelectedKey(agent.key)
}
const filtered = AGENTS.filter((a) => a.category === activeCategory)
return (
<div className="space-y-5">
<div className="px-1">
<p
className={cn(
"font-semibold text-[#fafafa] text-[22px]",
dmSans125ClassName(),
)}
>
Use your brain anywhere
</p>
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
Now plug it into the tools you already use to write code, chat, think.
</p>
</div>
<McpHero url={mcpUrl} />
<div className="grid lg:grid-cols-[300px_1fr] gap-4 h-[560px]">
<aside
className="rounded-[16px] bg-[#1B1F24] p-3 flex flex-col gap-3 overflow-hidden h-full"
style={modalCardStyle}
>
<CategoryTabs value={activeCategory} onChange={setActiveCategory} />
<div className="flex-1 min-h-0 overflow-y-auto space-y-1 scrollbar-thin pr-1">
{filtered.map((agent) => (
<AgentRow
key={agent.key}
agent={agent}
active={selectedKey === agent.key}
onClick={() => selectAgent(agent)}
/>
))}
</div>
</aside>
<section
className="rounded-[16px] bg-[#1B1F24] p-5 md:p-6 overflow-hidden flex flex-col h-full"
style={modalCardStyle}
>
{selectedAgent?.pluginId ? (
<PluginSteps pluginId={selectedAgent.pluginId} />
) : (
<MCPSteps variant="embedded" />
)}
</section>
</div>
<div className="flex flex-wrap items-center justify-end gap-[22px] px-1 pt-2">
<button
type="button"
onClick={onContinue}
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors"
>
Skip for now
</button>
<Button
variant="insideOut"
onClick={onContinue}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
Continue
<ArrowRight className="size-3.5" />
</Button>
</div>
</div>
)
}
function McpHero({ url }: { url: string }) {
const [copied, setCopied] = useState(false)
const copy = async () => {
try {
await navigator.clipboard.writeText(url)
setCopied(true)
toast.success("MCP URL copied")
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error("Could not copy")
}
}
return (
<section
className="rounded-[16px] bg-[#1B1F24] px-5 py-3 flex items-center gap-3 relative overflow-hidden"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px left-0 right-0 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
}}
/>
<div
className="size-9 rounded-[10px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center shrink-0"
style={inputBevelStyle}
>
<MCPIcon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[10px] uppercase tracking-[0.08em] text-[#737373] font-semibold">
Universal MCP URL
</p>
<p className="text-[13px] text-[#fafafa] font-mono truncate mt-0.5">
{url}
</p>
</div>
<Button
variant="insideOut"
onClick={copy}
className="rounded-full h-9 px-4 text-[12px] font-medium text-[#fafafa] shrink-0"
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
{copied ? "Copied" : "Copy URL"}
</Button>
</section>
)
}
function PluginSteps({ pluginId }: { pluginId: string }) {
const plugin = PLUGIN_CATALOG[pluginId]
if (!plugin) return null
const steps = plugin.installSteps ?? []
return (
<div className="h-full overflow-y-auto pr-1 scrollbar-thin">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)] flex items-center justify-center shrink-0 overflow-hidden">
<Image
src={plugin.icon}
alt={plugin.name}
width={28}
height={28}
unoptimized
className="size-6 object-contain"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-[16px] font-semibold text-[#fafafa] leading-tight">
Set up {plugin.name}
</p>
<p className="text-[12px] text-[#A1A1AA] font-medium mt-0.5">
{plugin.tagline}
</p>
</div>
{plugin.docsUrl && (
<a
href={plugin.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[12px] text-[#A1A1AA] hover:text-[#fafafa] transition-colors font-medium shrink-0"
>
Docs ↗
</a>
)}
</div>
<div className="space-y-3">
{steps.map((step, i) => (
<PluginStep key={step.title} idx={i + 1} step={step} />
))}
</div>
<div className="mt-4 rounded-[10px] border border-[#4BA0FA]/20 bg-[#4BA0FA]/[0.04] p-3 flex items-start gap-2">
<div className="text-[11px] text-[#A1A1AA] leading-[1.5] font-medium">
Your <span className="text-[#fafafa]">API key</span> is minted in
Settings → Integrations → Plugins. Mint it once and paste into the
step above.
</div>
</div>
</div>
)
}
function PluginStep({
idx,
step,
}: {
idx: number
step: import("@/lib/plugin-catalog").InstallStep
}) {
const [revealed, setRevealed] = useState(false)
const [copied, setCopied] = useState(false)
const copy = async () => {
if (!step.code) return
try {
await navigator.clipboard.writeText(step.code)
setCopied(true)
toast.success("Copied")
setTimeout(() => setCopied(false), 1500)
} catch {
toast.error("Could not copy")
}
}
return (
<div className="flex gap-3">
<div className="flex flex-col items-center shrink-0 pt-1">
<div className="size-5 rounded-full bg-[#4BA0FA]/15 text-[#4BA0FA] flex items-center justify-center text-[10px] font-semibold">
{idx}
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-semibold text-[#fafafa]">
{step.title}
{step.optional && (
<span className="ml-2 text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
Optional
</span>
)}
</p>
{step.description && (
<p className="text-[12px] text-[#A1A1AA] mt-1 leading-[1.5] font-medium">
{step.description}
</p>
)}
{step.code && (
<div
className="mt-2 rounded-[10px] bg-[#0F1217] border border-[rgba(82,89,102,0.2)] p-3 flex items-start gap-2"
style={inputBevelStyle}
>
<pre
className={cn(
"flex-1 min-w-0 text-[11px] text-[#fafafa] font-mono overflow-x-auto whitespace-pre",
step.secret && !revealed && "blur-[3px] select-none",
)}
>
{step.code}
</pre>
<div className="flex items-center gap-1 shrink-0">
{step.secret && (
<button
type="button"
onClick={() => setRevealed((v) => !v)}
className="size-7 rounded-md text-[#737373] hover:text-[#fafafa] flex items-center justify-center transition-colors"
aria-label={revealed ? "Hide" : "Reveal"}
>
{revealed ? (
<EyeOff className="size-3.5" />
) : (
<Eye className="size-3.5" />
)}
</button>
)}
<button
type="button"
onClick={copy}
className="size-7 rounded-md text-[#737373] hover:text-[#fafafa] flex items-center justify-center transition-colors"
aria-label="Copy"
>
{copied ? (
<Check className="size-3.5 text-[#4BA0FA]" />
) : (
<Copy className="size-3.5" />
)}
</button>
</div>
</div>
)}
</div>
</div>
)
}
function CategoryTabs({
value,
onChange,
}: {
value: AgentCategory
onChange: (c: AgentCategory) => void
}) {
const counts: Record<AgentCategory, number> = {
coding: 0,
productivity: 0,
}
for (const a of AGENTS) counts[a.category] += 1
return (
<div className="scrollbar-none flex items-center gap-0.5 overflow-x-auto rounded-full bg-[#0D121A] p-0.5 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)] w-full">
{CATEGORY_ORDER.map((cat) => {
const isActive = value === cat.id
return (
<button
key={cat.id}
type="button"
onClick={() => onChange(cat.id)}
className={cn(
dmSansClassName(),
"flex flex-1 h-7 shrink-0 items-center justify-center gap-1.5 rounded-full px-3 text-[12px] font-medium leading-none transition-colors",
isActive
? "bg-white/[0.10] text-[#FAFAFA]"
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
)}
>
<span className="leading-none">{cat.label}</span>
<span
className={cn(
"text-[10px] font-semibold tabular-nums leading-none",
isActive ? "text-[#A1A1AA]" : "text-[#525D6E]",
)}
>
{counts[cat.id]}
</span>
</button>
)
})}
</div>
)
}
function AgentRow({
agent,
active,
onClick,
}: {
agent: Agent
active: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
dmSansClassName(),
"w-full text-left flex items-center gap-2.5 rounded-[10px] px-2.5 py-2 transition-colors",
active
? "bg-white/[0.08] text-[#fafafa]"
: "text-[#A1A1AA] hover:bg-white/[0.04] hover:text-[#fafafa]",
)}
>
<div className="size-8 rounded-[8px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)] flex items-center justify-center shrink-0 overflow-hidden">
<Image
src={agentIcon(agent)}
alt={agent.name}
width={24}
height={24}
unoptimized
className="size-5 object-contain"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium truncate">{agent.name}</p>
<p className="text-[11px] text-[#737373] truncate font-medium">
{agent.tagline}
</p>
</div>
</button>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,331 @@
"use client"
import { useMemo, useState } from "react"
import { Button } from "@ui/components/button"
import { Input } from "@ui/components/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/select"
import { ArrowRight, Loader2, Mail, Plus, Trash2, Users } from "lucide-react"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
const modalCardStyle = {
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}
const inputBevelStyle = {
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
}
const inputClass =
"bg-[#0F1217] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#525D6E] h-12 shadow-none focus-visible:ring-0 focus-visible:border-[rgba(115,115,115,0.3)] transition-colors"
export interface TeamValues {
invites: { email: string; role: "admin" | "member" }[]
visibility: "team-private" | "org-shared"
suggestChanges: boolean
}
interface Props {
mode: "personal" | "team"
isScale: boolean
inviteDomain: string | null
values: TeamValues
onChange: (next: TeamValues) => void
onContinue: () => void
onSkip?: () => void
onUpgrade: () => void
submitting?: boolean
}
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi
export function StepTeam({
mode,
inviteDomain,
values,
onChange,
onContinue,
onSkip,
submitting,
}: Props) {
const [draft, setDraft] = useState("")
const domainOrFallback = (inviteDomain || "acme.com").trim().toLowerCase()
const addInvites = (text: string) => {
const found = text.match(EMAIL_RE) ?? []
if (found.length === 0) return
const existing = new Set(values.invites.map((i) => i.email.toLowerCase()))
const next: { email: string; role: "admin" | "member" }[] = []
for (const raw of found) {
const email = raw.trim().toLowerCase()
if (!email || existing.has(email)) continue
existing.add(email)
next.push({ email, role: "member" })
}
if (next.length === 0) {
setDraft("")
return
}
onChange({ ...values, invites: [...values.invites, ...next] })
setDraft("")
}
const removeInvite = (email: string) => {
onChange({
...values,
invites: values.invites.filter((i) => i.email !== email),
})
}
const setRole = (email: string, role: "admin" | "member") => {
onChange({
...values,
invites: values.invites.map((i) =>
i.email === email ? { ...i, role } : i,
),
})
}
const domainBreakdown = useMemo(() => {
const counts = new Map<string, number>()
for (const inv of values.invites) {
const at = inv.email.lastIndexOf("@")
if (at < 0) continue
const domain = inv.email.slice(at + 1).toLowerCase()
counts.set(domain, (counts.get(domain) ?? 0) + 1)
}
return [...counts.entries()].sort((a, b) => b[1] - a[1])
}, [values.invites])
if (mode === "personal") {
return (
<div
className="max-w-xl mx-auto rounded-[22px] bg-[#1B1F24] p-10 text-center"
style={modalCardStyle}
>
<div
className="size-12 rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center mx-auto"
style={inputBevelStyle}
>
<Users className="size-5 text-[#737373]" />
</div>
<p
className={cn(
"text-[20px] font-semibold text-[#fafafa] mt-5",
dmSans125ClassName(),
)}
>
Going solo — for now
</p>
<p className="text-[14px] text-[#737373] mt-2 leading-[1.5] font-medium">
You're in Personal mode, so there's no team step. Switch to Team in
the top bar anytime to invite others.
</p>
<Button
variant="insideOut"
onClick={onContinue}
className="mt-6 rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
Continue
<ArrowRight className="size-3.5" />
</Button>
</div>
)
}
const count = values.invites.length
return (
<div className="max-w-2xl mx-auto space-y-5">
<section
className="rounded-[22px] bg-[#1B1F24] p-7 md:p-8"
style={modalCardStyle}
>
<div className="flex items-start gap-4">
<div
className="size-12 rounded-[14px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center shrink-0"
style={inputBevelStyle}
>
<Users className="size-5 text-[#fafafa]" />
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
"text-[20px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Invite your team
</p>
<p className="text-[14px] text-[#737373] mt-1 leading-[1.5] font-medium">
A brain gets sharper as more people contribute. You can also do
this later.
</p>
</div>
</div>
<form
onSubmit={(e) => {
e.preventDefault()
addInvites(draft)
}}
className="mt-6 flex gap-2"
>
<div className="relative flex-1">
<Mail className="size-4 absolute left-3.5 top-1/2 -translate-y-1/2 text-[#737373]" />
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onPaste={(e) => {
const pasted = e.clipboardData.getData("text")
if (pasted && EMAIL_RE.test(pasted)) {
e.preventDefault()
addInvites(pasted)
}
}}
placeholder={`alex@${domainOrFallback}, sam@${domainOrFallback}, …`}
className={cn(inputClass, "pl-10")}
style={inputBevelStyle}
/>
</div>
<Button
type="submit"
variant="insideOut"
disabled={!draft.trim()}
className="rounded-full h-12 px-4 text-[13px] font-medium text-[#fafafa]"
>
<Plus className="size-4" />
Add
</Button>
</form>
<p className="text-[11px] text-[#525D6E] font-medium mt-2 pl-2">
Paste multiple emails at once — we'll split them for you.
</p>
{count === 0 ? (
<div className="mt-6 rounded-[14px] border border-dashed border-[rgba(82,89,102,0.3)] bg-[#14161A]/40 px-5 py-8 text-center">
<p className="text-[13px] text-[#737373] font-medium">
No invites yet.
</p>
<p className="text-[12px] text-[#525D6E] font-medium mt-1">
Try{" "}
<span className="text-[#A1A1AA]">alex@{domainOrFallback}</span>,{" "}
<span className="text-[#A1A1AA]">sam@{domainOrFallback}</span>,
etc.
</p>
</div>
) : (
<>
<div className="mt-6 flex items-center justify-between gap-3 px-1">
<p className="text-[10px] uppercase tracking-[0.08em] text-[#737373] font-semibold">
{count} invite{count === 1 ? "" : "s"}
</p>
{domainBreakdown.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-[#737373] font-medium">
{domainBreakdown.slice(0, 3).map(([d, n]) => (
<span
key={d}
className="px-2 py-0.5 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)]"
>
{d} · {n}
</span>
))}
</div>
)}
</div>
<div className="mt-3 space-y-1.5 max-h-[280px] overflow-y-auto scrollbar-thin pr-1">
{values.invites.map((inv) => (
<div
key={inv.email}
className="flex items-center gap-3 rounded-[12px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] px-3 py-2"
style={inputBevelStyle}
>
<div className="size-7 rounded-full bg-[#0D121A] border border-[rgba(115,115,115,0.15)] flex items-center justify-center shrink-0">
<span className="text-[11px] font-semibold text-[#A1A1AA] uppercase">
{(inv.email[0] ?? "?").toUpperCase()}
</span>
</div>
<span className="text-[13px] text-[#fafafa] flex-1 font-medium truncate">
{inv.email}
</span>
<Select
value={inv.role}
onValueChange={(r) =>
setRole(inv.email, r as "admin" | "member")
}
>
<SelectTrigger className="w-24 h-7 bg-transparent border border-[rgba(82,89,102,0.2)] rounded-full text-[#A1A1AA] text-[11px] font-medium px-3 shadow-none focus:ring-0">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#14161A] border-[rgba(82,89,102,0.2)] rounded-[12px]">
<SelectItem
value="member"
className="text-[#fafafa] focus:bg-[#1B1F24] text-[13px]"
>
Member
</SelectItem>
<SelectItem
value="admin"
className="text-[#fafafa] focus:bg-[#1B1F24] text-[13px]"
>
Admin
</SelectItem>
</SelectContent>
</Select>
<button
type="button"
onClick={() => removeInvite(inv.email)}
className="text-[#737373] hover:text-[#fafafa] p-1 transition-colors"
aria-label={`Remove ${inv.email}`}
>
<Trash2 className="size-4" />
</button>
</div>
))}
</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>
)
}

View file

@ -0,0 +1,131 @@
export type BrainMode = "personal" | "team"
export type BrainStep = "about" | "sources" | "ingest" | "team"
export const BRAIN_STEPS: BrainStep[] = ["about", "sources", "ingest", "team"]
export const BRAIN_STEP_LABELS: Record<BrainStep, string> = {
about: "About",
sources: "Tools",
ingest: "Flows",
team: "Team",
}
const FREE_EMAIL_DOMAINS = new Set([
"gmail.com",
"googlemail.com",
"yahoo.com",
"yahoo.co.uk",
"yahoo.co.in",
"outlook.com",
"hotmail.com",
"live.com",
"icloud.com",
"me.com",
"mac.com",
"aol.com",
"protonmail.com",
"proton.me",
"pm.me",
"fastmail.com",
"zoho.com",
"yandex.com",
"yandex.ru",
"mail.com",
"qq.com",
"163.com",
"126.com",
"naver.com",
"duck.com",
])
export function detectModeFromEmail(
email: string | undefined | null,
): BrainMode {
if (!email) return "personal"
const at = email.lastIndexOf("@")
if (at < 0) return "personal"
const domain = email
.slice(at + 1)
.toLowerCase()
.trim()
if (!domain) return "personal"
if (FREE_EMAIL_DOMAINS.has(domain)) return "personal"
return "team"
}
export function workspaceNameFromEmail(
email: string | undefined | null,
): string {
if (!email) return ""
const at = email.lastIndexOf("@")
if (at < 0) return ""
const domain = email.slice(at + 1).toLowerCase()
const root = domain.split(".")[0] ?? ""
if (!root) return ""
return root.charAt(0).toUpperCase() + root.slice(1)
}
export function workspaceDomainFromEmail(
email: string | undefined | null,
): string | null {
if (!email) return null
const at = email.lastIndexOf("@")
if (at < 0) return null
const domain = email
.slice(at + 1)
.toLowerCase()
.trim()
return domain || null
}
export type BrainMetadata = {
brainOnboardingVersion?: "v1"
brainOnboardingComplete?: boolean
brainMode?: BrainMode
brainWorkspaceName?: string
brainWorkspaceDomain?: string | null
brainAbout?: string
brainContainerTag?: string
brainSources?: {
drive?: { status: "connected" | "pending" | "skipped" }
gmail?: { status: "requested" | "connected" | "skipped"; range?: string }
notion?: { status: "connected" | "pending" | "skipped" }
granola?: { status: "waitlist" | "skipped" }
}
brainInvites?: { email: string; role: "admin" | "member" }[]
brainPermissions?: {
visibility: "team-private" | "org-shared"
suggestChanges: boolean
}
}
export function generateOrgSlug(name: string): string {
const base =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "org"
return `${base}-${Math.floor(100000 + Math.random() * 900000)}`
}
export function generateUsername(name: string): string {
const base =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/(^_|_$)/g, "") || "user"
return `${base}${Math.floor(100000 + Math.random() * 900000)}`
}
export function containerTagFromWorkspace(
name: string,
mode: BrainMode,
): string {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
if (!slug) return mode === "team" ? "team-brain" : "personal-brain"
return mode === "team" ? `${slug}-brain` : `${slug}-personal`
}

View file

@ -3,7 +3,7 @@
import { useState, useMemo, useEffect, useCallback, useRef } from "react"
import Image from "next/image"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { Drawer, DrawerContent, DrawerTitle } from "@repo/ui/components/drawer"
import { cn } from "@lib/utils"
import { useIsMobile } from "@hooks/use-mobile"
@ -1128,14 +1128,14 @@ export function SelectSpacesModal({
>
<div className="flex items-start justify-between gap-4 px-4 pt-4">
<div className="pl-1 space-y-1 flex-1">
<p
<DialogTitle
className={cn(
"font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Select Space
</p>
</DialogTitle>
<p className="text-[#737373] font-medium text-[14px] leading-[1.35]">
{isBulkDeleteMode
? "Choose spaces to permanently delete"

View file

@ -40,13 +40,18 @@ import { toast } from "sonner"
import { useContainerTags } from "@/hooks/use-container-tags"
import { PopoverAnchor } from "@ui/components/popover"
import { OrgContext } from "@/components/settings/org-context"
import { OrgPlanBadge } from "@/components/org-plan-badge"
import { useTokenUsage } from "@/hooks/use-token-usage"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
import { useCustomer } from "autumn-js/react"
import { FileText, Layers, Plug, Search } from "lucide-react"
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
)}
>
{children}
@ -127,6 +132,10 @@ function isPendingInvitation(invitation: {
export default function Account() {
const { user, org, refetchActiveOrg, refetchOrganizations } = useAuth()
const autumn = useCustomer()
const { currentPlan, searchesUsed } = useTokenUsage(autumn)
const { data: orgSummaries } = useOrgSummaries()
const orgSummary = orgSummaries?.find((s) => s.orgId === org?.id)
const [inviteDialogOpen, setInviteDialogOpen] = useState(false)
const [inviteEmail, setInviteEmail] = useState("")
const [inviteRole, setInviteRole] = useState<InviteRole>("member")
@ -202,6 +211,11 @@ export default function Account() {
[org?.invitations],
)
const showTeamCard =
!canManageTeam ||
pendingInvitations.length > 0 ||
(org?.members?.length ?? 0) > 1
const resetInviteForm = () => {
setInviteEmail("")
setInviteRole("member")
@ -372,30 +386,29 @@ export default function Account() {
: "—"
return (
<div className="flex flex-col gap-8 w-full">
<section id="profile-details" className="flex flex-col gap-4">
<SectionTitle>Profile Details</SectionTitle>
<div className="flex flex-col gap-5 w-full">
<section id="profile-details">
<SettingsCard>
<div className="flex flex-col gap-6">
{/* Avatar + Name/Email */}
<div className="flex min-w-0 items-center gap-4">
<div className="relative size-16 rounded-full bg-linear-to-b from-[#0D121A] to-black overflow-hidden shrink-0">
<div className="flex flex-col gap-5">
{/* Identity */}
<div className="flex items-start gap-3">
<div className="relative size-11 rounded-full bg-linear-to-b from-[#0D121A] to-black overflow-hidden shrink-0">
<Avatar className="size-full">
<AvatarImage
src={user?.image ?? ""}
alt={user?.name ?? "User"}
className="object-cover"
/>
<AvatarFallback className="bg-transparent text-white text-xl">
<AvatarFallback className="bg-transparent text-white text-base">
{user?.name?.charAt(0) ?? "U"}
</AvatarFallback>
</Avatar>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex min-w-0 flex-1 flex-col gap-0.5 pt-0.5">
<p
className={cn(
dmSans125ClassName(),
"truncate font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA]",
"truncate font-semibold text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
)}
>
{user?.name ?? "—"}
@ -403,28 +416,32 @@ export default function Account() {
<p
className={cn(
dmSans125ClassName(),
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
"truncate text-[13px] tracking-[-0.13px] text-[#737373]",
)}
>
{user?.email ?? "—"}
</p>
</div>
<div className="shrink-0">
<OrgPlanBadge plan={currentPlan} />
</div>
</div>
<div className="flex flex-col gap-4 sm:flex-row">
<div className="flex min-w-0 flex-1 flex-col gap-2">
<p
{/* Fields */}
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 flex-col gap-1">
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
"text-[12px] tracking-[-0.12px] text-[#737373]",
)}
>
Organization
</p>
</span>
{isEditingOrgName ? (
<form
onSubmit={handleOrgNameSubmit}
className="flex min-w-0 max-w-full items-center gap-1.5 sm:max-w-[360px]"
className="flex min-w-0 max-w-full items-center gap-1.5 sm:max-w-[320px]"
>
<input
value={orgNameDraft}
@ -433,51 +450,47 @@ export default function Account() {
maxLength={80}
className={cn(
dmSans125ClassName(),
"h-9 min-w-0 flex-1 rounded-[9px] border border-white/10 bg-black/30 px-3 text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA] outline-none transition-colors placeholder:text-[#525252] focus:border-[#4BA0FA]/60",
"h-8 min-w-0 flex-1 rounded-[9px] border border-white/10 bg-black/30 px-2.5 text-[13px] font-medium tracking-[-0.13px] text-[#FAFAFA] outline-none transition-colors placeholder:text-[#525252] focus:border-[#4BA0FA]/60",
)}
placeholder="Organization name"
/>
<div className="flex shrink-0 items-center gap-1">
<button
type="submit"
disabled={
updateOrgNameMutation.isPending ||
!orgNameDraft.trim() ||
orgNameDraft.trim() === org?.name
}
className={cn(
dmSans125ClassName(),
"inline-flex h-8 items-center justify-center gap-1 rounded-full border border-transparent bg-[#0D121A] px-2.5 text-[11px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{updateOrgNameMutation.isPending ? (
<LoaderIcon className="size-3 animate-spin" />
) : null}
Save
</button>
<button
type="button"
disabled={updateOrgNameMutation.isPending}
aria-label="Cancel organization name edit"
title="Cancel"
onClick={() => {
setOrgNameDraft(org?.name ?? "")
setIsEditingOrgName(false)
}}
className={cn(
"inline-flex size-8 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-colors hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<X className="size-3.5" />
</button>
</div>
<button
type="submit"
disabled={
updateOrgNameMutation.isPending ||
!orgNameDraft.trim() ||
orgNameDraft.trim() === org?.name
}
className={cn(
dmSans125ClassName(),
"inline-flex h-7 items-center justify-center gap-1 rounded-full border border-transparent bg-[#0D121A] px-2.5 text-[11px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{updateOrgNameMutation.isPending ? (
<LoaderIcon className="size-3 animate-spin" />
) : null}
Save
</button>
<button
type="button"
disabled={updateOrgNameMutation.isPending}
aria-label="Cancel organization name edit"
title="Cancel"
onClick={() => {
setOrgNameDraft(org?.name ?? "")
setIsEditingOrgName(false)
}}
className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-colors hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-50"
>
<X className="size-3.5" />
</button>
</form>
) : (
<div className="flex min-w-0 max-w-full items-center gap-2">
<div className="flex min-w-0 items-center gap-1.5">
<span
className={cn(
dmSans125ClassName(),
"truncate font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
"truncate font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
)}
>
{org?.name ?? "Personal"}
@ -491,49 +504,110 @@ export default function Account() {
setOrgNameDraft(org?.name ?? "")
setIsEditingOrgName(true)
}}
className={cn(
"inline-flex size-7 shrink-0 items-center justify-center rounded-md text-[#FAFAFA] transition-colors hover:bg-white/5",
)}
className="inline-flex size-5 shrink-0 items-center justify-center rounded-md text-[#737373] transition-colors hover:bg-white/5 hover:text-[#FAFAFA]"
>
<Pencil className="size-3.5" />
<Pencil className="size-3" />
</button>
) : null}
</div>
)}
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<p
<div className="flex shrink-0 flex-col items-end gap-1 text-right">
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#737373]",
"text-[12px] tracking-[-0.12px] text-[#737373]",
)}
>
Member since
</p>
<p
</span>
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] tracking-[-0.16px] text-[#FAFAFA]",
"font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
)}
>
{memberSince}
</p>
</span>
</div>
</div>
</div>
</SettingsCard>
</section>
<section className="flex flex-col gap-3 px-1">
<SectionTitle>Overview</SectionTitle>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{[
{
label: "Memories",
value: orgSummary?.documentCount,
icon: FileText,
},
{
label: "Spaces",
value: orgSummary?.containerTagCount,
icon: Layers,
},
{
label: "Connections",
value: orgSummary?.activeConnectors,
icon: Plug,
},
{ label: "Searches", value: searchesUsed, icon: Search },
].map(({ label, value, icon: Icon }) => (
<div
key={label}
className="flex flex-col gap-3 rounded-[14px] bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
>
<span className="flex size-8 items-center justify-center rounded-[10px] bg-[#0D121A] text-[#737373] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]">
<Icon className="size-4" />
</span>
<div className="flex flex-col gap-0.5">
<span
className={cn(
dmSans125ClassName(),
"text-[24px] font-semibold leading-none tracking-[-0.5px] text-[#FAFAFA] tabular-nums",
)}
>
{typeof value === "number" ? value.toLocaleString() : "—"}
</span>
<span
className={cn(
dmSans125ClassName(),
"text-[12px] tracking-[-0.12px] text-[#737373]",
)}
>
{label}
</span>
</div>
</div>
))}
</div>
</section>
<OrgContext />
<section id="team-members" className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3 px-2">
<section id="team-members" className="flex flex-col gap-4 px-1">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-col gap-1">
<SectionTitle>Team members</SectionTitle>
<div className="flex items-baseline gap-1.5">
<SectionTitle>Team members</SectionTitle>
{(org?.members?.length ?? 0) > 0 && (
<span
className={cn(
dmSans125ClassName(),
"text-[12px] tabular-nums text-[#737373]",
)}
>
{org?.members?.length}
</span>
)}
</div>
<p
className={cn(
dmSans125ClassName(),
"text-[13px] tracking-[-0.13px] text-[#737373] px-2",
"text-[13px] tracking-[-0.13px] text-[#737373]",
)}
>
Invite people into {org?.name ?? "your organization"} and manage
@ -541,17 +615,6 @@ export default function Account() {
</p>
</div>
<div className="flex items-center gap-3">
{(org?.members?.length ?? 0) > 0 && (
<span
className={cn(
dmSans125ClassName(),
"text-[13px] tracking-[-0.13px] text-[#737373] tabular-nums",
)}
>
{org?.members?.length}{" "}
{org?.members?.length === 1 ? "member" : "members"}
</span>
)}
{canManageTeam && (
<button
type="button"
@ -568,234 +631,209 @@ export default function Account() {
)}
</div>
</div>
<SettingsCard>
<div className="flex flex-col gap-5">
{!canManageTeam && (
<div className="flex items-center gap-3 rounded-[12px] border border-white/[0.06] bg-white/[0.02] p-3">
<div className="size-9 rounded-full bg-white/[0.04] flex items-center justify-center shrink-0">
<Users className="size-4 text-[#737373]" />
{showTeamCard && (
<div className="rounded-[14px] bg-[#14161A] p-2 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<div className="flex flex-col gap-2">
{!canManageTeam && (
<div className="flex items-center gap-3 rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
<div className="size-8 rounded-full bg-white/[0.04] flex items-center justify-center shrink-0">
<Users className="size-4 text-[#737373]" />
</div>
<p
className={cn(
dmSans125ClassName(),
"text-[12.5px] tracking-[-0.12px] text-[#737373]",
)}
>
Only organization owners and admins can invite teammates or
change roles.
</p>
</div>
<p
className={cn(
dmSans125ClassName(),
"text-[13px] tracking-[-0.13px] text-[#737373]",
)}
>
Only organization owners and admins can invite teammates or
change roles.
</p>
</div>
)}
)}
{pendingInvitations.length > 0 && (
<div className="flex flex-col gap-2">
<p
className={cn(
dmSans125ClassName(),
"text-[11px] uppercase tracking-[0.12em] text-[#737373] font-mono",
)}
>
Pending invitations
</p>
<ul className="flex flex-col rounded-[12px] border border-white/[0.05] overflow-hidden">
{pendingInvitations.map((invitation) => (
<li
key={invitation.id}
className="flex items-center gap-3 px-3 py-2.5 border-t border-white/[0.04] first:border-t-0 bg-white/[0.015]"
>
<div className="size-9 rounded-full bg-[#0D121A] flex items-center justify-center shrink-0">
<Mail className="size-4 text-[#737373]" />
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
dmSans125ClassName(),
"truncate text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA]",
)}
>
{invitation.email}
</p>
<p
className={cn(
dmSans125ClassName(),
"text-[12px] tracking-[-0.12px] text-[#737373]",
)}
>
Invited as {formatRole(invitation.role)}
</p>
</div>
{canManageTeam && (
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
disabled={cancelInvitationMutation.isPending}
onClick={() =>
cancelInvitationMutation.mutate(invitation.id)
}
className="flex size-8 items-center justify-center rounded-[8px] text-[#8A5247] hover:bg-[#1A0F0C]/60 hover:text-[#C73B1B] disabled:opacity-50"
aria-label={`Cancel invitation for ${invitation.email}`}
title="Cancel invitation"
>
<X className="size-4" />
</button>
</div>
)}
</li>
))}
</ul>
</div>
)}
{org?.members && org.members.length > 0 ? (
<ul className="flex flex-col">
{[...org.members]
.sort((a, b) => {
const rolePriority = (r: string) =>
r === "owner" ? 0 : r === "admin" ? 1 : 2
const diff =
rolePriority(a.role.toLowerCase()) -
rolePriority(b.role.toLowerCase())
if (diff !== 0) return diff
return (a.user?.name ?? "").localeCompare(
b.user?.name ?? "",
)
})
.map((m, idx) => {
const isYou = m.userId === user?.id
const memberRole = m.role.toLowerCase()
const name = m.user?.name ?? m.user?.email ?? "Unknown"
const canEditMember =
canManageTeam && !isYou && memberRole !== "owner"
return (
{pendingInvitations.length > 0 && (
<div className="flex flex-col gap-2">
<p
className={cn(
dmSans125ClassName(),
"text-[11px] uppercase tracking-[0.12em] text-[#737373] font-mono",
)}
>
Pending invitations
</p>
<ul className="flex flex-col rounded-[12px] border border-white/[0.05] overflow-hidden">
{pendingInvitations.map((invitation) => (
<li
key={m.id}
className={cn(
"flex items-center gap-3 py-2.5",
idx > 0 && "border-t border-white/[0.04]",
)}
key={invitation.id}
className="flex items-center gap-3 px-3 py-2.5 border-t border-white/[0.04] first:border-t-0 bg-white/[0.015]"
>
<Avatar className="size-9 shrink-0 bg-[#0D121A]">
<AvatarImage
src={m.user?.image ?? ""}
alt={name}
className="object-cover"
/>
<AvatarFallback className="bg-transparent text-white text-[13px] font-medium">
{(name.charAt(0) || "U").toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
<div className="flex items-center gap-2 min-w-0">
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA] truncate",
)}
<div className="size-9 rounded-full bg-[#0D121A] flex items-center justify-center shrink-0">
<Mail className="size-4 text-[#737373]" />
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
dmSans125ClassName(),
"truncate text-[14px] font-medium tracking-[-0.14px] text-[#FAFAFA]",
)}
>
{invitation.email}
</p>
<p
className={cn(
dmSans125ClassName(),
"text-[12px] tracking-[-0.12px] text-[#737373]",
)}
>
Invited as {formatRole(invitation.role)}
</p>
</div>
{canManageTeam && (
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
disabled={cancelInvitationMutation.isPending}
onClick={() =>
cancelInvitationMutation.mutate(invitation.id)
}
className="flex size-8 items-center justify-center rounded-[8px] text-[#8A5247] hover:bg-[#1A0F0C]/60 hover:text-[#C73B1B] disabled:opacity-50"
aria-label={`Cancel invitation for ${invitation.email}`}
title="Cancel invitation"
>
{name}
</span>
{isYou && (
<X className="size-4" />
</button>
</div>
)}
</li>
))}
</ul>
</div>
)}
{org?.members && org.members.length > 0 && (
<ul className="flex flex-col">
{[...org.members]
.sort((a, b) => {
const rolePriority = (r: string) =>
r === "owner" ? 0 : r === "admin" ? 1 : 2
const diff =
rolePriority(a.role.toLowerCase()) -
rolePriority(b.role.toLowerCase())
if (diff !== 0) return diff
return (a.user?.name ?? "").localeCompare(
b.user?.name ?? "",
)
})
.map((m) => {
const isYou = m.userId === user?.id
const memberRole = m.role.toLowerCase()
const name = m.user?.name ?? m.user?.email ?? "Unknown"
const canEditMember =
canManageTeam && !isYou && memberRole !== "owner"
return (
<li
key={m.id}
className="flex items-center gap-3 rounded-[10px] px-3 py-2.5 hover:bg-white/[0.02]"
>
<Avatar className="size-8 shrink-0 bg-[#0D121A]">
<AvatarImage
src={m.user?.image ?? ""}
alt={name}
className="object-cover"
/>
<AvatarFallback className="bg-transparent text-white text-[13px] font-medium">
{(name.charAt(0) || "U").toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
<div className="flex items-center gap-2 min-w-0">
<span
className={cn(
dmSans125ClassName(),
"text-[10.5px] uppercase tracking-[0.1em] text-[#737373] font-mono",
"font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA] truncate",
)}
>
You
{name}
</span>
{isYou && (
<span
className={cn(
dmSans125ClassName(),
"text-[10.5px] uppercase tracking-[0.1em] text-[#737373] font-mono",
)}
>
You
</span>
)}
</div>
{m.user?.email && (
<span
className={cn(
dmSans125ClassName(),
"text-[12px] tracking-[-0.12px] text-[#737373] truncate",
)}
>
{m.user.email}
</span>
)}
</div>
{m.user?.email && (
<span
className={cn(
dmSans125ClassName(),
"text-[12px] tracking-[-0.12px] text-[#737373] truncate",
)}
{canEditMember ? (
<Select
value={memberRole}
onValueChange={(value) => {
if (value === memberRole) return
updateMemberRoleMutation.mutate({
memberId: m.id,
role: value as InviteRole,
})
}}
>
{m.user.email}
</span>
<SelectTrigger className="h-8 w-[112px] rounded-[8px] border-white/[0.08] bg-[#0D0F14] text-[#FAFAFA]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
) : (
<RolePill role={m.role} />
)}
</div>
{canEditMember ? (
<Select
value={memberRole}
onValueChange={(value) => {
if (value === memberRole) return
updateMemberRoleMutation.mutate({
memberId: m.id,
role: value as InviteRole,
})
}}
>
<SelectTrigger className="h-8 w-[112px] rounded-[8px] border-white/[0.08] bg-[#0D0F14] text-[#FAFAFA]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
) : (
<RolePill role={m.role} />
)}
{canEditMember && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex size-8 shrink-0 items-center justify-center rounded-[8px] text-[#737373] hover:bg-white/[0.05] hover:text-[#FAFAFA]"
aria-label={`Team actions for ${name}`}
>
<MoreHorizontal className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
className="text-[#C73B1B] focus:text-[#C73B1B]"
onSelect={() =>
removeMemberMutation.mutate(m.id)
}
disabled={
removeMemberMutation.isPending || !isOwner
}
>
<UserMinus className="size-4" />
Remove member
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</li>
)
})}
</ul>
) : (
<div className="flex items-center gap-3 py-2">
<div className="size-9 rounded-full bg-white/[0.04] flex items-center justify-center shrink-0">
<Users className="size-4 text-[#737373]" />
</div>
<div className="flex flex-col">
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
)}
>
Just you for now
</span>
<span
className={cn(
dmSans125ClassName(),
"text-[12px] tracking-[-0.12px] text-[#737373]",
)}
>
Invite teammates to start collaborating.
</span>
</div>
</div>
)}
{canEditMember && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex size-8 shrink-0 items-center justify-center rounded-[8px] text-[#737373] hover:bg-white/[0.05] hover:text-[#FAFAFA]"
aria-label={`Team actions for ${name}`}
>
<MoreHorizontal className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
className="text-[#C73B1B] focus:text-[#C73B1B]"
onSelect={() =>
removeMemberMutation.mutate(m.id)
}
disabled={
removeMemberMutation.isPending || !isOwner
}
>
<UserMinus className="size-4" />
Remove member
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</li>
)
})}
</ul>
)}
</div>
</div>
</SettingsCard>
)}
</section>
<Dialog
@ -1148,7 +1186,7 @@ export default function Account() {
onClick={() => setInviteDialogOpen(false)}
className={cn(
dmSans125ClassName(),
"h-9 rounded-full border border-[#161F2C] bg-[#0D121A] px-4 text-[13px] font-medium text-[#737373] transition-colors hover:bg-[#14161A] hover:text-white",
"h-9 rounded-full px-4 text-[13px] font-medium text-[#737373] transition-colors hover:bg-white/[0.04] hover:text-white",
)}
>
Cancel

View file

@ -11,8 +11,15 @@ import {
DialogContent,
DialogTrigger,
} from "@ui/components/dialog"
import { Logo } from "@ui/assets/Logo"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useCustomer } from "autumn-js/react"
import { usePostHog } from "@lib/posthog"
import {
CANCEL_REASONS,
cancelReasonNeedsDetail,
type CancelReasonValue,
} from "./cancel-reasons"
import {
Check,
ChevronLeft,
@ -31,6 +38,38 @@ import { toast } from "sonner"
const API_BASE =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const BOOK_CALL_HREF = "https://cal.com/maheshthedev/15min"
function GoogleMeetIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 87.5 72"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
fill="#00832d"
d="M49.5 36l8.53 9.75 11.47 7.33 2-17.02-2-16.64-11.69 6.44z"
/>
<path
fill="#0066da"
d="M0 51.5V66c0 3.315 2.685 6 6 6h14.5l3-10.96-3-9.54-9.95-3z"
/>
<path fill="#e94235" d="M20.5 0L0 20.5l10.55 3 9.95-3 2.95-9.41z" />
<path fill="#2684fc" d="M20.5 20.5H0v31h20.5z" />
<path
fill="#00ac47"
d="M82.6 8.68L69.5 19.42v33.66l13.16 10.79c1.97 1.54 4.85.135 4.85-2.37V11c0-2.535-2.945-3.925-4.91-2.32zM49.5 36v15.5h-29V72h43c3.315 0 6-2.685 6-6V53.08z"
/>
<path
fill="#ffba00"
d="M63.5 0h-43v20.5h29V36l20-16.57V6c0-3.315-2.685-6-6-6z"
/>
</svg>
)
}
const CREDIT_FEATURE_ID = "usd_credits"
const TOP_UP_PLAN_ID = "credits_topup"
const TOP_UP_AMOUNTS = [10, 25, 50, 100] as const
@ -436,12 +475,16 @@ function getInvoiceProductLabel(productId: string | undefined): string {
export default function Billing() {
const queryClient = useQueryClient()
const { user, org } = useAuth()
const autumn = useCustomer({ expand: ["payment_method"] })
const autumn = useCustomer()
const posthog = usePostHog()
const [isUpgrading, setIsUpgrading] = useState(false)
const [isCancelling, setIsCancelling] = useState(false)
const [isResuming, setIsResuming] = useState(false)
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false)
const [cancelConfirmText, setCancelConfirmText] = useState("")
const [cancelReason, setCancelReason] = useState<CancelReasonValue | null>(
null,
)
const [cancelDetail, setCancelDetail] = useState("")
const [isCreditsDialogOpen, setIsCreditsDialogOpen] = useState(false)
const [isPlanCarouselActive, setIsPlanCarouselActive] = useState(false)
const [planPage, setPlanPage] = useState<0 | 1>(0)
@ -578,16 +621,11 @@ export default function Billing() {
? (`api_${currentPlan}` as const)
: null
const currentPlanCard = [...PLAN_CARDS, ...ADVANCED_PLAN_CARDS].find(
(p) => p.id === currentPlan,
)
const cancelLossItems = currentPlanCard
? [
`${currentPlanCard.credits}/mo included credits`,
...currentPlanCard.features.filter((f) => !/credit/i.test(f)),
]
: []
const canConfirmCancel = cancelConfirmText.trim().toUpperCase() === "CANCEL"
const cancelNeedsDetail =
cancelReason != null && cancelReasonNeedsDetail(cancelReason)
const canConfirmCancel =
cancelReason != null &&
(!cancelNeedsDetail || cancelDetail.trim().length > 0)
const canceledSub = getCanceledSubscription(autumn.data?.subscriptions)
const isPlanCanceling = canceledSub != null
@ -627,6 +665,11 @@ export default function Billing() {
}
}
const resetCancelForm = () => {
setCancelReason(null)
setCancelDetail("")
}
const handleCancelSubscription = async () => {
if (!cancellablePlanId) return
setIsCancelling(true)
@ -635,9 +678,18 @@ export default function Billing() {
planId: cancellablePlanId,
cancelAction: "cancel_end_of_cycle",
})
if (posthog?.__loaded) {
posthog.capture("subscription_cancelled", {
reason: cancelReason,
reason_detail: cancelDetail.trim() || null,
plan: currentPlan,
plan_id: cancellablePlanId,
surface: "nova",
})
}
autumn.refetch?.()
setIsCancelDialogOpen(false)
setCancelConfirmText("")
resetCancelForm()
toast.success(
`Subscription cancelled. ${planDisplayNames[currentPlan]} features remain active until the end of your billing period.`,
)
@ -934,7 +986,7 @@ export default function Billing() {
open={isCancelDialogOpen}
onOpenChange={(open) => {
setIsCancelDialogOpen(open)
if (!open) setCancelConfirmText("")
if (!open) resetCancelForm()
}}
>
<DialogTrigger asChild>
@ -950,7 +1002,7 @@ export default function Billing() {
</DialogTrigger>
<DialogContent
showCloseButton={false}
className="w-[min(420px,calc(100vw-32px))] rounded-[18px] border border-white/10 bg-[#14161A] p-5 shadow-[0px_16px_60px_rgba(0,0,0,0.55)]"
className="w-[min(760px,calc(100vw-32px))] rounded-[18px] border border-white/10 bg-[#14161A] p-5 shadow-[0px_16px_60px_rgba(0,0,0,0.55)] sm:max-w-[760px]"
>
<div className="flex items-start justify-between gap-4">
<div>
@ -985,74 +1037,177 @@ export default function Billing() {
</button>
</DialogClose>
</div>
{cancelLossItems.length > 0 ? (
<div className="mt-4 rounded-[10px] border border-white/[0.06] bg-[#0D121A] p-3.5">
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.18em] text-[#737373]">
You'll lose
</p>
<ul className="mt-2.5 flex flex-col gap-1.5">
{cancelLossItems.map((item) => (
<li
className="flex items-start gap-2 text-[13px] leading-snug text-[#C8D0DA]"
key={item}
<div className="@container">
<div className="grid gap-4 @xl:grid-cols-[1fr_auto_1fr] @xl:gap-5">
<div className="relative flex flex-col overflow-hidden rounded-[14px] border border-[#1E4FA0]/25 bg-gradient-to-b from-[#121826] to-[#0C0E13] p-5">
<Logo className="pointer-events-none absolute -right-8 -bottom-10 z-0 h-56 w-auto opacity-[0.03]" />
<div className="relative z-10 flex flex-1 flex-col">
<div className="flex items-center gap-2.5">
<GoogleMeetIcon className="h-[18px] w-auto" />
<p
className={cn(
dmSans125ClassName(),
"text-[15px] font-semibold tracking-[-0.01em] text-white",
)}
>
Talk to us first
</p>
</div>
<p
className={cn(
dmSans125ClassName(),
"mt-3 text-[13px] leading-relaxed text-[#AEBCD8]",
)}
>
<X className="mt-0.5 size-3.5 shrink-0 text-[#C73B1B]" />
<span>{item}</span>
</li>
))}
</ul>
Give us 15 minutes before you go — a quick call
with the team where we'll:
</p>
<ul className="mt-3 flex flex-col gap-2.5">
{[
"Fix anything that's not working for you",
"Help you integrate Supermemory the right way",
"Get a discount to stay",
].map((benefit) => (
<li
className={cn(
dmSans125ClassName(),
"flex items-start gap-2 text-[13px] leading-snug text-[#D4DEEE]",
)}
key={benefit}
>
<Check
className="mt-0.5 size-[15px] shrink-0 text-[#3FD37E]"
strokeWidth={2.5}
/>
<span>{benefit}</span>
</li>
))}
</ul>
<a
href={BOOK_CALL_HREF}
target="_blank"
rel="noreferrer"
onClick={() => {
if (posthog?.__loaded) {
posthog.capture(
"cancel_book_call_clicked",
{
plan: currentPlan,
surface: "nova",
},
)
}
}}
className={cn(
dmSans125ClassName(),
"group mt-auto inline-flex h-10 items-center justify-center gap-1.5 rounded-[10px] bg-[#0054AD] px-4 text-[13px] font-semibold text-white transition-colors hover:bg-[#0B65C9]",
)}
>
Book a call
<ChevronRight className="size-4 transition-transform group-hover:translate-x-0.5" />
</a>
</div>
</div>
<div className="flex items-center justify-center">
<span className="rounded-full px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[#737373]">
or
</span>
</div>
<div className="flex flex-1 flex-col rounded-[14px] border border-white/10 bg-gradient-to-b from-[#12151B] to-[#0C0E13] p-5">
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.18em] text-[#737373]">
Why are you leaving?
</p>
<div className="mt-2 flex flex-col gap-0.5">
{CANCEL_REASONS.map((option) => {
const selected = cancelReason === option.value
return (
<button
key={option.value}
type="button"
onClick={() =>
setCancelReason(option.value)
}
className={cn(
dmSans125ClassName(),
"flex items-center gap-2.5 rounded-[8px] px-2 py-1.5 text-left text-[13px] transition-colors",
selected
? "text-[#FAFAFA]"
: "text-[#A3A3A3] hover:bg-white/[0.03]",
)}
>
<span
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-full border",
selected
? "border-[#0054AD]"
: "border-white/25",
)}
>
{selected ? (
<span className="size-2 rounded-full bg-[#0054AD]" />
) : null}
</span>
{option.label}
</button>
)
})}
</div>
{cancelReason !== null ? (
<div className="mt-3">
<label
className={cn(
dmSans125ClassName(),
"text-[13px] text-[#A3A3A3]",
)}
htmlFor="cancel-detail"
>
{cancelNeedsDetail
? "Tell us more (required)"
: "Anything else? (optional)"}
</label>
<textarea
className="mt-2 min-h-[72px] w-full resize-none rounded-[9px] border border-white/10 bg-[#0D121A] px-3 py-2 text-[13px] text-[#FAFAFA] outline-none placeholder:text-[#3F4651] focus:border-white/25"
id="cancel-detail"
onChange={(e) =>
setCancelDetail(e.target.value)
}
placeholder="What could we have done better?"
value={cancelDetail}
/>
</div>
) : null}
<div className="mt-auto pt-5">
<DialogClose asChild>
<button
type="button"
className={cn(
dmSans125ClassName(),
"h-9 w-full text-[13px] font-medium text-[#A3A3A3] transition-colors hover:text-[#FAFAFA]",
)}
>
Keep plan
</button>
</DialogClose>
<button
type="button"
onClick={() => void handleCancelSubscription()}
disabled={isCancelling || !canConfirmCancel}
className={cn(
dmSans125ClassName(),
"mt-1.5 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[10px] border border-[#C73B1B]/25 bg-[#290F0A] text-[13px] font-semibold text-[#E5604A] transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{isCancelling ? (
<LoaderIcon className="size-3.5 animate-spin" />
) : null}
Cancel subscription
</button>
</div>
</div>
</div>
) : null}
<div className="mt-4">
<label
className={cn(
dmSans125ClassName(),
"text-[13px] text-[#A3A3A3]",
)}
htmlFor="cancel-confirm"
>
Type{" "}
<span className="font-semibold text-[#FAFAFA]">
CANCEL
</span>{" "}
to confirm
</label>
<input
autoComplete="off"
className="mt-2 h-9 w-full rounded-[9px] border border-white/10 bg-[#0D121A] px-3 text-[13px] text-[#FAFAFA] outline-none placeholder:text-[#3F4651] focus:border-white/25"
id="cancel-confirm"
onChange={(e) => setCancelConfirmText(e.target.value)}
placeholder="CANCEL"
type="text"
value={cancelConfirmText}
/>
</div>
<div className="mt-5 flex items-center justify-end gap-3">
<DialogClose asChild>
<button
type="button"
className={cn(
dmSans125ClassName(),
"h-9 px-3 text-[13px] font-medium text-[#A3A3A3] transition-colors hover:text-[#FAFAFA]",
)}
>
Keep plan
</button>
</DialogClose>
<button
type="button"
onClick={() => void handleCancelSubscription()}
disabled={isCancelling || !canConfirmCancel}
className={cn(
dmSans125ClassName(),
"inline-flex h-9 items-center gap-2 rounded-[9px] bg-[#290F0A] px-3 text-[13px] font-medium text-[#C73B1B] transition-opacity disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{isCancelling ? (
<LoaderIcon className="size-3.5 animate-spin" />
) : null}
Cancel subscription
</button>
</div>
</DialogContent>
</Dialog>

View file

@ -0,0 +1,19 @@
export const CANCEL_REASONS = [
{ value: "too_expensive", label: "Too expensive" },
{ value: "missing_features", label: "Missing features I need" },
{ value: "switching", label: "Found a better alternative" },
{ value: "not_using", label: "Not using it enough" },
{ value: "other", label: "Other" },
] as const
export type CancelReasonValue = (typeof CANCEL_REASONS)[number]["value"]
const NEEDS_DETAIL: CancelReasonValue[] = [
"missing_features",
"switching",
"other",
]
export function cancelReasonNeedsDetail(value: CancelReasonValue): boolean {
return NEEDS_DETAIL.includes(value)
}

View file

@ -23,8 +23,8 @@ import { useQueryState } from "nuqs"
import type { ConnectionResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { analytics } from "@/lib/analytics"
import { ConnectAIModal } from "@/components/connect-ai-modal"
import { AddDocumentModal } from "@/components/add-document"
import { useRouter } from "next/navigation"
import { RemoveConnectionDialog } from "@/components/remove-connection-dialog"
import { addDocumentParam } from "@/lib/search-params"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
@ -421,7 +421,7 @@ export default function ConnectionsMCP() {
const queryClient = useQueryClient()
const autumn = useCustomer()
const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam)
const [mcpModalOpen, setMcpModalOpen] = useState(false)
const router = useRouter()
const [removeDialog, setRemoveDialog] = useState<{
open: boolean
connection: Connection | null
@ -709,14 +709,14 @@ export default function ConnectionsMCP() {
</a>
</p>
<ConnectAIModal open={mcpModalOpen} onOpenChange={setMcpModalOpen}>
<PillButton onClick={() => setMcpModalOpen(true)}>
<Plus className="size-[10px] text-[#FAFAFA]" />
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
Connect your AI to Supermemory
</span>
</PillButton>
</ConnectAIModal>
<PillButton
onClick={() => router.push("/?view=integrations&cat=ai-clients")}
>
<Plus className="size-[10px] text-[#FAFAFA]" />
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">
Connect your AI to Supermemory
</span>
</PillButton>
</div>
</ConnectionsCard>
</div>

View file

@ -1,70 +1,17 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { LoaderIcon, Settings, X } from "lucide-react"
import { useEffect, useState } from "react"
import { LoaderIcon } from "lucide-react"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { useOrgSettings, useUpdateOrgSettings } from "@/hooks/use-org-settings"
import { cn } from "@lib/utils"
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
type ContextTemplate = {
id: string
label: string
description: string
prompt: string
}
const SURFACE_SHADOW =
"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 CONTEXT_TEMPLATES: ContextTemplate[] = [
{
id: "personal-general",
label: "General Personal Assistant",
description:
"Remember preferences, routines, relationships, plans, and life context.",
prompt: `Supermemory personal assistant. The user saves conversations, notes, and daily context.
EXTRACT:
- Preferences: "prefers morning meetings", "allergic to peanuts"
- Routines: "works out every Tuesday and Thursday"
- Relationships: "Sarah is their manager", "lives with roommate Jake"
- Plans: "planning a trip to Japan in March"
- Life events: "moved to Austin last month", "started a new job"
SKIP:
- Generic assistant suggestions the user did not confirm
- Pleasantries and small talk without factual content
- Repeated scheduling details already captured`,
},
{
id: "personal-productivity",
label: "Productivity Assistant",
description:
"Focus on tasks, decisions, deadlines, workflows, and blockers.",
prompt: `Supermemory productivity tool. The user saves meeting notes, task updates, and project context.
EXTRACT:
- Action items: "needs to send proposal to client by Friday"
- Decisions: "team decided to use Figma for design handoff"
- Deadlines: "Q3 review due September 15th"
- Workflows: "deploys happen every Wednesday via CI pipeline"
- Blockers: "waiting on legal approval before launch"
SKIP:
- Meeting filler ("let's circle back", "good point")
- Status updates that repeat previously captured information
- Agenda items with no outcome or decision`,
},
]
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[20px] tracking-[-0.2px] text-[#FAFAFA] px-2",
"font-semibold text-[14px] tracking-[-0.14px] text-[#FAFAFA]",
)}
>
{children}
@ -72,55 +19,9 @@ function SectionTitle({ children }: { children: React.ReactNode }) {
)
}
function SettingsCard({ children }: { children: React.ReactNode }) {
return (
<div
className={cn(
"relative bg-[#14161A] rounded-[14px] p-6 w-full overflow-hidden",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)}
>
{children}
</div>
)
}
function PillButton({
children,
onClick,
disabled,
variant = "default",
}: {
children: React.ReactNode
onClick: () => void
disabled?: boolean
variant?: "default" | "ghost" | "primary"
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
dmSansClassName(),
"inline-flex h-9 items-center justify-center gap-2 rounded-full px-4 text-[13px] font-semibold transition-[color,opacity] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
variant === "ghost"
? "px-3 font-medium text-[#737373] hover:bg-white/[0.04] hover:text-[#A3A3A3]"
: "border border-transparent bg-[#0D121A] text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] hover:opacity-80",
)}
>
{children}
</button>
)
}
export function OrgContext() {
const { data: settings, isLoading, isError } = useOrgSettings()
const updateSettings = useUpdateOrgSettings()
const [confirmDialog, setConfirmDialog] = useState<
"enable" | "disable" | null
>(null)
const [isManaging, setIsManaging] = useState(false)
const [prompt, setPrompt] = useState("")
const enabled = settings?.shouldLLMFilter ?? false
@ -132,255 +33,95 @@ export function OrgContext() {
setPrompt(settings?.filterPrompt ?? "")
}, [settings?.filterPrompt])
const selectedTemplateId = useMemo(() => {
const normalized = prompt.trim()
return CONTEXT_TEMPLATES.find(
(template) => template.prompt.trim() === normalized,
)?.id
}, [prompt])
const handleConfirmToggle = () => {
const newEnabled = confirmDialog === "enable"
updateSettings.mutate(
newEnabled
? { shouldLLMFilter: true }
: { shouldLLMFilter: false, filterPrompt: null },
{
onSuccess: () => {
setConfirmDialog(null)
if (!newEnabled) {
setIsManaging(false)
setPrompt("")
}
},
},
)
const handleToggle = (next: boolean) => {
updateSettings.mutate({ shouldLLMFilter: next })
}
const handleSave = () => {
updateSettings.mutate(
{
shouldLLMFilter: true,
filterPrompt: prompt.trim() ? prompt.trim() : null,
},
{
onSuccess: () => {
setIsManaging(false)
},
},
)
}
const handleCancel = () => {
setPrompt(savedPrompt)
setIsManaging(false)
updateSettings.mutate({
shouldLLMFilter: true,
filterPrompt: prompt.trim() ? prompt.trim() : null,
})
}
return (
<section id="organization-context" className="flex flex-col gap-4">
<div className="flex flex-col gap-1 px-2">
<SectionTitle>Organization Context</SectionTitle>
<p
<section id="organization-context" className="flex flex-col gap-3 px-1">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<SectionTitle>Organization Context</SectionTitle>
<p
className={cn(
dmSans125ClassName(),
"text-[13px] tracking-[-0.13px] text-[#737373]",
)}
>
Guide how Supermemory processes and remembers your content.
</p>
</div>
<button
type="button"
disabled={!settingsReady || updateSettings.isPending}
onClick={() => handleToggle(!enabled)}
className={cn(
dmSans125ClassName(),
"text-[13px] tracking-[-0.13px] text-[#737373] px-2",
dmSansClassName(),
"inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-full px-4 text-[12px] font-semibold transition-[color,opacity] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
enabled
? "text-[#737373] hover:bg-white/[0.04] hover:text-[#A3A3A3]"
: "bg-[#0D121A] text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] hover:opacity-80",
)}
>
Guide how Nova processes and remembers your content.
</p>
{updateSettings.isPending && (
<LoaderIcon className="size-3 animate-spin" />
)}
{enabled ? "DISABLE" : "ENABLE"}
</button>
</div>
<SettingsCard>
<div className={cn(dmSansClassName(), "flex flex-col gap-5")}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center">
<div className="min-w-0">
<p className="text-[15px] font-medium text-[#FAFAFA]">
{enabled ? "Context is enabled" : "Context is disabled"}
</p>
<p className="text-[13px] leading-snug text-[#737373]">
{enabled
? "New content will use your guidance when Nova creates memories."
: "Turn this on to tell Nova what matters most when it learns."}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<PillButton
onClick={() => setConfirmDialog(enabled ? "disable" : "enable")}
disabled={!settingsReady || updateSettings.isPending}
variant={enabled ? "ghost" : "primary"}
>
{enabled ? "DISABLE" : "ENABLE"}
</PillButton>
{enabled && (
<PillButton
onClick={() => setIsManaging(true)}
{enabled && (
<div className={cn(dmSansClassName(), "flex flex-col gap-2")}>
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="Tell Nova what to focus on, extract, and skip when turning your content into memories..."
maxLength={750}
className="min-h-[96px] w-full resize-y rounded-[12px] border border-white/[0.08] bg-[#0D121A] px-3.5 py-3 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.16] focus:outline-none"
/>
<div className="flex items-center justify-between">
<span className="text-[11px] text-[#737373] tabular-nums">
{prompt.length}/750
</span>
{dirty && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPrompt(savedPrompt)}
disabled={updateSettings.isPending}
className={cn(
dmSansClassName(),
"h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A3A3A3] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<Settings className="size-3.5" />
MANAGE
</PillButton>
)}
</div>
</div>
{enabled && !isManaging && savedPrompt && (
<div className="rounded-[12px] border border-white/[0.08] bg-[#0D121A] p-4">
<p className="line-clamp-6 whitespace-pre-wrap text-[13px] leading-relaxed text-[#A3A3A3]">
{savedPrompt}
</p>
</div>
)}
{enabled && !isManaging && !savedPrompt && (
<p className="text-[13px] italic text-[#737373]">
No organization context configured.{" "}
<button
type="button"
onClick={() => setIsManaging(true)}
className="text-[#4BA0FA] transition-colors hover:text-[#7BB8FF] cursor-pointer"
>
Set up now
</button>
</p>
)}
{enabled && isManaging && (
<div className="overflow-hidden rounded-[18px] border border-white/[0.08] bg-[#1B1F24]">
<div className="flex flex-col gap-4 p-4">
<label className="flex flex-col gap-2">
<span className="text-[13px] font-medium text-[#FAFAFA]">
What should Nova focus on?
</span>
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="Describe what Nova should extract, skip, and prioritize when turning your content into memories..."
className={cn(
dmSansClassName(),
"min-h-[180px] w-full resize-y rounded-[14px] border border-white/[0.08] bg-[#0D121A] p-4 text-[13px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.14] focus:outline-none",
)}
maxLength={750}
/>
<span className="self-end text-[11px] text-[#737373]">
{prompt.length}/750
</span>
</label>
<div className="grid gap-3 md:grid-cols-2">
{CONTEXT_TEMPLATES.map((template) => {
const isSelected = selectedTemplateId === template.id
return (
<button
key={template.id}
type="button"
onClick={() => setPrompt(template.prompt)}
className={cn(
"rounded-[14px] border p-4 text-left transition-colors cursor-pointer",
"bg-[#14161A] hover:bg-[#121820]",
isSelected
? "border-white/[0.16] bg-[#171B22]"
: "border-white/[0.08]",
)}
>
<p className="text-[14px] font-medium text-[#FAFAFA]">
{template.label}
</p>
<p className="mt-1 text-[12px] leading-snug text-[#737373]">
{template.description}
</p>
</button>
)
})}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-white/[0.08] bg-[#171B22] px-4 py-3">
<PillButton onClick={handleCancel} variant="ghost">
CANCEL
</PillButton>
<PillButton
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={!dirty || updateSettings.isPending}
variant="primary"
disabled={updateSettings.isPending}
className={cn(
dmSansClassName(),
"inline-flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{updateSettings.isPending && (
<LoaderIcon className="size-3.5 animate-spin" />
<LoaderIcon className="size-3 animate-spin" />
)}
SAVE
</PillButton>
Save
</button>
</div>
</div>
)}
</div>
</SettingsCard>
<Dialog
open={!!confirmDialog}
onOpenChange={(open) => {
if (!open) setConfirmDialog(null)
}}
>
<DialogContent
showCloseButton={false}
style={{
boxShadow: SURFACE_SHADOW,
}}
className={cn(
"sm:max-w-[440px] border border-white/[0.12] bg-[#1B1F24] p-0 gap-0 rounded-[22px] overflow-hidden",
dmSansClassName(),
)}
>
<div className="flex flex-col gap-4 p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5 flex-1">
<DialogTitle
className={cn(
dmSans125ClassName(),
"text-[18px] font-semibold tracking-[-0.18px] text-[#FAFAFA]",
)}
>
{confirmDialog === "enable"
? "Enable Organization Context?"
: "Disable Organization Context?"}
</DialogTitle>
<p
className={cn(
dmSans125ClassName(),
"text-[13px] tracking-[-0.13px] leading-relaxed text-[#737373]",
)}
>
{confirmDialog === "enable"
? "Nova will use your guidance when processing new content across this organization."
: "Nova will stop using this guidance for new content, and the saved context will be cleared."}
</p>
</div>
<DialogPrimitive.Close className="flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#737373] shadow-inside-out transition-opacity hover:opacity-80 focus:outline-hidden cursor-pointer">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
<div className="flex justify-end gap-2">
<PillButton
onClick={() => setConfirmDialog(null)}
variant="ghost"
>
CANCEL
</PillButton>
<PillButton
onClick={handleConfirmToggle}
disabled={updateSettings.isPending}
variant="primary"
>
{updateSettings.isPending && (
<LoaderIcon className="size-3.5 animate-spin" />
)}
{confirmDialog === "enable" ? "ENABLE" : "DISABLE"}
</PillButton>
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
)}
</section>
)
}

View file

@ -0,0 +1,538 @@
"use client"
import { Logo } from "@ui/assets/Logo"
import { useAuth } from "@lib/auth-context"
import NovaOrb from "@/components/nova/nova-orb"
import { useState } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import Account from "@/components/settings/account"
import Billing from "@/components/settings/billing"
import Integrations from "@/components/settings/integrations"
import ConnectionsMCP from "@/components/settings/connections-mcp"
import Support from "@/components/settings/support"
import { ErrorBoundary } from "@/components/error-boundary"
import { useRouter } from "next/navigation"
import { useIsMobile } from "@hooks/use-mobile"
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
import {
LogOut,
RotateCcw,
Trash2,
Sun,
LoaderIcon,
User as UserIcon,
Zap,
HelpCircle,
CreditCard,
ShieldAlert,
ChevronRight,
ArrowUpRight,
} from "lucide-react"
import { authClient } from "@lib/auth"
import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import { useResetOrganization } from "@/hooks/use-reset-organization"
import { useDeleteUserAccount } from "@/hooks/use-account-settings"
import { SettingsOrgSwitcher } from "@/components/settings/settings-org-switcher"
export const TABS = [
"account",
"billing",
"integrations",
"connections",
"support",
] as const
export type SettingsTab = (typeof TABS)[number]
type NavItem = {
id: SettingsTab
label: string
description: string
icon: React.ReactNode
}
const NAV_ITEMS: NavItem[] = [
{
id: "account",
label: "Account",
description: "Your profile and organization",
icon: <UserIcon className="size-[18px]" />,
},
{
id: "billing",
label: "Billing",
description: "Plan, usage and payments",
icon: <CreditCard className="size-[18px]" />,
},
{
id: "integrations",
label: "Integrations",
description: "Save, sync and search across tools",
icon: <Sun className="size-[18px]" />,
},
{
id: "connections",
label: "Connections & MCP",
description: "Drive, Notion, OneDrive, MCP",
icon: <Zap className="size-[18px]" />,
},
{
id: "support",
label: "Support & Help",
description: "Get help or share feedback",
icon: <HelpCircle className="size-[18px]" />,
},
]
export function parseHashToTab(hash: string): SettingsTab {
const cleaned = hash.replace("#", "").toLowerCase()
return TABS.includes(cleaned as SettingsTab)
? (cleaned as SettingsTab)
: "account"
}
function IdentityCard({ displayName }: { displayName: string }) {
const firstName = displayName?.split(" ")[0] || ""
return (
<div className="relative flex items-center justify-center h-[140px]">
<NovaOrb size={150} className="blur-[3px]!" />
<div className="absolute inset-0 flex items-center justify-center z-10">
<Logo className="h-7 shrink-0 text-white" />
<div
className={cn(
"flex flex-col items-start justify-center ml-3.5",
dmSansClassName(),
)}
>
<p className="text-white text-[14px] font-medium leading-none">
{firstName ? `${firstName}'s` : "Your"}
</p>
<p className="text-white font-bold text-[20px] leading-none mt-1">
supermemory
</p>
</div>
</div>
</div>
)
}
export function SettingsContent({
activeTab,
onTabChange,
className,
showIdentity = true,
}: {
activeTab: SettingsTab
onTabChange: (tab: SettingsTab) => void
className?: string
showIdentity?: boolean
}) {
const { user, org } = useAuth()
const router = useRouter()
const isMobile = useIsMobile()
const localStorageUsername = useLocalStorageUsername()
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false)
const [resetConfirmation, setResetConfirmation] = useState("")
const resetOrganization = useResetOrganization()
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("")
const deleteUserAccount = useDeleteUserAccount()
const [dangerMenuOpen, setDangerMenuOpen] = useState(false)
const displayName =
user?.displayUsername ||
localStorageUsername ||
user?.name ||
user?.email?.split("@")[0] ||
""
const handleLogout = async () => {
await authClient.signOut()
router.push("/login")
}
const handleIntegrations = () => {
void router.push("/?view=integrations")
}
const handleDeleteAccount = async () => {
if (deleteEmailConfirm !== user?.email) return
deleteUserAccount.mutate(
{ confirmation: deleteEmailConfirm },
{
onSuccess: () => {
setIsDeleteDialogOpen(false)
setDeleteEmailConfirm("")
router.push("/login")
},
},
)
}
return (
<>
<div
className={cn("flex flex-col md:flex-row gap-4 md:gap-5", className)}
>
{/* Left rail */}
<aside className="md:flex md:h-full md:min-h-0 md:w-[248px] md:flex-col shrink-0">
{showIdentity && !isMobile && (
<div className="mb-4">
<IdentityCard displayName={displayName} />
</div>
)}
<div className="mb-3">
<SettingsOrgSwitcher />
</div>
<nav
className={cn(
"flex",
isMobile
? "flex-row gap-2 overflow-x-auto pb-2 scrollbar-thin"
: "min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
dmSansClassName(),
)}
>
{NAV_ITEMS.map((item) => {
const isExternal = item.id === "integrations"
const isActive = !isExternal && activeTab === item.id
return (
<button
key={item.id}
type="button"
onClick={() =>
isExternal ? handleIntegrations() : onTabChange(item.id)
}
className={cn(
"relative rounded-xl transition-colors flex items-center gap-3 shrink-0 group",
isMobile
? "px-3 py-2 text-sm border border-white/[0.06]"
: "text-left px-3 py-2",
isActive
? "bg-[#14161A] text-white shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]"
: "text-white/60 hover:text-white hover:bg-white/[0.025]",
)}
>
{!isMobile && (
<span
aria-hidden
className={cn(
"absolute left-0 top-1/2 -translate-y-1/2 w-[3px] rounded-r-full transition-all",
isActive
? "h-5 bg-[#4BA0FA]"
: "h-0 bg-transparent group-hover:h-3 group-hover:bg-white/20",
)}
/>
)}
<span
className={cn(
"shrink-0 transition-colors",
isActive ? "text-white" : "text-white/45",
)}
>
{item.icon}
</span>
<span className="font-medium text-[14px] whitespace-nowrap">
{item.label}
</span>
{isExternal && (
<ArrowUpRight className="ml-auto size-4 shrink-0 text-white/30 transition-colors group-hover:text-white/60" />
)}
</button>
)
})}
{!isMobile && <div className="flex-1" />}
<button
type="button"
onClick={handleLogout}
className={cn(
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer text-white/60 hover:text-white hover:bg-white/[0.025]",
isMobile
? "px-3 py-2 text-sm border border-white/[0.06]"
: "text-left px-3 py-2",
)}
>
<LogOut className="size-[18px] shrink-0 text-white/45" />
<span className="font-medium text-[14px] whitespace-nowrap">
Log out
</span>
</button>
<Popover open={dangerMenuOpen} onOpenChange={setDangerMenuOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"group rounded-xl transition-colors flex items-center gap-3 shrink-0 cursor-pointer",
isMobile
? "px-3 py-2 text-sm border border-[#3A211C]"
: "text-left px-3 py-2.5 mt-1",
dangerMenuOpen
? "bg-[#1A0F0C] text-[#C73B1B]"
: "text-[#8A5247] hover:text-[#C73B1B] hover:bg-[#1A0F0C]/60",
)}
>
<ShieldAlert className="size-[18px] shrink-0" />
<span className="font-medium text-[14px] whitespace-nowrap">
Danger zone
</span>
<ChevronRight
className={cn(
"size-4 ml-auto shrink-0 transition-transform opacity-60",
dangerMenuOpen ? "-rotate-90" : "rotate-0",
)}
/>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
sideOffset={8}
className={cn(
"w-[var(--radix-popover-trigger-width)] min-w-[248px] p-1.5 bg-[#14161A] border-white/10 rounded-[14px]",
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
dmSansClassName(),
)}
>
<button
type="button"
onClick={() => {
setDangerMenuOpen(false)
setIsResetDialogOpen(true)
}}
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#A37A2E] hover:text-[#C7991B] hover:bg-[#1A1200]/60 transition-colors cursor-pointer"
>
<RotateCcw className="size-[16px] shrink-0" />
<span className="font-medium text-[13.5px]">Reset data</span>
</button>
<div className="my-1 h-px bg-white/[0.06]" />
<button
type="button"
onClick={() => {
setDangerMenuOpen(false)
setIsDeleteDialogOpen(true)
}}
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#C73B1B] hover:bg-[#290F0A]/60 transition-colors cursor-pointer"
>
<Trash2 className="size-[16px] shrink-0" />
<span className="font-semibold text-[13.5px]">
Delete account
</span>
</button>
</PopoverContent>
</Popover>
</nav>
</aside>
{/* Content */}
<section className="flex-1 min-w-0 flex flex-col md:overflow-y-auto md:max-w-4xl [scrollbar-gutter:stable] md:pr-2">
<ErrorBoundary
key={activeTab}
fallback={
<p className="text-muted-foreground p-4">
Something went wrong loading this section.{" "}
<button
type="button"
className="underline cursor-pointer"
onClick={() => window.location.reload()}
>
Reload
</button>
</p>
}
>
{activeTab === "account" && <Account />}
{activeTab === "billing" && <Billing />}
{activeTab === "integrations" && <Integrations />}
{activeTab === "connections" && <ConnectionsMCP />}
{activeTab === "support" && <Support />}
</ErrorBoundary>
</section>
</div>
{/* Reset data dialog */}
{(() => {
const confirmText = org?.name || user?.name || ""
return (
<Dialog
open={isResetDialogOpen}
onOpenChange={(open) => {
setIsResetDialogOpen(open)
if (!open) setResetConfirmation("")
}}
>
<DialogContent className="sm:max-w-md">
<div
className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}
>
<div className="flex flex-col gap-1.5">
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
Reset all data?
</h2>
<p className="text-sm text-[#8B8B8B]">
This permanently removes:
</p>
<ul className="text-sm text-[#8B8B8B] list-disc pl-5 space-y-0.5 mt-1">
<li>All documents and memories</li>
<li>All connections (Google Drive, Notion, etc.)</li>
<li>All custom spaces (default space stays)</li>
<li>Organization settings and filters</li>
</ul>
<p className="text-sm text-[#8B8B8B] mt-1">
Your account and billing plan stay intact.{" "}
<strong className="text-[#FAFAFA]">
This cannot be undone.
</strong>
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-[#8B8B8B]">
Type{" "}
<strong className="text-[#FAFAFA]">
{confirmText || "your name"}
</strong>{" "}
to confirm:
</p>
<input
type="text"
value={resetConfirmation}
onChange={(e) => setResetConfirmation(e.target.value)}
placeholder={confirmText || "Your name"}
autoComplete="off"
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C7991B]/50 transition-colors"
/>
</div>
<div className="flex gap-3 justify-end">
<DialogClose asChild>
<button
type="button"
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
>
Cancel
</button>
</DialogClose>
<button
type="button"
disabled={
!confirmText ||
resetConfirmation !== confirmText ||
resetOrganization.isPending
}
title={
!confirmText || resetConfirmation !== confirmText
? `Type "${confirmText || "your name"}" exactly to enable`
: undefined
}
onClick={() =>
resetOrganization.mutate(
{ confirmation: confirmText },
{
onSuccess: () => {
setIsResetDialogOpen(false)
setResetConfirmation("")
},
},
)
}
className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#1A1200] text-[#C7991B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
>
{resetOrganization.isPending ? (
<LoaderIcon className="size-[15px] animate-spin" />
) : (
<RotateCcw className="size-[15px]" />
)}
{resetOrganization.isPending
? "Resetting…"
: "Reset organization"}
</button>
</div>
</div>
</DialogContent>
</Dialog>
)
})()}
{/* Delete account dialog */}
<Dialog
open={isDeleteDialogOpen}
onOpenChange={(open) => {
setIsDeleteDialogOpen(open)
if (!open) setDeleteEmailConfirm("")
}}
>
<DialogContent className="sm:max-w-md">
<div className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}>
<div className="flex flex-col gap-1.5">
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
Delete your account?
</h2>
<p className="text-sm text-[#8B8B8B]">
Permanently deletes all your data and cancels any active
subscriptions.{" "}
<strong className="text-[#FAFAFA]">
This cannot be undone.
</strong>
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-[#8B8B8B]">
Type your email{" "}
<strong className="text-[#FAFAFA]">{user?.email}</strong> to
confirm:
</p>
<input
type="email"
value={deleteEmailConfirm}
onChange={(e) => setDeleteEmailConfirm(e.target.value)}
placeholder={user?.email ?? "your@email.com"}
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C73B1B]/50 transition-colors"
/>
</div>
<div className="flex gap-3 justify-end">
<DialogClose asChild>
<button
type="button"
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
>
Cancel
</button>
</DialogClose>
<button
type="button"
disabled={
deleteEmailConfirm !== user?.email ||
deleteUserAccount.isPending
}
title={
deleteEmailConfirm !== user?.email
? `Type ${user?.email ?? "your email"} exactly to confirm`
: undefined
}
onClick={handleDeleteAccount}
className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
>
{deleteUserAccount.isPending ? (
<LoaderIcon className="size-[15px] animate-spin" />
) : (
<Trash2 className="size-[15px]" />
)}
{deleteUserAccount.isPending ? "Deleting…" : "Delete account"}
</button>
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -0,0 +1,142 @@
"use client"
import {
createContext,
useCallback,
useContext,
useMemo,
type ReactNode,
} from "react"
import { useQueryState } from "nuqs"
import {
Dialog,
DialogClose,
DialogContent,
DialogTitle,
} from "@ui/components/dialog"
import { X } from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import { analytics } from "@/lib/analytics"
import {
SettingsContent,
TABS,
type SettingsTab,
} from "@/components/settings/settings-content"
const SETTINGS_PARAM = "settings"
/** Integrations is a link-out, not a modal tab, so it falls back to account. */
function parseTab(value: string | null): SettingsTab {
if (
value &&
value !== "integrations" &&
TABS.includes(value as SettingsTab)
) {
return value as SettingsTab
}
return "account"
}
type SettingsModalContextValue = {
open: boolean
openSettings: (tab?: SettingsTab) => void
close: () => void
}
const SettingsModalContext = createContext<SettingsModalContextValue | null>(
null,
)
export function SettingsModalProvider({ children }: { children: ReactNode }) {
const [param, setParam] = useQueryState(SETTINGS_PARAM)
const open = param !== null
const tab = parseTab(param)
const openSettings = useCallback(
(next?: SettingsTab) => {
setParam(next ?? "account")
},
[setParam],
)
const close = useCallback(() => setParam(null), [setParam])
const handleTabChange = useCallback(
(next: SettingsTab) => {
setParam(next)
analytics.settingsTabChanged({ tab: next })
},
[setParam],
)
const value = useMemo(
() => ({ open, openSettings, close }),
[open, openSettings, close],
)
return (
<SettingsModalContext.Provider value={value}>
{children}
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) setParam(null)
}}
>
<DialogContent
showCloseButton={false}
style={{
display: "flex",
width: "calc(100vw - 1rem)",
maxWidth: "1160px",
height: "min(840px, 90vh)",
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",
}}
className={cn(
"flex-col min-w-0 border-none bg-[#1B1F24] p-0 gap-0 rounded-[22px] overflow-hidden",
dmSansClassName(),
)}
>
<div className="flex items-center justify-between gap-4 px-5 md:px-7 pt-5 shrink-0">
<DialogTitle
className={cn(
"font-semibold text-[18px] text-[#fafafa]",
dmSans125ClassName(),
)}
>
Settings
</DialogTitle>
<DialogClose
className="flex size-7 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.2)] bg-[#0D121A] text-[#737373] transition-colors hover:text-white cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<X className="size-3.5" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
<SettingsContent
activeTab={tab}
onTabChange={handleTabChange}
showIdentity={false}
className="flex-1 min-h-0 w-full overflow-y-auto md:overflow-hidden px-5 md:px-4 pt-4 pb-6"
/>
</DialogContent>
</Dialog>
</SettingsModalContext.Provider>
)
}
export function useSettingsModal() {
const ctx = useContext(SettingsModalContext)
if (!ctx) {
throw new Error(
"useSettingsModal must be used within a SettingsModalProvider",
)
}
return ctx
}

View file

@ -0,0 +1,262 @@
"use client"
import { useMemo, useState } from "react"
import { useCustomer } from "autumn-js/react"
import { toast } from "sonner"
import {
Building2,
Check,
ChevronsUpDown,
LoaderIcon,
Plus,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import { useAuth } from "@lib/auth-context"
import { authClient } from "@lib/auth"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
import { OrgPlanBadge, resolveOrgPlan } from "@/components/org-plan-badge"
import { useOrgSummaries } from "@/hooks/use-org-summaries"
import { useTokenUsage, type PlanType } from "@/hooks/use-token-usage"
const SURFACE_SHADOW =
"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"
function generateOrgSlug(name: string): string {
const base =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "org"
return `${base}-${Math.floor(100000 + Math.random() * 900000)}`
}
export function SettingsOrgSwitcher() {
const { org, organizations, setActiveOrg } = useAuth()
const autumn = useCustomer()
const { currentPlan } = useTokenUsage(autumn)
const { data: orgSummaries } = useOrgSummaries()
const [open, setOpen] = useState(false)
const [switchingId, setSwitchingId] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [createName, setCreateName] = useState("")
const [creating, setCreating] = useState(false)
const planByOrgId = useMemo(() => {
const map = new Map<string, PlanType>()
for (const summary of orgSummaries ?? []) {
map.set(summary.orgId, summary.plan)
}
return map
}, [orgSummaries])
const activeOrgPlan = org?.id
? resolveOrgPlan(org.id, true, currentPlan, planByOrgId)
: currentPlan
const sortedOrgs = useMemo(
() =>
[...(organizations ?? [])].sort((a, b) => a.name.localeCompare(b.name)),
[organizations],
)
const handleSwitch = async (slug: string, id: string) => {
if (id === org?.id) {
setOpen(false)
return
}
setSwitchingId(id)
try {
await setActiveOrg(slug)
window.location.reload()
} catch (error) {
console.error("Failed to switch organization:", error)
setSwitchingId(null)
toast.error("Failed to switch organization")
}
}
const handleCreate = async () => {
const name = createName.trim()
if (!name || creating) return
setCreating(true)
try {
const result = await authClient.organization.create({
name,
slug: generateOrgSlug(name),
metadata: { signupSource: "consumer" },
})
if (result.error) {
throw new Error(result.error.message ?? "Failed to create organization")
}
await setActiveOrg(result.data?.slug ?? "")
window.location.reload()
} catch (error) {
setCreating(false)
toast.error(
error instanceof Error
? error.message
: "Failed to create organization",
)
}
}
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
dmSansClassName(),
"flex w-full items-center gap-2 rounded-[12px] border border-white/[0.06] bg-white/[0.03] px-2.5 py-2 transition-colors cursor-pointer hover:bg-white/[0.06]",
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-white/[0.05] text-white/55">
<Building2 className="size-[13px]" />
</span>
<span className="min-w-0 flex-1 truncate text-left text-[13px] font-medium text-white">
{org?.name ?? "Personal"}
</span>
<OrgPlanBadge plan={activeOrgPlan} />
<ChevronsUpDown className="size-3.5 shrink-0 text-white/40" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
style={{
maxHeight:
"min(360px, var(--radix-popover-content-available-height))",
}}
className={cn(
"w-[var(--radix-popover-trigger-width)] min-w-[220px] overflow-y-auto p-1.5",
"bg-[#14161A] border-white/10 rounded-[14px]",
"shadow-[0px_8px_28px_rgba(0,0,0,0.5)]",
dmSansClassName(),
)}
>
{sortedOrgs.map((organization) => {
const isCurrent = organization.id === org?.id
const isSwitching = switchingId === organization.id
const plan = resolveOrgPlan(
organization.id,
isCurrent,
currentPlan,
planByOrgId,
)
return (
<button
key={organization.id}
type="button"
disabled={isCurrent || isSwitching}
onClick={() => handleSwitch(organization.slug, organization.id)}
className={cn(
"w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left transition-colors",
isCurrent ? "bg-white/5" : "hover:bg-white/5 cursor-pointer",
"disabled:cursor-default",
)}
>
<Building2 className="size-4 shrink-0 text-white/40" />
<span className="min-w-0 flex-1 truncate text-[13.5px] text-white">
{organization.name}
</span>
{isSwitching ? (
<LoaderIcon className="size-4 shrink-0 animate-spin text-[#4BA0FA]" />
) : isCurrent ? (
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
) : null}
<OrgPlanBadge plan={plan} />
</button>
)
})}
<div className="my-1 h-px bg-white/[0.06]" />
<button
type="button"
onClick={() => {
setOpen(false)
setCreateOpen(true)
}}
className="w-full flex items-center gap-2.5 rounded-[10px] px-3 py-2 text-left text-[#A3A3A3] transition-colors hover:bg-white/5 hover:text-white cursor-pointer"
>
<Plus className="size-4 shrink-0" />
<span className="text-[13.5px] font-medium">
Create organization
</span>
</button>
</PopoverContent>
</Popover>
<Dialog
open={createOpen}
onOpenChange={(next) => {
setCreateOpen(next)
if (!next) setCreateName("")
}}
>
<DialogContent
showCloseButton={false}
style={{ boxShadow: SURFACE_SHADOW }}
className={cn(
"sm:max-w-[420px] border border-white/[0.12] bg-[#1B1F24] p-5 gap-0 rounded-[22px]",
dmSansClassName(),
)}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<DialogTitle
className={cn(
dmSans125ClassName(),
"text-[18px] font-semibold tracking-[-0.18px] text-[#FAFAFA]",
)}
>
Create organization
</DialogTitle>
<p className="text-[13px] tracking-[-0.13px] leading-relaxed text-[#737373]">
A separate workspace with its own memories, connections, and
members.
</p>
</div>
<input
value={createName}
onChange={(e) => setCreateName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate()
}}
placeholder="Organization name"
maxLength={80}
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#4BA0FA]/50 transition-colors"
/>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setCreateOpen(false)}
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="button"
disabled={!createName.trim() || creating}
onClick={handleCreate}
className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#0D121A] text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
>
{creating ? (
<LoaderIcon className="size-[15px] animate-spin" />
) : (
<Plus className="size-[15px]" />
)}
{creating ? "Creating…" : "Create"}
</button>
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -1,9 +1,14 @@
"use client"
import { Brain, X } from "lucide-react"
import { Brain, Loader, X } from "lucide-react"
import { motion } from "motion/react"
import { useEffect, useState } from "react"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { useSpaceProfile } from "@/hooks/use-space-profile"
import {
useSpaceContext,
useUpdateSpaceContext,
} from "@/hooks/use-space-context"
import { cn } from "@lib/utils"
type SpaceProfilePanelProps = {
@ -87,6 +92,85 @@ function ProfileSection({ label, items }: { label: string; items: string[] }) {
)
}
function SpaceContextEditor({ containerTag }: { containerTag: string }) {
const { data, isLoading } = useSpaceContext(containerTag)
const update = useUpdateSpaceContext()
const [value, setValue] = useState("")
const saved = data?.entityContext ?? ""
useEffect(() => {
setValue(data?.entityContext ?? "")
}, [data?.entityContext])
const dirty = value.trim() !== saved.trim()
const handleSave = () => {
update.mutate({
containerTag,
entityContext: value.trim() ? value.trim() : null,
})
}
return (
<div className="flex flex-col gap-2 rounded-[14px] border border-white/[0.08] bg-[#14161A] p-3">
<div className="flex flex-col gap-0.5">
<span
className={cn(
dmSans125ClassName(),
"text-[12px] font-semibold text-[#FAFAFA]",
)}
>
What to remember
</span>
<span className="text-[11px] leading-relaxed text-[#737373]">
Tell Nova what matters in this space — it shapes which memories get
extracted.
</span>
</div>
<textarea
value={value}
onChange={(event) => setValue(event.target.value)}
disabled={isLoading || update.isPending}
placeholder="e.g. This space tracks Acme's billing project — decisions, owners, and deadlines."
maxLength={750}
className="min-h-[80px] w-full resize-y rounded-[10px] border border-white/[0.08] bg-[#0D121A] px-3 py-2.5 text-[12px] leading-relaxed text-[#FAFAFA] placeholder:text-[#525966] focus:border-white/[0.16] focus:outline-none disabled:opacity-60"
/>
<div className="flex items-center justify-between">
<span className="text-[11px] text-[#737373] tabular-nums">
{value.length}/750
</span>
{dirty && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setValue(saved)}
disabled={update.isPending}
className={cn(
dmSansClassName(),
"h-7 rounded-full px-3 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#A3A3A3] cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
disabled={update.isPending}
className={cn(
dmSansClassName(),
"inline-flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-semibold text-[#FAFAFA] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{update.isPending && <Loader className="size-3 animate-spin" />}
Save
</button>
</div>
)}
</div>
</div>
)
}
export function SpaceProfileContent({
containerTag,
onClose,
@ -125,20 +209,23 @@ export function SpaceProfileContent({
</div>
<div className="min-h-0 flex-1 overflow-y-auto pt-4 scrollbar-thin">
{isLoading ? (
<LoadingState />
) : error ? (
<p className="rounded-[12px] border border-white/[0.08] bg-[#14161A] p-3 text-[13px] text-[#A3A3A3]">
Failed to load space profile.
</p>
) : totalCount === 0 ? (
<EmptyState />
) : (
<div className="flex flex-col gap-5">
<ProfileSection label="Key Facts" items={keyFacts} />
<ProfileSection label="Recent Context" items={recentContext} />
</div>
)}
<SpaceContextEditor containerTag={containerTag} />
<div className="mt-5">
{isLoading ? (
<LoadingState />
) : error ? (
<p className="rounded-[12px] border border-white/[0.08] bg-[#14161A] p-3 text-[13px] text-[#A3A3A3]">
Failed to load space profile.
</p>
) : totalCount === 0 ? (
<EmptyState />
) : (
<div className="flex flex-col gap-5">
<ProfileSection label="Key Facts" items={keyFacts} />
<ProfileSection label="Recent Context" items={recentContext} />
</div>
)}
</div>
</div>
</div>
)

View file

@ -7,10 +7,11 @@ import { cn } from "@lib/utils"
import { $fetch } from "@lib/api"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import { ChevronDownIcon, XIcon, Loader2, Trash2 } from "lucide-react"
import { ChevronDownIcon, Pencil, XIcon, Loader2, Trash2 } from "lucide-react"
import type { ContainerTagListType } from "@lib/types"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import { AddSpaceModal } from "./add-space-modal"
import { EditSpaceModal } from "./edit-space-modal"
import { SelectSpacesModal } from "./select-spaces-modal"
import { SpaceGlyph } from "./space-glyph"
import { useProjectMutations } from "@/hooks/use-project-mutations"
@ -54,6 +55,7 @@ export interface SpaceSelectorProps {
compact?: boolean
includeAuto?: boolean
hideCount?: boolean
enableEdit?: boolean
}
const triggerVariants = {
@ -114,9 +116,11 @@ export function SpaceSelector({
compact = false,
includeAuto = false,
hideCount = false,
enableEdit = false,
}: SpaceSelectorProps) {
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [showSelectSpacesModal, setShowSelectSpacesModal] = useState(false)
const [showEditDialog, setShowEditDialog] = useState(false)
const [recents, setRecents] = useState<string[]>([])
const [deleteDialog, setDeleteDialog] = useState<{
open: boolean
@ -231,6 +235,12 @@ export function SpaceSelector({
}
}, [allProjects, selectedProjects, pluginMetaMap, includeAuto, user?.id])
const canEditCurrent =
enableEdit &&
!displayInfo.isAuto &&
!displayInfo.plugin &&
!displayInfo.isOwnSpace
const pushRecent = useCallback((tag: string) => {
setRecents((prev) => {
const next = [tag, ...prev.filter((t) => t !== tag)].slice(0, RECENTS_MAX)
@ -371,85 +381,115 @@ export function SpaceSelector({
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<div className="group relative inline-flex min-w-0 max-w-full">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setShowSelectSpacesModal(true)}
aria-label={
isLoading
? "Loading spaces"
: `Space: ${displayInfo.name}. Open selector.`
}
className={cn(
"flex min-w-0 max-w-full items-center cursor-pointer transition-colors",
triggerVariants[variant],
variant === "default" &&
compact &&
"h-9 min-h-9 gap-1.5 px-2.5",
dmSansClassName(),
triggerClassName,
)}
>
<span
className={cn(
"flex shrink-0 items-center",
canEditCurrent && "transition-opacity group-hover:opacity-0",
)}
>
{displayInfo.isAuto ? (
<AutoSpaceIcon size={compact ? 16 : 18} />
) : displayInfo.isOwnSpace ? (
<NovaOrb
size={compact ? 14 : 16}
className="shrink-0 blur-[0.45px]!"
/>
) : displayInfo.plugin ? (
displayInfo.plugin.iconSrc ? (
<Image
src={displayInfo.plugin.iconSrc}
alt=""
width={16}
height={16}
className={cn(
"shrink-0 rounded-[3px]",
compact ? "size-3.5" : "size-4",
)}
aria-hidden
/>
) : (
<span
className={cn(
"shrink-0 flex items-center justify-center rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase",
compact ? "size-3.5" : "size-4",
)}
aria-hidden
>
{pluginInitial(displayInfo.plugin.label)}
</span>
)
) : (
<SpaceGlyph
emoji={displayInfo.emoji}
size={compact ? 16 : 18}
/>
)}
</span>
<span
className={cn(
"min-w-0 truncate text-sm font-medium text-white",
compact ? "max-w-[7rem]" : "max-w-[10rem] md:max-w-[15rem]",
)}
title={isLoading ? undefined : displayInfo.name}
>
{isLoading ? "…" : displayInfo.name}
</span>
{!compact &&
!hideCount &&
spaceCountData !== undefined &&
spaceCountData > 0 && (
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
· {formatCount(spaceCountData)}
</span>
)}
<ChevronDownIcon
className="size-3.5 shrink-0 text-[#737373]"
aria-hidden
/>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Switch space
</TooltipContent>
</Tooltip>
{canEditCurrent && (
<button
type="button"
onClick={() => setShowSelectSpacesModal(true)}
aria-label={
isLoading
? "Loading spaces"
: `Space: ${displayInfo.name}. Open selector.`
}
onClick={(event) => {
event.stopPropagation()
setShowEditDialog(true)
}}
aria-label={`Edit ${displayInfo.name}`}
className={cn(
"flex min-w-0 max-w-full items-center cursor-pointer transition-colors",
triggerVariants[variant],
variant === "default" && compact && "h-9 min-h-9 gap-1.5 px-2.5",
dmSansClassName(),
triggerClassName,
"pointer-events-none absolute top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-md text-[#A1A1AA] opacity-0 transition-opacity hover:text-white group-hover:pointer-events-auto group-hover:opacity-100",
compact ? "left-2.5 size-4" : "left-3 size-[18px]",
)}
>
{displayInfo.isAuto ? (
<AutoSpaceIcon size={compact ? 16 : 18} />
) : displayInfo.isOwnSpace ? (
<NovaOrb
size={compact ? 14 : 16}
className="shrink-0 blur-[0.45px]!"
/>
) : displayInfo.plugin ? (
displayInfo.plugin.iconSrc ? (
<Image
src={displayInfo.plugin.iconSrc}
alt=""
width={16}
height={16}
className={cn(
"shrink-0 rounded-[3px]",
compact ? "size-3.5" : "size-4",
)}
aria-hidden
/>
) : (
<span
className={cn(
"shrink-0 flex items-center justify-center rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase",
compact ? "size-3.5" : "size-4",
)}
aria-hidden
>
{pluginInitial(displayInfo.plugin.label)}
</span>
)
) : (
<SpaceGlyph emoji={displayInfo.emoji} size={compact ? 16 : 18} />
)}
<span
className={cn(
"min-w-0 truncate text-sm font-medium text-white",
compact ? "max-w-[7rem]" : "max-w-[10rem] md:max-w-[15rem]",
)}
title={isLoading ? undefined : displayInfo.name}
>
{isLoading ? "…" : displayInfo.name}
</span>
{!compact &&
!hideCount &&
spaceCountData !== undefined &&
spaceCountData > 0 && (
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
· {formatCount(spaceCountData)}
</span>
)}
<ChevronDownIcon
className="size-3.5 shrink-0 text-[#737373]"
aria-hidden
/>
<Pencil className={compact ? "size-3" : "size-3.5"} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Switch space
</TooltipContent>
</Tooltip>
)}
</div>
<AddSpaceModal
isOpen={showCreateDialog}
@ -475,6 +515,16 @@ export function SpaceSelector({
onBulkDeleteRequest={handleBulkDeleteRequest}
/>
{canEditCurrent && (
<EditSpaceModal
containerTag={activeTag}
currentName={displayInfo.name}
currentEmoji={displayInfo.emoji ?? "📁"}
open={showEditDialog}
onOpenChange={setShowEditDialog}
/>
)}
<Dialog
open={deleteDialog.open}
onOpenChange={(open: boolean) => {

View file

@ -47,7 +47,7 @@ export function SuperLoader({
<output
aria-label={label}
className={`inline-flex flex-row items-center gap-2 ${className}`}
style={{ width: size + 10 }}
style={{ minWidth: size + 10 }}
>
<motion.svg
xmlns="http://www.w3.org/2000/svg"
@ -87,7 +87,7 @@ export function SuperLoader({
</motion.svg>
<span
className="text-xs font-medium text-slate-500"
className="whitespace-nowrap text-xs font-medium text-slate-500"
style={{ fontSize: size * 0.25 }}
>
{label}

View file

@ -17,6 +17,7 @@ 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"
export function UserProfileMenu({
className,
@ -29,6 +30,7 @@ export function UserProfileMenu({
}) {
const { user } = useAuth()
const router = useRouter()
const { openSettings } = useSettingsModal()
const { resetOrgOnboarded } = useOrgOnboarding()
const autumn = useCustomer()
const { currentPlan, isLoading: planLoading } = useTokenUsage(autumn)
@ -144,7 +146,7 @@ export function UserProfileMenu({
</div>
<DropdownMenuSeparator className="mx-1 my-1.5 bg-white/[0.06]" />
<DropdownMenuItem
onClick={() => router.push("/settings")}
onClick={() => openSettings()}
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"
>
<Settings className="size-4 text-[#737373]" />

View file

@ -190,6 +190,16 @@
padding-left: 0.25rem;
}
/* Loose lists wrap item content in <p> whose top margin detaches the bullet */
.chat-markdown-content li > p:first-child {
margin-top: 0;
}
/* Model spams `---` between every section; headings already separate them */
.chat-markdown-content hr {
display: none;
}
.shadow-inside-out {
box-shadow:
inset 0 2px 4px rgba(0, 0, 0, 0.3),

View file

@ -7,28 +7,26 @@ import {
} from "@tanstack/react-query"
import { toast } from "sonner"
import { $fetch } from "@lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { useAuth } from "@lib/auth-context"
import { analytics } from "@/lib/analytics"
import { fetchSpaceSettings, spaceSettingsKey } from "@/hooks/use-space-context"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
/** Pull the human-readable message out of a $fetch error (handles `{error}`/`{message}`/string). */
function fetchErrorMessage(err: unknown, fallback: string): string {
if (typeof err === "string") return err
if (err && typeof err === "object") {
const e = err as { error?: unknown; message?: unknown }
if (typeof e.error === "string") return e.error
if (typeof e.message === "string") return e.message
}
return fallback
}
interface DocumentWithId {
id?: string
customId?: string | null
}
interface DocumentsQueryData {
documents: DocumentWithId[]
totalCount: number
}
type InfiniteQueryData = {
pages: DocumentsResponse[]
pageParams: number[]
}
interface UseDocumentMutationsOptions {
onClose?: () => void
}
@ -212,6 +210,8 @@ function restoreQueriesFromSnapshot(
}
const FILE_UPLOAD_CONCURRENCY = 3
const fullDocumentQueryKey = (documentId: string) =>
["document-full", documentId] as const
export type FileUploadEntry = { id: string; file: File }
@ -226,7 +226,23 @@ export function useDocumentMutations({
const queryClient = useQueryClient()
const { user } = useAuth()
const entityContext = `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`
const defaultEntityContext = `This is ${user?.name ?? "a user"}, saving items in a personal knowledge management system. This may be websites, links, notes, journals, PDFs, etc. Understand the user from it into a graph.`
// Skip when the space has its own context — sending one would overwrite the stored value.
const resolveEntityContext = async (
project: string,
): Promise<string | undefined> => {
try {
const settings = await queryClient.fetchQuery({
queryKey: spaceSettingsKey(project),
queryFn: () => fetchSpaceSettings(project),
staleTime: 60 * 1000,
})
return settings?.entityContext ? undefined : defaultEntityContext
} catch {
return defaultEntityContext
}
}
const noteMutation = useMutation({
mutationFn: async ({
@ -236,11 +252,12 @@ export function useDocumentMutations({
content: string
project: string
}) => {
const entityContext = await resolveEntityContext(project)
const response = await $fetch("@post/documents", {
body: {
content,
containerTags: [project],
entityContext,
...(entityContext !== undefined ? { entityContext } : {}),
metadata: { sm_source: "consumer" },
},
})
@ -300,11 +317,12 @@ export function useDocumentMutations({
const linkMutation = useMutation({
mutationFn: async ({ url, project }: { url: string; project: string }) => {
const entityContext = await resolveEntityContext(project)
const response = await $fetch("@post/documents", {
body: {
content: url,
containerTags: [project],
entityContext,
...(entityContext !== undefined ? { entityContext } : {}),
metadata: { sm_source: "consumer" },
},
})
@ -376,12 +394,15 @@ export function useDocumentMutations({
}): Promise<FileUploadBatchResult> => {
const applyMeta = fileEntries.length === 1
const failures: { id: string; message: string }[] = []
const entityContext = await resolveEntityContext(project)
const uploadOne = async (entry: FileUploadEntry) => {
const formData = new FormData()
formData.append("file", entry.file)
formData.append("containerTags", JSON.stringify([project]))
formData.append("entityContext", entityContext)
if (entityContext !== undefined) {
formData.append("entityContext", entityContext)
}
formData.append("metadata", JSON.stringify({ sm_source: "consumer" }))
const response = await fetch(
@ -544,6 +565,10 @@ export function useDocumentMutations({
onSuccess: (_data, variables) => {
analytics.documentEdited({ document_id: variables.documentId })
toast.success("Document saved successfully!")
queryClient.setQueryData(
fullDocumentQueryKey(variables.documentId),
variables.content,
)
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
},
onError: (error) => {
@ -560,7 +585,9 @@ export function useDocumentMutations({
})
if (response.error) {
throw new Error(response.error?.message || "Failed to delete document")
throw new Error(
fetchErrorMessage(response.error, "Failed to delete document"),
)
}
return response.data
@ -584,6 +611,10 @@ export function useDocumentMutations({
onSuccess: (_data, variables) => {
analytics.documentDeleted({ document_id: variables.documentId })
toast.success("Document deleted successfully!")
queryClient.removeQueries({
queryKey: fullDocumentQueryKey(variables.documentId),
exact: true,
})
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
onClose?.()
},
@ -596,7 +627,9 @@ export function useDocumentMutations({
})
if (response.error) {
throw new Error(response.error?.message || "Failed to delete documents")
throw new Error(
fetchErrorMessage(response.error, "Failed to delete documents"),
)
}
return response.data
@ -623,6 +656,12 @@ export function useDocumentMutations({
toast.success(
`${variables.documentIds.length} document${variables.documentIds.length === 1 ? "" : "s"} deleted`,
)
for (const documentId of variables.documentIds) {
queryClient.removeQueries({
queryKey: fullDocumentQueryKey(documentId),
exact: true,
})
}
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
},
})

View file

@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query"
import { $fetch } from "@lib/api"
// List endpoint truncates `content` to ~2000 chars; fetch the full doc by id for the viewer.
export function useFullDocumentContent(
documentId: string | null | undefined,
enabled: boolean,
): string | null {
const isReal = !!documentId && !documentId.startsWith("temp-")
const { data } = useQuery({
queryKey: ["document-full", documentId],
enabled: enabled && isReal,
staleTime: 5 * 60 * 1000,
queryFn: async () => {
const res = await $fetch("@get/documents/:id", {
params: { id: documentId as string },
disableValidation: true,
})
if (res.error) {
throw new Error(res.error?.message || "Failed to fetch document")
}
const doc = res.data as { content?: string | null } | null
return typeof doc?.content === "string" ? doc.content : null
},
})
return data ?? null
}

View file

@ -0,0 +1,115 @@
"use client"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { $fetch } from "@lib/api"
export type SpaceSettings = {
containerTag: string
name: string | null
entityContext: string | null
}
export const spaceSettingsKey = (containerTag: string) =>
["container-tag-settings", containerTag] as const
export async function fetchSpaceSettings(
containerTag: string,
): Promise<SpaceSettings | null> {
const response = await $fetch(`@get/container-tags/${containerTag}`, {
disableValidation: true,
})
if (response.error) {
throw new Error(response.error?.message || "Failed to load space settings")
}
const data = response.data as Partial<SpaceSettings> | null
if (!data) return null
return {
containerTag,
name: data.name ?? null,
entityContext: data.entityContext ?? null,
}
}
export function useSpaceContext(containerTag: string, enabled = true) {
return useQuery({
queryKey: spaceSettingsKey(containerTag),
queryFn: () => fetchSpaceSettings(containerTag),
enabled: enabled && !!containerTag,
staleTime: 60 * 1000,
})
}
export function useUpdateSpace() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
containerTag,
name,
entityContext,
}: {
containerTag: string
name?: string
entityContext?: string | null
}) => {
const body: { name?: string; entityContext?: string | null } = {}
if (name !== undefined) body.name = name
if (entityContext !== undefined) body.entityContext = entityContext
const response = await $fetch(`@patch/container-tags/${containerTag}`, {
body,
})
if (response.error) {
throw new Error(response.error?.message || "Failed to save space")
}
return response.data
},
onSuccess: (_data, { containerTag }) => {
queryClient.invalidateQueries({
queryKey: spaceSettingsKey(containerTag),
})
queryClient.invalidateQueries({ queryKey: ["container-tags"] })
toast.success("Space updated")
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save space",
)
},
})
}
export function useUpdateSpaceContext() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
containerTag,
entityContext,
}: {
containerTag: string
entityContext: string | null
}) => {
const response = await $fetch(`@patch/container-tags/${containerTag}`, {
body: { entityContext },
})
if (response.error) {
throw new Error(
response.error?.message || "Failed to save space context",
)
}
return response.data
},
onSuccess: (_data, { containerTag }) => {
queryClient.invalidateQueries({
queryKey: spaceSettingsKey(containerTag),
})
toast.success("Space context saved")
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to save space context",
)
},
})
}

View file

@ -1,5 +1,4 @@
import { useQuery } from "@tanstack/react-query"
import { $fetch } from "@lib/api"
import { useAuth } from "@lib/auth-context"
export type SpaceProfile = {
@ -7,6 +6,16 @@ export type SpaceProfile = {
dynamic: string[]
}
const API_BASE =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
type SpaceProfileResponse = {
profile?: {
static?: string[] | null
dynamic?: string[] | null
} | null
}
export function useSpaceProfile(containerTag: string) {
const { org } = useAuth()
const orgId = org?.id ?? ""
@ -14,21 +23,32 @@ export function useSpaceProfile(containerTag: string) {
return useQuery({
queryKey: ["space-profile", orgId, containerTag],
queryFn: async (): Promise<SpaceProfile> => {
const response = await $fetch(
"@get/container-tags/:containerTag/profile",
{
params: { containerTag },
const response = await fetch(`${API_BASE}/v4/profile`, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-App-Source": "nova",
},
)
if (response.error) {
body: JSON.stringify({ containerTag }),
})
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as {
error?: string
message?: string
}
throw new Error(
response.error.message || "Failed to load space profile",
body.message ?? body.error ?? "Failed to load space profile",
)
}
const profile = response.data.profile
const data = (await response.json()) as SpaceProfileResponse
const profile = data.profile
return {
static: profile.static ?? [],
dynamic: profile.dynamic ?? [],
static: profile?.static ?? [],
dynamic: profile?.dynamic ?? [],
}
},
enabled: !!orgId && !!containerTag,

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