Merge remote-tracking branch 'upstream/main' into fix/dynamic-chat-suggestions

# Conflicts:
#	apps/web/components/chat/index.tsx
This commit is contained in:
Sunny 2026-05-10 22:57:59 -07:00
commit ecd43d7131
222 changed files with 12894 additions and 11539 deletions

View file

@ -16,7 +16,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.4
bun-version: 1.3.6
- name: Install dependencies
run: bun install --frozen-lockfile

View file

@ -0,0 +1,37 @@
name: Publish Agent Framework Python
on:
push:
branches:
- main
paths:
- "packages/agent-framework-python/pyproject.toml"
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: ./packages/agent-framework-python
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install build dependencies
run: pip install hatchling build
- name: Build package
run: python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: packages/agent-framework-python/dist/

View file

@ -23,22 +23,36 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: Upgrade npm for trusted publishing support
run: npm install -g npm@latest
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: bun install
- name: Check if version changed
id: version-check
run: |
PACKAGE_NAME=$(jq -r '.name' package.json)
LOCAL_VERSION=$(jq -r '.version' package.json)
NPM_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || echo "0.0.0")
if [ "$LOCAL_VERSION" = "$NPM_VERSION" ]; then
echo "Version $LOCAL_VERSION already published, skipping."
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "Publishing $LOCAL_VERSION (npm has $NPM_VERSION)"
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Build
if: steps.version-check.outputs.changed == 'true'
run: bun run build
- name: Publish
run: pnpm publish --access public --verbose
env:
NPM_CONFIG_PROVENANCE: true
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
if: steps.version-check.outputs.changed == 'true'
run: npm publish --access public --provenance

View file

@ -0,0 +1,37 @@
name: Publish Cartesia SDK Python
on:
push:
branches:
- main
paths:
- "packages/cartesia-sdk-python/pyproject.toml"
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: ./packages/cartesia-sdk-python
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install build dependencies
run: pip install hatchling build
- name: Build package
run: python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: packages/cartesia-sdk-python/dist/

View file

@ -0,0 +1,58 @@
name: Publish Memory Graph
on:
push:
branches:
- main
paths:
- "packages/memory-graph/package.json"
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: ./packages/memory-graph
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: Upgrade npm for trusted publishing support
run: npm install -g npm@latest
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install
- name: Check if version changed
id: version-check
run: |
PACKAGE_NAME=$(jq -r '.name' package.json)
LOCAL_VERSION=$(jq -r '.version' package.json)
NPM_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || echo "0.0.0")
if [ "$LOCAL_VERSION" = "$NPM_VERSION" ]; then
echo "Version $LOCAL_VERSION already published, skipping."
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "Publishing $LOCAL_VERSION (npm has $NPM_VERSION)"
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Build
if: steps.version-check.outputs.changed == 'true'
run: bun run build
- name: Publish
if: steps.version-check.outputs.changed == 'true'
run: npm publish --access public --provenance

View file

@ -0,0 +1,37 @@
name: Publish OpenAI SDK Python
on:
push:
branches:
- main
paths:
- "packages/openai-sdk-python/pyproject.toml"
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: ./packages/openai-sdk-python
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install build dependencies
run: pip install hatchling build
- name: Build package
run: python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: packages/openai-sdk-python/dist/

View file

@ -0,0 +1,37 @@
name: Publish Pipecat SDK Python
on:
push:
branches:
- main
paths:
- "packages/pipecat-sdk-python/pyproject.toml"
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: ./packages/pipecat-sdk-python
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install build dependencies
run: pip install hatchling build
- name: Build package
run: python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: packages/pipecat-sdk-python/dist/

58
.github/workflows/publish-tools.yml vendored Normal file
View file

@ -0,0 +1,58 @@
name: Publish Tools
on:
push:
branches:
- main
paths:
- "packages/tools/package.json"
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: ./packages/tools
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: Upgrade npm for trusted publishing support
run: npm install -g npm@latest
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install
- name: Check if version changed
id: version-check
run: |
PACKAGE_NAME=$(jq -r '.name' package.json)
LOCAL_VERSION=$(jq -r '.version' package.json)
NPM_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || echo "0.0.0")
if [ "$LOCAL_VERSION" = "$NPM_VERSION" ]; then
echo "Version $LOCAL_VERSION already published, skipping."
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "Publishing $LOCAL_VERSION (npm has $NPM_VERSION)"
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Build
if: steps.version-check.outputs.changed == 'true'
run: bun run build
- name: Publish
if: steps.version-check.outputs.changed == 'true'
run: npm publish --access public --provenance

View file

@ -48,10 +48,12 @@ Before you begin, ensure you have the following installed:
5. **Start the Development Server**
```bash
bun run dev
bun run dev:local
```
This will start all applications in the monorepo. The web app will be available at `http://localhost:3000`.
This starts each app on plain `localhost` ports (web on 3000, mcp on 8788, docs on 3003, graph on 3004) and points the web app at the public API at `https://api.supermemory.ai` via `NEXT_PUBLIC_BACKEND_URL` in `.env.example`. Sign in with magic-link/email-OTP — Google/GitHub OAuth sign-in won't round-trip back to localhost.
> **Internal team note:** `bun run dev` (without `:local`) routes through [portless](https://github.com/portless/portless) to give every app a stable `*.dev.supermemory.ai` HTTPS URL with shared cookies and a working OAuth proxy. That path requires `bun run setup:dev` once (binds port 443, trusts a local CA). OSS contributors don't need it.
## 📁 Project Structure
@ -84,7 +86,8 @@ supermemory/
### Available Scripts
- `bun run dev` - Start development servers for all apps
- `bun run dev:local` - Start dev servers on plain localhost ports (recommended for OSS contributors)
- `bun run dev` - Start dev servers through portless (`*.dev.supermemory.ai`, internal team)
- `bun run build` - Build all applications
- `bun run format-lint` - Format and lint code using Biome
- `bun run check-types` - Type check all packages

View file

@ -232,7 +232,7 @@ Drop-in wrappers for every major AI framework:
```typescript
// Vercel AI SDK
import { withSupermemory } from "@supermemory/tools/ai-sdk";
const model = withSupermemory(openai("gpt-4o"), "user_123");
const model = withSupermemory(openai("gpt-4o"), { containerTag: "user_123", customId: "conv-1" });
// Mastra
import { withSupermemory } from "@supermemory/tools/mastra";

View file

@ -47,7 +47,10 @@ async function initializePostHog(): Promise<PostHog> {
throw new Error("PostHog API key not configured")
}
posthog.init(import.meta.env.WXT_POSTHOG_API_KEY || "", POSTHOG_CONFIG)
posthog.init(
"phc_ShqecfUPQgf16lWu6ZMUzduQvcWzCywrkCz5KHwmWsv",
POSTHOG_CONFIG,
)
await identifyUser(posthog)

View file

@ -29,7 +29,7 @@ export default defineConfig({
manifest: {
name: "supermemory",
homepage_url: "https://supermemory.ai",
version: "6.1.2",
version: "6.1.3",
permissions: ["storage", "activeTab", "webRequest", "tabs"],
host_permissions: [
"*://x.com/*",

View file

@ -26,7 +26,10 @@ import { withSupermemory } from "@supermemory/tools/ai-sdk"
import { openai } from "@ai-sdk/openai"
// Wrap your model with Supermemory - profiles are automatically injected
const modelWithMemory = withSupermemory(openai("gpt-5"), "user-123")
const modelWithMemory = withSupermemory(openai("gpt-5"), {
containerTag: "user-123",
customId: "conversation-456",
})
const result = await generateText({
model: modelWithMemory,
@ -36,11 +39,13 @@ const result = await generateText({
```
<Note>
**Memory saving is disabled by default.** The middleware only retrieves existing memories. To automatically save new memories from conversations, enable it explicitly:
**Memory saving is enabled by default** (`addMemory: "always"`). New conversations are persisted automatically. To opt out, set `addMemory: "never"`:
```typescript
const modelWithMemory = withSupermemory(openai("gpt-5"), "user-123", {
addMemory: "always"
const modelWithMemory = withSupermemory(openai("gpt-5"), {
containerTag: "user-123",
customId: "conversation-456",
addMemory: "never",
})
```
</Note>

View file

@ -20,10 +20,10 @@ import { withSupermemory } from "@supermemory/tools/ai-sdk"
import { openai } from "@ai-sdk/openai"
// Wrap any model with Supermemory middleware
const modelWithMemory = withSupermemory(
openai("gpt-4"), // Your base model
"user-123" // Container tag (user ID)
)
const modelWithMemory = withSupermemory(openai("gpt-4"), {
containerTag: "user-123",
customId: "conversation-456",
})
// Use normally - profiles are automatically injected!
const result = await generateText({
@ -46,11 +46,13 @@ The `withSupermemory` middleware:
All of this happens transparently - you write code as if using a normal model, but get personalized responses.
<Note>
**Memory saving is disabled by default.** The middleware only retrieves existing memories. To automatically save new memories from conversations, set `addMemory: "always"`:
**Memory saving is enabled by default** (`addMemory: "always"`). New conversations are persisted automatically. To opt out, set `addMemory: "never"`:
```typescript
const model = withSupermemory(openai("gpt-5"), "user-123", {
addMemory: "always"
const model = withSupermemory(openai("gpt-5"), {
containerTag: "user-123",
customId: "conversation-456",
addMemory: "never",
})
```
</Note>
@ -65,11 +67,16 @@ Retrieves the user's complete profile without query-specific search. Best for ge
```typescript
// Default behavior - profile mode
const model = withSupermemory(openai("gpt-4"), "user-123")
const model = withSupermemory(openai("gpt-4"), {
containerTag: "user-123",
customId: "conv-1",
})
// Or explicitly specify
const model = withSupermemory(openai("gpt-4"), "user-123", {
mode: "profile"
const model = withSupermemory(openai("gpt-4"), {
containerTag: "user-123",
customId: "conv-1",
mode: "profile",
})
const result = await generateText({
@ -84,8 +91,10 @@ const result = await generateText({
Searches memories based on the user's specific message. Best for finding relevant information.
```typescript
const model = withSupermemory(openai("gpt-4"), "user-123", {
mode: "query"
const model = withSupermemory(openai("gpt-4"), {
containerTag: "user-123",
customId: "conv-1",
mode: "query",
})
const result = await generateText({
@ -103,8 +112,10 @@ const result = await generateText({
Combines profile AND query-based search for comprehensive context. Best for complex interactions.
```typescript
const model = withSupermemory(openai("gpt-4"), "user-123", {
mode: "full"
const model = withSupermemory(openai("gpt-4"), {
containerTag: "user-123",
customId: "conv-1",
mode: "full",
})
const result = await generateText({
@ -137,9 +148,11 @@ ${data.generalSearchMemories}
</user_memories>
`.trim()
const model = withSupermemory(openai("gpt-4"), "user-123", {
const model = withSupermemory(openai("gpt-4"), {
containerTag: "user-123",
customId: "conv-1",
mode: "full",
promptTemplate: customPrompt
promptTemplate: customPrompt,
})
const result = await generateText({
@ -174,9 +187,11 @@ const claudePrompt = (data: MemoryPromptData) => `
Use the above context to provide personalized responses.
`.trim()
const model = withSupermemory(anthropic("claude-3-sonnet"), "user-123", {
const model = withSupermemory(anthropic("claude-3-sonnet"), {
containerTag: "user-123",
customId: "conv-1",
mode: "full",
promptTemplate: claudePrompt
promptTemplate: claudePrompt,
})
```
@ -199,9 +214,11 @@ ${relevant.map((r) => `- ${r.memory}`).join("\n")}
`.trim()
}
const model = withSupermemory(openai("gpt-4"), "user-123", {
const model = withSupermemory(openai("gpt-4"), {
containerTag: "user-123",
customId: "conv-1",
mode: "full",
promptTemplate: selectivePrompt
promptTemplate: selectivePrompt,
})
```
@ -222,8 +239,10 @@ ${data.generalSearchMemories}
Use this information to provide personalized and contextually relevant responses.
`.trim()
const model = withSupermemory(openai("gpt-4"), "user-123", {
promptTemplate: brandedPrompt
const model = withSupermemory(openai("gpt-4"), {
containerTag: "user-123",
customId: "conv-1",
promptTemplate: brandedPrompt,
})
```
@ -241,8 +260,10 @@ const defaultPrompt = (data: MemoryPromptData) =>
Enable detailed logging to see exactly what's happening:
```typescript
const model = withSupermemory(openai("gpt-4"), "user-123", {
verbose: true // Enable detailed logging
const model = withSupermemory(openai("gpt-4"), {
containerTag: "user-123",
customId: "conv-1",
verbose: true, // Enable detailed logging
})
const result = await generateText({
@ -266,8 +287,11 @@ The AI SDK middleware abstracts away the complexity of manual profile management
<Tabs>
<Tab title="With AI SDK (Simple)">
```typescript
// One line setup
const model = withSupermemory(openai("gpt-4"), "user-123")
// Simple setup
const model = withSupermemory(openai("gpt-4"), {
containerTag: "user-123",
customId: "conv-1",
})
// Use normally
const result = await generateText({

View file

@ -6,6 +6,10 @@ description: "API updates, new endpoints, and SDK releases"
API updates, new endpoints, SDK releases, and developer-focused features.
## April 13, 2026
- **Google Drive scoped sync:** New connections default to a **hosted folder/file picker** after OAuth; only chosen items sync. Use `metadata.syncScope: "full"` to sync the whole Drive. Import jobs **skip** scoped connections until a selection exists.
## March 18, 2026
- **Supermemory CLI:** New command-line tool for managing memories, documents, profiles, tags, connectors, and API keys directly from the terminal.

View file

@ -3,6 +3,14 @@ title: "Changelog"
description: "New updates and improvements to Supermemory"
---
<Update label="April 13, 2026" tags={["Integrations", "API"]}>
### Google Drive: scoped sync by default
New Google Drive connections default to **folder and file** scope: after OAuth, users complete a hosted picker; only selected items sync. Set `metadata.syncScope` to `"full"` on connection creation to sync the entire Drive without the picker. Scoped connections without a saved selection are skipped by import jobs until setup is finished.
</Update>
<Update label="March 18, 2026" tags={["API", "SDK", "Console", "CLI"]}>
### Supermemory CLI

View file

@ -6,6 +6,18 @@ icon: "google-drive"
Connect Google Drive to sync documents into your Supermemory knowledge base with OAuth authentication and custom app support.
## Sync scope
**Default for new connections:** after OAuth, the user completes a **folder and file** picker (Google Docs, Sheets, Slides, and PDFs). Only items they select are synced and updated until they change the selection (for example from the Supermemory console).
**Whole Drive:** set `metadata.syncScope` to `"full"` when creating the connection so the entire Drive syncs without the picker.
**Explicit scoped mode:** set `metadata.syncScope` to `"selected"` for the picker flow, or rely on the default for new connects.
<Note>
If you use scoped sync and the user has not finished the picker yet, **scheduled or manual import may skip that connection** until a selection is saved on the connection.
</Note>
## Quick Setup
### 1. Create Google Drive Connection
@ -25,7 +37,8 @@ Connect Google Drive to sync documents into your Supermemory knowledge base with
documentLimit: 3000,
metadata: {
source: 'google-drive',
department: 'engineering'
department: 'engineering',
syncScope: 'selected'
}
});
@ -48,7 +61,8 @@ Connect Google Drive to sync documents into your Supermemory knowledge base with
document_limit=3000,
metadata={
'source': 'google-drive',
'department': 'engineering'
'department': 'engineering',
'syncScope': 'selected',
}
)
@ -68,16 +82,21 @@ Connect Google Drive to sync documents into your Supermemory knowledge base with
"documentLimit": 3000,
"metadata": {
"source": "google-drive",
"department": "engineering"
"department": "engineering",
"syncScope": "selected"
}
}'
```
</Tab>
</Tabs>
<Note>
For **whole Drive** sync, include `"syncScope": "full"` in `metadata` on the same `POST /v3/connections/google-drive` request instead of `"selected"`.
</Note>
### 2. Handle OAuth Callback
After user grants permissions, Google redirects to your callback URL. The connection is automatically established.
After the user grants permissions, Google redirects through Supermemory to finish the connection. With **scoped** sync (`syncScope` omitted or `"selected"`), the user is sent to Supermemorys **hosted file and folder picker**; they must complete that step before imports run. With **`syncScope: "full"`**, Supermemory redirects to your `redirectUrl` (or returns connection details) **without** the picker. You can open the picker again later for an existing connection (Supermemory console, or `POST /v3/connections/{connectionId}/google-drive/hosted-picker` with an authenticated admin session).
### 3. Check Connection Status

View file

@ -144,6 +144,28 @@
"pages": ["supermemory-mcp/claude-desktop"]
}
]
},
{
"anchor": "SMFS",
"icon": "database",
"pages": [
"smfs/overview",
"smfs/install",
"smfs/mount",
"smfs/bash-tool",
"smfs/bash-tool-python",
{
"group": "Providers",
"icon": "cloud",
"pages": [
"smfs/providers/daytona",
"smfs/providers/e2b",
"smfs/providers/vercel",
"smfs/providers/cloudflare"
]
},
"smfs/examples"
]
}
],
"tab": "Developer Platform"
@ -170,7 +192,12 @@
"integrations/pipecat",
"integrations/n8n",
"integrations/viasocket",
"integrations/zapier"
"integrations/zapier",
{
"group": "Migration Guides",
"icon": "arrow-up-right",
"pages": ["migration/tools-v2-upgrade"]
}
]
}
],

View file

@ -106,7 +106,10 @@ const result = await streamText({
// Option 2: Profile middleware (automatic context injection)
import { withSupermemory } from '@supermemory/tools/ai-sdk'
const modelWithMemory = withSupermemory(anthropic('claude-3-5-sonnet-20241022'), userId)
const modelWithMemory = withSupermemory(anthropic('claude-3-5-sonnet-20241022'), {
containerTag: userId,
customId: 'conversation-1',
})
const result = await generateText({
model: modelWithMemory,

View file

@ -7,6 +7,10 @@ icon: "triangle"
The Supermemory AI SDK provides native integration with Vercel's AI SDK through two approaches: **User Profiles** for automatic personalization and **Memory Tools** for agent-based interactions.
<Note>
Migrating to v2 from 1.4.x? Check the [migration guide](/migration/tools-v2-upgrade).
</Note>
<Card title="@supermemory/tools on npm" icon="npm" href="https://www.npmjs.com/package/@supermemory/tools">
Check out the NPM page for more details
</Card>
@ -35,7 +39,10 @@ import { generateText } from "ai"
import { withSupermemory } from "@supermemory/tools/ai-sdk"
import { openai } from "@ai-sdk/openai"
const modelWithMemory = withSupermemory(openai("gpt-5"), "user-123")
const modelWithMemory = withSupermemory(openai("gpt-5"), {
containerTag: "user-123",
customId: "conversation-456",
})
const result = await generateText({
model: modelWithMemory,
@ -43,12 +50,21 @@ const result = await generateText({
})
```
### Required fields
Both `containerTag` and `customId` are required.
- **`containerTag`** — *who* the memories belong to. Use a stable identifier per user, workspace, or tenant (e.g. `"user-123"`, `"acme-workspace"`). Memory search and writes are scoped to this tag.
- **`customId`** — *which conversation* this turn belongs to. Use it to group messages from the same chat session into a single document (e.g. `"chat-2026-04-25"`, a thread ID, or a UUID per session).
<Note>
**Memory saving is disabled by default.** The middleware only retrieves existing memories. To automatically save new memories:
**Memory saving is enabled by default** (`addMemory: "always"`). New conversations are persisted automatically. To opt out, set `addMemory: "never"`:
```typescript
const modelWithMemory = withSupermemory(openai("gpt-5"), "user-123", {
addMemory: "always"
const modelWithMemory = withSupermemory(openai("gpt-5"), {
containerTag: "user-123",
customId: "conversation-456",
addMemory: "never",
})
```
</Note>
@ -58,19 +74,19 @@ const result = await generateText({
**Profile Mode (Default)** - Retrieves the user's complete profile:
```typescript
const model = withSupermemory(openai("gpt-4"), "user-123", { mode: "profile" })
const model = withSupermemory(openai("gpt-4"), { containerTag: "user-123", customId: "conv-1", mode: "profile" })
```
**Query Mode** - Searches memories based on the user's message:
```typescript
const model = withSupermemory(openai("gpt-4"), "user-123", { mode: "query" })
const model = withSupermemory(openai("gpt-4"), { containerTag: "user-123", customId: "conv-1", mode: "query" })
```
**Full Mode** - Combines profile AND query-based search:
```typescript
const model = withSupermemory(openai("gpt-4"), "user-123", { mode: "full" })
const model = withSupermemory(openai("gpt-4"), { containerTag: "user-123", customId: "conv-1", mode: "full" })
```
### Custom Prompt Templates
@ -91,21 +107,39 @@ const claudePrompt = (data: MemoryPromptData) => `
</context>
`.trim()
const model = withSupermemory(anthropic("claude-3-sonnet"), "user-123", {
const model = withSupermemory(anthropic("claude-3-sonnet"), {
containerTag: "user-123",
customId: "conv-1",
mode: "full",
promptTemplate: claudePrompt
promptTemplate: claudePrompt,
})
```
### Verbose Logging
```typescript
const model = withSupermemory(openai("gpt-4"), "user-123", {
verbose: true
const model = withSupermemory(openai("gpt-4"), {
containerTag: "user-123",
customId: "conv-1",
verbose: true,
})
// Console output shows memory retrieval details
```
### When Supermemory errors (default: continue without memories)
If the Supermemory API returns an error, is unreachable, or retrieval hits the internal time limit, memory injection is skipped. **`skipMemoryOnError` defaults to `true`**, so the LLM call still runs with the **original** prompt (no injected memories). Use `verbose: true` if you want console output when that happens.
To **fail the call** when memory retrieval fails instead, set `skipMemoryOnError: false`:
```typescript
const model = withSupermemory(openai("gpt-5"), {
containerTag: "user-123",
customId: "conv-1",
skipMemoryOnError: false,
})
```
---
## Memory Tools

View file

@ -7,6 +7,10 @@ icon: "/images/mastra-icon.svg"
Integrate Supermemory with [Mastra](https://mastra.ai) to give your AI agents persistent memory. Use the `withSupermemory` wrapper for zero-config setup or processors for fine-grained control.
<Note>
Migrating to v2 from 1.4.x? Check the [migration guide](/migration/tools-v2-upgrade).
</Note>
<Card title="@supermemory/tools on npm" icon="npm" href="https://www.npmjs.com/package/@supermemory/tools">
Check out the NPM page for more details
</Card>
@ -34,11 +38,10 @@ const agent = new Agent(withSupermemory(
model: openai("gpt-4o"),
instructions: "You are a helpful assistant.",
},
"user-123", // containerTag - scopes memories to this user
{
containerTag: "user-123", // Required: scopes memories to this user
customId: "conv-456", // Required: groups messages for contextual memory
mode: "full",
addMemory: "always",
threadId: "conv-456",
}
))
@ -46,15 +49,15 @@ const response = await agent.generate("What do you know about me?")
```
<Note>
**Memory saving is disabled by default.** The wrapper only retrieves existing memories. To automatically save conversations:
**Memory saving is enabled by default.** Conversations are automatically saved to Supermemory. To disable saving:
```typescript
const agent = new Agent(withSupermemory(
{ id: "my-assistant", model: openai("gpt-4o"), ... },
"user-123",
{
addMemory: "always",
threadId: "conv-456" // Required for conversation grouping
containerTag: "user-123",
customId: "conv-456",
addMemory: "never", // Disable automatic conversation saving
}
))
```
@ -96,11 +99,12 @@ sequenceDiagram
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `containerTag` | `string` | **Required** | User/container tag for scoping memories |
| `customId` | `string` | **Required** | Groups messages into a single document for contextual memory |
| `apiKey` | `string` | `SUPERMEMORY_API_KEY` env | Your Supermemory API key |
| `baseUrl` | `string` | `https://api.supermemory.ai` | Custom API endpoint |
| `mode` | `"profile" \| "query" \| "full"` | `"profile"` | Memory search mode |
| `addMemory` | `"always" \| "never"` | `"never"` | Auto-save conversations |
| `threadId` | `string` | - | Conversation ID for grouping messages |
| `addMemory` | `"always" \| "never"` | `"always"` | Auto-save conversations |
| `verbose` | `boolean` | `false` | Enable debug logging |
| `promptTemplate` | `function` | - | Custom memory formatting |
@ -111,19 +115,31 @@ sequenceDiagram
**Profile Mode (Default)** - Retrieves the user's complete profile without query-based filtering:
```typescript
const agent = new Agent(withSupermemory(config, "user-123", { mode: "profile" }))
const agent = new Agent(withSupermemory(config, {
containerTag: "user-123",
customId: "conv-456",
mode: "profile",
}))
```
**Query Mode** - Searches memories based on the user's message:
```typescript
const agent = new Agent(withSupermemory(config, "user-123", { mode: "query" }))
const agent = new Agent(withSupermemory(config, {
containerTag: "user-123",
customId: "conv-456",
mode: "query",
}))
```
**Full Mode** - Combines profile AND query-based search for maximum context:
```typescript
const agent = new Agent(withSupermemory(config, "user-123", { mode: "full" }))
const agent = new Agent(withSupermemory(config, {
containerTag: "user-123",
customId: "conv-456",
mode: "full",
}))
### Mode Comparison
@ -137,26 +153,34 @@ const agent = new Agent(withSupermemory(config, "user-123", { mode: "full" }))
## Saving Conversations
Enable automatic conversation saving with `addMemory: "always"`. A `threadId` is required to group messages:
Conversation saving is enabled by default (`addMemory: "always"`). Messages are grouped using the required `customId`:
```typescript
const agent = new Agent(withSupermemory(
{ id: "my-assistant", model: openai("gpt-4o"), instructions: "..." },
"user-123",
{
addMemory: "always",
threadId: "conv-456",
containerTag: "user-123",
customId: "conv-456", // Required: groups messages for contextual memory
}
))
// All messages in this conversation are saved
// All messages in this conversation are saved automatically
await agent.generate("I prefer TypeScript over JavaScript")
await agent.generate("My favorite framework is Next.js")
```
<Warning>
Without a `threadId`, the output processor will log a warning and skip saving. Always provide a `threadId` when using `addMemory: "always"`.
</Warning>
To disable automatic saving:
```typescript
const agent = new Agent(withSupermemory(
{ id: "my-assistant", model: openai("gpt-4o"), instructions: "..." },
{
containerTag: "user-123",
customId: "conv-456",
addMemory: "never", // Only retrieve memories, don't save
}
))
```
---
@ -182,8 +206,9 @@ const claudePrompt = (data: MemoryPromptData) => `
const agent = new Agent(withSupermemory(
{ id: "my-assistant", model: openai("gpt-4o"), instructions: "..." },
"user-123",
{
containerTag: "user-123",
customId: "conv-456",
mode: "full",
promptTemplate: claudePrompt,
}
@ -210,8 +235,11 @@ const agent = new Agent({
name: "My Assistant",
model: openai("gpt-4o"),
inputProcessors: [
createSupermemoryProcessor("user-123", {
createSupermemoryProcessor({
containerTag: "user-123",
customId: "conv-456",
mode: "full",
addMemory: "never",
verbose: true,
}),
],
@ -232,9 +260,9 @@ const agent = new Agent({
name: "My Assistant",
model: openai("gpt-4o"),
outputProcessors: [
createSupermemoryOutputProcessor("user-123", {
addMemory: "always",
threadId: "conv-456",
createSupermemoryOutputProcessor({
containerTag: "user-123",
customId: "conv-456",
}),
],
})
@ -249,10 +277,10 @@ import { Agent } from "@mastra/core/agent"
import { createSupermemoryProcessors } from "@supermemory/tools/mastra"
import { openai } from "@ai-sdk/openai"
const { input, output } = createSupermemoryProcessors("user-123", {
const { input, output } = createSupermemoryProcessors({
containerTag: "user-123",
customId: "conv-456",
mode: "full",
addMemory: "always",
threadId: "conv-456",
verbose: true,
})
@ -267,9 +295,9 @@ const agent = new Agent({
---
## Using RequestContext
## Using RequestContext for Dynamic Thread IDs
Mastra's `RequestContext` can provide `threadId` dynamically:
For server setups where one agent instance handles multiple concurrent conversations, use Mastra's `RequestContext` to provide per-request thread IDs. **RequestContext takes precedence** over the construction-time `customId`:
```typescript
import { Agent } from "@mastra/core/agent"
@ -279,21 +307,25 @@ import { openai } from "@ai-sdk/openai"
const agent = new Agent(withSupermemory(
{ id: "my-assistant", model: openai("gpt-4o"), instructions: "..." },
"user-123",
{
containerTag: "user-123",
customId: "fallback-conv", // Used only when RequestContext doesn't provide a threadId
mode: "full",
addMemory: "always",
// threadId not set - will use RequestContext
}
))
// Set threadId dynamically via RequestContext
// Per-request threadId takes precedence over customId
const ctx = new RequestContext()
ctx.set(MASTRA_THREAD_ID_KEY, "dynamic-thread-id")
ctx.set(MASTRA_THREAD_ID_KEY, "user-456-session-789")
await agent.generate("Hello!", { requestContext: ctx })
// This conversation is stored under "user-456-session-789", not "fallback-conv"
```
<Note>
**Server-side usage**: Always use `RequestContext` to pass unique conversation IDs per request. Using a fixed `customId` for all requests will merge conversations from different users.
</Note>
---
## Verbose Logging
@ -303,8 +335,11 @@ Enable detailed logging for debugging:
```typescript
const agent = new Agent(withSupermemory(
{ id: "my-assistant", model: openai("gpt-4o"), instructions: "..." },
"user-123",
{ verbose: true }
{
containerTag: "user-123",
customId: "conv-456",
verbose: true,
}
))
// Console output:
@ -330,7 +365,10 @@ const agent = new Agent(withSupermemory(
inputProcessors: [myLoggingProcessor],
outputProcessors: [myAnalyticsProcessor],
},
"user-123"
{
containerTag: "user-123",
customId: "conv-456",
}
))
```
@ -345,15 +383,13 @@ Enhances a Mastra agent config with memory capabilities.
```typescript
function withSupermemory<T extends AgentConfig>(
config: T,
containerTag: string,
options?: SupermemoryMastraOptions
options: SupermemoryMastraOptions
): T
```
**Parameters:**
- `config` - The Mastra agent configuration object
- `containerTag` - User/container ID for scoping memories
- `options` - Configuration options
- `options` - Configuration options (includes required `containerTag` and `customId`)
**Returns:** Enhanced config with Supermemory processors injected
@ -363,8 +399,7 @@ Creates an input processor for memory injection.
```typescript
function createSupermemoryProcessor(
containerTag: string,
options?: SupermemoryMastraOptions
options: SupermemoryMastraOptions
): SupermemoryInputProcessor
```
@ -374,8 +409,7 @@ Creates an output processor for conversation saving.
```typescript
function createSupermemoryOutputProcessor(
containerTag: string,
options?: SupermemoryMastraOptions
options: SupermemoryMastraOptions
): SupermemoryOutputProcessor
```
@ -385,8 +419,7 @@ Creates both processors with shared configuration.
```typescript
function createSupermemoryProcessors(
containerTag: string,
options?: SupermemoryMastraOptions
options: SupermemoryMastraOptions
): {
input: SupermemoryInputProcessor
output: SupermemoryOutputProcessor
@ -397,11 +430,12 @@ function createSupermemoryProcessors(
```typescript
interface SupermemoryMastraOptions {
containerTag: string // Required: User/container tag for scoping memories
customId: string // Required: Groups messages for contextual memory generation
apiKey?: string
baseUrl?: string
mode?: "profile" | "query" | "full"
addMemory?: "always" | "never"
threadId?: string
addMemory?: "always" | "never" // Default: "always"
verbose?: boolean
promptTemplate?: (data: MemoryPromptData) => string
}
@ -423,14 +457,16 @@ Processors gracefully handle errors without breaking the agent:
- **API errors** - Logged and skipped; agent continues without memories
- **Missing API key** - Throws immediately with helpful error message
- **Missing threadId** - Warns in console; skips saving
```typescript
// Missing API key throws immediately
const agent = new Agent(withSupermemory(
{ id: "my-assistant", model: openai("gpt-4o"), instructions: "..." },
"user-123",
{ apiKey: undefined } // Will check SUPERMEMORY_API_KEY env
{
containerTag: "user-123",
customId: "conv-456",
apiKey: undefined, // Will check SUPERMEMORY_API_KEY env
}
))
// Error: SUPERMEMORY_API_KEY is not set
```

View file

@ -10,6 +10,10 @@ Add memory capabilities to the official OpenAI SDKs using Supermemory. Two appro
1. **`withSupermemory` wrapper** - Automatic memory injection into system prompts (zero-config)
2. **Function calling tools** - Explicit tool calls for search/add memory operations
<Note>
Migrating to v2 from 1.4.x? Check the [migration guide](/migration/tools-v2-upgrade).
</Note>
<Tip>
**New to Supermemory?** Start with `withSupermemory` for the simplest integration. It automatically injects relevant memories into your prompts.
</Tip>
@ -44,9 +48,11 @@ import { withSupermemory } from "@supermemory/tools/openai"
const openai = new OpenAI()
// Wrap client with memory - memories auto-injected into system prompts
const client = withSupermemory(openai, "user-123", {
mode: "full", // "profile" | "query" | "full"
addMemory: "always", // "always" | "never"
const client = withSupermemory(openai, {
containerTag: "user-123", // Required: identifies the user/container
customId: "conversation-456", // Required: groups messages into the same document
mode: "full", // "profile" | "query" | "full"
addMemory: "always", // "always" (default) | "never"
})
// Use normally - memories are automatically included
@ -62,16 +68,19 @@ const response = await client.chat.completions.create({
### Configuration Options
```typescript
const client = withSupermemory(openai, "user-123", {
const client = withSupermemory(openai, {
// Required: identifies the user/container
containerTag: "user-123",
// Required: Group messages into the same document
customId: "conv-456",
// Memory search mode
mode: "full", // "profile" (user profile only), "query" (search only), "full" (both)
// Auto-save conversations as memories
// Auto-save conversations as memories (default: "always")
addMemory: "always", // "always" | "never"
// Group messages into conversations
conversationId: "conv-456",
// Enable debug logging
verbose: true,
@ -91,7 +100,7 @@ const client = withSupermemory(openai, "user-123", {
### Works with Responses API Too
```typescript
const client = withSupermemory(openai, "user-123", { mode: "full" })
const client = withSupermemory(openai, { containerTag: "user-123", customId: "conv-456", mode: "full" })
// Memories injected into instructions
const response = await client.responses.create({

View file

@ -7,6 +7,10 @@ icon: "bolt"
Supermemory integrates with [VoltAgent](https://github.com/VoltAgent/voltagent), providing long-term memory capabilities for AI agents. Your VoltAgent applications will remember past conversations and provide personalized responses based on user history.
<Note>
Migrating to v2 from 1.4.x? Check the [migration guide](/migration/tools-v2-upgrade).
</Note>
<Card title="@supermemory/tools on npm" icon="npm" href="https://www.npmjs.com/package/@supermemory/tools">
Check out the NPM page for more details
</Card>

View file

@ -0,0 +1,188 @@
---
title: 'Upgrading @supermemory/tools to v2.0.0'
description: 'Migrate your code from @supermemory/tools 1.4.x to 2.0.0 — config-object signature, customId, and new defaults'
sidebarTitle: 'Tools: v1.4 → 2.0'
---
`@supermemory/tools` v2.0.0 unifies the API across all four integrations (Vercel AI SDK, OpenAI, Mastra, VoltAgent) around a single config-object signature and a consistent conversation-grouping concept. This guide walks you through the breaking changes.
<Note>
This release is **breaking**. Update calls and re-test before bumping in
production.
</Note>
## What changed at a glance
| Area | v1.4.x | v2.0.0 |
| --------------------- | ----------------------------------------------------- | ----------------------------------------------------------- |
| Signature | `withSupermemory(model, "user-123", { ... })` | `withSupermemory(model, { containerTag: "user-123", ... })` |
| Conversation grouping | `conversationId` (Vercel/OpenAI), `threadId` (Mastra) | **`customId`** everywhere |
| `customId` | Optional | **Required** — throws if missing or empty |
| `containerTag` | Positional argument | **Required** field on options object |
| `addMemory` default | `"never"` | `"always"` |
| VoltAgent `verbose` | Hardcoded to `false` | Honored from options |
## Install
```bash
npm install @supermemory/tools@^2.0.0
```
## 1. Vercel AI SDK
```typescript
// v1.4.x
import { withSupermemory } from '@supermemory/tools/ai-sdk';
const model = withSupermemory(openai('gpt-4'), 'user-123', {
conversationId: 'conv-456',
mode: 'full',
});
```
```typescript
// v2.0.0
import { withSupermemory } from '@supermemory/tools/ai-sdk';
const model = withSupermemory(openai('gpt-4'), {
containerTag: 'user-123',
customId: 'conv-456',
mode: 'full',
});
```
<Warning>
`customId` is now **required**. Passing an empty string or omitting it throws
at construction time.
</Warning>
## 2. OpenAI SDK
```typescript
// v1.4.x
import { withSupermemory } from '@supermemory/tools/openai';
const client = withSupermemory(openai, 'user-123', {
conversationId: 'conv-456',
});
```
```typescript
// v2.0.0
import { withSupermemory } from '@supermemory/tools/openai';
const client = withSupermemory(openai, {
containerTag: 'user-123',
customId: 'conv-456',
});
```
Both `containerTag` and `customId` are validated and throw with explicit error messages if missing.
## 3. Mastra
Processor constructors and factory functions both moved to a single options argument. `threadId` is gone — use `customId` instead.
```typescript
// v1.4.x
import {
SupermemoryInputProcessor,
createSupermemoryOutputProcessor,
} from '@supermemory/tools/mastra';
const input = new SupermemoryInputProcessor('user-123', {
mode: 'full',
});
const output = createSupermemoryOutputProcessor('user-123', {
threadId: 'conv-456',
addMemory: 'always',
});
```
```typescript
// v2.0.0
import {
SupermemoryInputProcessor,
createSupermemoryOutputProcessor,
} from '@supermemory/tools/mastra';
const input = new SupermemoryInputProcessor({
containerTag: 'user-123',
customId: 'conv-456',
mode: 'full',
});
const output = createSupermemoryOutputProcessor({
containerTag: 'user-123',
customId: 'conv-456',
});
```
<Note>
In server setups, Mastra's `RequestContext` thread ID still takes precedence
over the construction-time `customId` — the option now acts as the fallback
when no per-request thread ID is provided.
</Note>
## 4. VoltAgent
VoltAgent already used a config-object signature, so the call shape is unchanged. Two behavior fixes ship in v2.0.0:
- `verbose: true` is now honored (was hardcoded to `false` in v1.4.x).
- A runtime warning is logged when advanced search params (`threshold`, `limit`, `rerank`, `rewriteQuery`, `filters`, `include`, `searchMode`) are set while `mode: "profile"` — those parameters are ignored in profile mode.
If you were relying on `verbose: false` implicitly while passing `verbose: true`, you will now see logs. Adjust as needed.
## 5. New default: `addMemory: "always"`
Across all four integrations, `addMemory` now defaults to `"always"`. If your v1.4.x code relied on the old default of `"never"`, set it explicitly:
```typescript
const model = withSupermemory(openai('gpt-4'), {
containerTag: 'user-123',
customId: 'conv-456',
addMemory: 'never', // preserve v1.4.x behavior
});
```
## Conversation persistence
In v1.4.x the Vercel middleware fell back to `client.add` with a synthesized `customId` when no `conversationId` was passed. In v2.0.0, because `customId` is required, all conversation persistence goes through the `/v4/conversations` endpoint via `addConversation`. There is no fallback path.
## Migration checklist
<Steps>
<Step title="Bump the dependency">
`npm install @supermemory/tools@^2.0.0`
</Step>
<Step title="Find every withSupermemory / processor call">
Grep your codebase for `withSupermemory(`, `SupermemoryInputProcessor`,
`SupermemoryOutputProcessor`, `createSupermemoryProcessor`,
`createSupermemoryOutputProcessor`.
</Step>
<Step title="Move containerTag into the options object">
Drop the positional `containerTag` argument and add it to the options
object.
</Step>
<Step title="Rename conversationId / threadId to customId">
Make sure every call site provides a non-empty `customId`.
</Step>
<Step title="Audit addMemory">
If you depended on the old `"never"` default, pass `addMemory: "never"`
explicitly.
</Step>
<Step title="Run your test suite">
Validation throws happen at construction time, so missing fields surface
immediately.
</Step>
</Steps>
## Need help?
- [Vercel AI SDK integration](/integrations/ai-sdk)
- [OpenAI integration](/integrations/openai)
- [Mastra integration](/integrations/mastra)
- [VoltAgent integration](/integrations/voltagent)
If you hit something this guide does not cover, open an issue on [GitHub](https://github.com/supermemoryai/supermemory).

View file

@ -3,8 +3,10 @@
"module": "index.ts",
"type": "module",
"private": true,
"portless": { "name": "docs.dev.supermemory", "script": "dev:app", "appPort": 3003 },
"scripts": {
"dev": "bunx mintlify@latest dev --no-open --port 3003"
"dev": "portless",
"dev:app": "bunx mintlify@latest dev --no-open --port 3003"
},
"devDependencies": {
"@types/bun": "latest",

View file

@ -0,0 +1,252 @@
---
title: "Bash Tool (Python)"
sidebarTitle: "Bash Tool (Python)"
description: "supermemory-bash. The SMFS idea wrapped as a single agent tool, for Python agents and serverless runtimes."
icon: "terminal"
---
`supermemory-bash` is the SMFS idea wrapped as a single agent tool: `run_bash(command)`. The "filesystem" is your Supermemory container. Runs anywhere Python runs. AWS Lambda, Modal, Fly Machines, Cloud Run, your laptop. No mount, no FUSE, no local disk.
Reach for the Bash Tool when your agent runs somewhere it can't mount a real filesystem.
## Install
```bash
pip install supermemory-bash
```
Or with uv:
```bash
uv add supermemory-bash
```
## Quickstart
```python
import asyncio
import os
from supermemory_bash import create_bash
async def main() -> None:
result = await create_bash(
api_key=os.environ["SUPERMEMORY_API_KEY"],
container_tag="user_42",
)
bash = result.bash
r = await bash.exec("ls /")
print(r.stdout)
asyncio.run(main())
```
`create_bash` returns a `CreateBashResult` with:
- `bash`: a `Shell` instance with `.exec(cmd)`
- `tool_description`: a pre-written tool description ready to hand to the model
- `configure_memory_paths(paths)`: scope which paths get extracted into Supermemory
- `refresh()`: re-prime the path index after external writes
## Use it as a model tool
### Anthropic SDK
Pass `tool_description` straight into Claude's tool definition and run a normal agent loop. Each `tool_use` block calls `bash.exec` and the result goes back as a `tool_result`.
```python
import asyncio
import os
import anthropic
from supermemory_bash import create_bash
async def run_agent(user_message: str) -> str:
result = await create_bash(
api_key=os.environ["SUPERMEMORY_API_KEY"],
container_tag="user_42",
)
bash = result.bash
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
tools = [
{
"name": "bash",
"description": result.tool_description,
"input_schema": {
"type": "object",
"properties": {
"cmd": {"type": "string", "description": "The bash command to run."}
},
"required": ["cmd"],
},
}
]
messages = [{"role": "user", "content": user_message}]
for _ in range(10):
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
tools=tools,
messages=messages,
)
if response.stop_reason == "end_turn":
for block in response.content:
if hasattr(block, "text"):
return block.text
return ""
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
cmd = block.input.get("cmd", "")
r = await bash.exec(cmd)
output = r.stdout
if r.stderr:
output += f"\n[stderr]: {r.stderr}"
if r.exit_code != 0:
output += f"\n[exit_code]: {r.exit_code}"
tool_results.append(
{
"type": "tool_result",
"tool_use_id": block.id,
"content": output or "(no output)",
}
)
messages.append({"role": "user", "content": tool_results})
return "(max steps reached)"
asyncio.run(run_agent("What's in my notes about the Q3 launch?"))
```
### OpenAI SDK
Same idea with OpenAI's function-calling format. Define a single `bash` function, dispatch each `tool_calls` entry to `bash.exec`, and feed the output back as a `tool` message.
```python
import asyncio
import json
import os
from openai import OpenAI
from supermemory_bash import create_bash
async def run_agent(user_message: str) -> str:
result = await create_bash(
api_key=os.environ["SUPERMEMORY_API_KEY"],
container_tag="user_42",
)
bash = result.bash
client = OpenAI()
tools = [
{
"type": "function",
"function": {
"name": "bash",
"description": result.tool_description,
"parameters": {
"type": "object",
"properties": {"cmd": {"type": "string"}},
"required": ["cmd"],
},
},
}
]
messages = [{"role": "user", "content": user_message}]
for _ in range(10):
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
)
message = response.choices[0].message
if not message.tool_calls:
return message.content or ""
messages.append(message.model_dump(exclude_none=True))
for call in message.tool_calls:
args = json.loads(call.function.arguments or "{}")
r = await bash.exec(args.get("cmd", ""))
output = r.stdout
if r.stderr:
output += f"\n[stderr]: {r.stderr}"
if r.exit_code != 0:
output += f"\n[exit_code]: {r.exit_code}"
messages.append(
{
"role": "tool",
"tool_call_id": call.id,
"content": output or "(no output)",
}
)
return "(max steps reached)"
asyncio.run(run_agent("List my notes"))
```
### Claude Agent SDK
The [Claude Agent SDK](https://docs.claude.com/en/api/agent-sdk/overview) ships with built-in `Bash`, `Read`, and `Write` tools. If your agent runs somewhere SMFS can be mounted (a long-lived process on macOS or Linux), point those built-ins at an SMFS mount and you don't need `supermemory-bash` at all — the agent just sees your container as a directory.
See [Mount SMFS](/smfs/mount) for setup, or the [provider guides](/smfs/overview#use-smfs-with-your-sandbox-provider) for sandbox-specific instructions.
## Memory
The Bash Tool inherits SMFS memory semantics. By default, files named `user.md` or `memory.md` are extracted as memories. Configure additional memory paths after construction:
```python
result = await create_bash(api_key=api_key, container_tag=container_tag)
bash = result.bash
await result.configure_memory_paths(["/notes/", "/journal.md"])
```
Trailing `/` matches recursively. No slash matches an exact file. Pass `[]` to disable memory generation.
The container also exposes a virtual `profile.md` at the root: a live digest of everything in the container. Read it once at the start of a session to give the model context without walking every file.
```python
r = await bash.exec("cat /profile.md")
print(r.stdout)
```
## Commands the agent can run
The Python tool exposes the same command surface as the TypeScript version: standard Unix builtins (`pwd`, `cd`, `ls`, `cat`, `stat`, `mkdir`, `rm`, `mv`, `cp`, `echo`), search and text utilities (`grep`, `find`, `head`, `tail`, `wc`, `sort`, `sed`, `awk`), plus the custom `sgrep <query> [path]` for semantic search across the container. Pipes, redirects, conditionals, loops, and file tests all work.
See the [TypeScript Bash Tool reference](/smfs/bash-tool#commands-the-agent-can-run) for the full list.
## Configuration
| Option | Default | Purpose |
| --- | --- | --- |
| `api_key` | required | Supermemory API key |
| `container_tag` | required | Container to expose as the filesystem |
| `base_url` | `None` | Override the API endpoint |
| `eager_load` | `True` | Warm the path index when the instance starts |
| `eager_content` | `True` | Also warm the content cache during eager load |
| `cwd` | `"/home/user"` | Initial working directory |
| `env` | `None` | Extra environment variables |
| `cache_ttl_ms` | `150_000` | Content cache TTL in ms. `None` = never expires (single-writer). `0` = no cache. |
The container is what defines the filesystem; setting `cwd` or extra `env` from the host doesn't change the files the agent sees.
## Limitations
- `chmod`, `utimes`, and symlinks (`ln -s`, `readlink`) raise `ENOSYS`.
- `/dev/null` as a redirect target isn't supported. Write to `/tmp/discard.log` instead.
- Binary uploads aren't supported. Text is extracted server-side.

View file

@ -0,0 +1,203 @@
---
title: "Bash Tool"
sidebarTitle: "Bash Tool"
description: "@supermemory/bash. The SMFS idea wrapped as a single agent tool, for serverless and edge runtimes."
icon: "terminal"
---
`@supermemory/bash` is the SMFS idea wrapped as a single agent tool: `run_bash(command)`. The "filesystem" is your Supermemory container. Runs anywhere TypeScript runs. Cloudflare Workers, AWS Lambda, Vercel, Node, the browser. No mount, no FUSE, no local disk.
Reach for the Bash Tool when your agent runs somewhere it can't mount a real filesystem.
## Install
```bash
npm install @supermemory/bash
```
Or with bun:
```bash
bun add @supermemory/bash
```
## Quickstart
```typescript
import { createBash } from "@supermemory/bash";
const { bash, toolDescription } = await createBash({
apiKey: process.env.SUPERMEMORY_API_KEY!,
containerTag: "user_42",
});
const result = await bash.exec("ls /");
console.log(result.stdout);
```
`createBash` returns:
- `bash`: the instance with `.exec(cmd)`
- `toolDescription`: a pre-written tool description ready to hand to the model
- `configureMemoryPaths(paths)`: scope which paths get extracted into Supermemory
- `refresh()`: re-prime the path index after external writes
## Use it as a model tool
### Vercel AI SDK
```typescript
import { generateText, tool } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const { bash, toolDescription } = await createBash({
apiKey: process.env.SUPERMEMORY_API_KEY!,
containerTag: "user_42",
});
const result = await generateText({
model: openai("gpt-4o"),
tools: {
bash: tool({
description: toolDescription,
inputSchema: z.object({ cmd: z.string() }),
execute: async ({ cmd }) => bash.exec(cmd),
}),
},
prompt: "What's in my notes about the Q3 launch?",
});
```
### Anthropic SDK
```typescript
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const { bash, toolDescription } = await createBash({
apiKey: process.env.SUPERMEMORY_API_KEY!,
containerTag: "user_42",
});
const response = await client.messages.create({
model: "claude-opus-4-7",
max_tokens: 4096,
tools: [
{
name: "bash",
description: toolDescription,
input_schema: {
type: "object",
properties: { cmd: { type: "string" } },
required: ["cmd"],
},
},
],
messages: [{ role: "user", content: "List my notes" }],
});
```
### OpenAI SDK
```typescript
import OpenAI from "openai";
const client = new OpenAI();
const { bash, toolDescription } = await createBash({
apiKey: process.env.SUPERMEMORY_API_KEY!,
containerTag: "user_42",
});
const response = await client.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: "List my notes" }],
tools: [
{
type: "function",
function: {
name: "bash",
description: toolDescription,
parameters: {
type: "object",
properties: { cmd: { type: "string" } },
required: ["cmd"],
},
},
},
],
});
```
## Memory
The Bash Tool inherits SMFS memory semantics. By default, files named `user.md` or `memory.md` are extracted as memories. Configure additional memory paths after construction:
```typescript
const { configureMemoryPaths } = await createBash({ apiKey, containerTag });
await configureMemoryPaths(["/notes/", "/journal.md"]);
```
Trailing `/` matches recursively. No slash matches an exact file. Pass `[]` to disable memory generation.
The container also exposes a virtual `profile.md` at the root: a live digest of everything in the container. Read it once at the start of a session to give the model context without walking every file.
```typescript
const { stdout } = await bash.exec("cat /profile.md");
```
## Commands the agent can run
Standard Unix surface, plus one custom command. Each does what you'd expect.
### Filesystem
- `pwd`: print working directory
- `cd`: change directory
- `ls`, `ls -la`: list
- `cat`: read a file
- `stat`: file metadata
- `mkdir`: create directory
- `rm`, `rm -rf`: delete
- `rmdir`: delete empty directory
- `mv`: move or rename
- `cp`: copy
- `echo`: write or append (`echo "x" > file`, `echo "x" >> file`)
### Search and text
- `grep`: literal substring match against a known path
- `sgrep <query> [path]`: **semantic** search across the container. Trailing `/` on path scopes to a directory. No path searches everything.
- `find`: search by name or properties
- `head`, `tail`: first or last N lines
- `wc`: word, line, byte counts
- `sort`: sort lines
- `sed`, `awk`: text transformation
### Shell features
- Pipes (`|`)
- Redirects (`>`, `>>`)
- Conditionals (`&&`, `||`)
- Loops (`for`, `while`)
- File tests (`[ -f ]`, `[ -d ]`, `[ -e ]`)
## Configuration
| Option | Default | Purpose |
| --- | --- | --- |
| `apiKey` | required | Supermemory API key |
| `containerTag` | required | Container to expose as the filesystem |
| `baseURL` | SDK default | Override the API endpoint |
| `eagerLoad` | `true` | Warm the path index when the instance starts |
| `eagerContent` | `true` | Also warm the content cache during eager load |
| `cacheTtlMs` | `150_000` | Content cache TTL in ms. `null` = never expires (single-writer). `0` = no cache. |
Other options (`customCommands`, `logger`, plus `just-bash` pass-throughs like `executionLimits`, `network`, `python`, `javascript`, `cwd`, `env`) exist but aren't part of the supported surface for the SMFS use case. The container is what defines the filesystem; setting `cwd` or extra `env` from the host doesn't change that.
## Limitations
- `chmod`, `utimes`, and symlinks (`ln -s`, `readlink`) throw `ENOSYS`.
- `/dev/null` as a redirect target isn't supported. Write to `/tmp/discard.log` instead.
- Binary uploads aren't supported. Text is extracted server-side.

View file

@ -0,0 +1,48 @@
---
title: "Examples"
sidebarTitle: "Examples"
description: "Full web-based demo apps you can clone and run."
icon: "code"
---
Web-based example apps showing SMFS in realistic use cases. Each one is a
complete project with its own README, dependencies, and a working UI.
<CardGroup cols={2}>
<Card
title="Research Assistant"
icon="magnifying-glass"
href="https://github.com/supermemoryai/examples/tree/main/research-assistant"
>
Upload documents and chat with an AI that can search and cite them.
Next.js + TypeScript + `@supermemory/bash`.
</Card>
<Card
title="Knowledge Base"
icon="book"
href="https://github.com/supermemoryai/examples/tree/main/knowledge-base"
>
Add notes and chat with an AI that can search your knowledge base.
FastAPI + Python + `supermemory-bash`.
</Card>
<Card
title="Code Sandbox"
icon="terminal"
href="https://github.com/supermemoryai/examples/tree/main/code-sandbox"
>
Write and run code in an E2B sandbox with persistent AI memory.
Next.js + E2B SDK + SMFS mount.
</Card>
</CardGroup>
The Research Assistant and Knowledge Base examples use the
[Bash Tool](/smfs/bash-tool) — the serverless-friendly way to give an agent a
Supermemory-backed filesystem. The Code Sandbox example uses an
[E2B](/smfs/providers/e2b) sandbox with a real SMFS mount.
## Running an example
1. Clone the [examples repo](https://github.com/supermemoryai/examples)
2. `cd` into the example you want
3. Follow the README — typically: install deps, copy `.env.example` to `.env`,
fill in your API keys, and start the dev server

View file

@ -0,0 +1,68 @@
---
title: "Install SMFS"
sidebarTitle: "Install"
description: "Install, log in, mount."
icon: "download"
---
## 1. Install the binary
```bash
curl -fsSL https://smfs.ai/install | bash
```
Drops `smfs` into `~/.local/bin`. Works on macOS (arm64, x64) and Linux (arm64, x64).
If `smfs` isn't on your `PATH` after install, add `~/.local/bin` to your shell profile and reopen the terminal.
## 2. Log in
```bash
smfs login
```
One-time. Prompts you for your Supermemory API key and stores it in your global credentials. Get a key at [console.supermemory.ai](https://console.supermemory.ai).
You can also pass the key directly:
```bash
smfs login --key sm_...
```
## 3. Mount a container
```bash
smfs mount agent_memory
```
`agent_memory` is your container tag. SMFS creates a folder named `agent_memory/` in the current directory and mounts the container there.
That's it. Read it with `ls`, `cat`, `grep`. See [Mount](/smfs/mount) for memory paths, sync modes, flags, and every subcommand.
To mount somewhere else, pass `--path`:
```bash
smfs mount agent_memory --path ~/memory
```
## Optional: refresh the semantic grep wrapper
`smfs mount` installs the shell wrapper automatically the first time you mount. If you ever need to force a clean reinstall (after upgrading the binary, for example):
```bash
smfs init
```
It writes the wrapper into your `~/.zshrc` directly. Then reopen your terminal (or `source ~/.zshrc`) so the new shell picks it up.
Inside any mount, plain `grep` becomes semantic. Outside a mount, your normal `grep` is untouched. Pass any flag (`grep -r`, `grep -i`, anything) and you get the real `grep` back.
## Refresh the binary
If anything ever feels broken:
```bash
smfs install
```
Re-copies the binary into `~/.local/bin` and resets permissions.

289
apps/docs/smfs/mount.mdx Normal file
View file

@ -0,0 +1,289 @@
---
title: "Mount"
sidebarTitle: "Mount"
description: "Mount a Supermemory container, generate memories, and sync."
icon: "hard-drive"
---
A mount turns a Supermemory container into a directory on your machine. macOS uses NFSv3, Linux uses FUSE. Both are handled for you.
```bash
smfs mount <container-tag>
```
Example:
```bash
smfs mount agent_memory
```
`agent_memory` is the container tag. SMFS creates a folder named `agent_memory/` in the current directory and mounts the container there. The mount runs as a background daemon. A marker file `.smfs` is written at the mount root so other tools (and the semantic `grep` wrapper from `smfs init`) can find the mount.
To mount at a different path:
```bash
smfs mount agent_memory --path ~/memory
```
## Memory
This is the part most people miss. SMFS isn't a normal filesystem. It generates **memories** from files at specific paths. Memories are extracted, summarized, and indexed by Supermemory.
Files outside those paths are still semantically searchable; they're indexed through **SuperRAG** by default. Nothing in the mount is dead weight.
### Defaults
By default, files named `user.md` or `memory.md` are treated as memory paths. Drop those files anywhere in your mount and Supermemory generates memories from them automatically.
### Configure your own memory paths
Pass `--memory-paths` at mount time to control which files become memories:
```bash
smfs mount agent_memory --memory-paths "/notes/,/journal.md"
```
Rules:
- Paths are **absolute**, anchored at the mount root. Always start with `/`.
- Trailing `/` matches every file inside that folder, recursively (`/notes/` covers `/notes/foo.md`, `/notes/2026/march.md`, etc.).
- No trailing slash matches one exact file (`/journal.md`).
- Comma-separated. Multiple paths are fine.
- Empty string disables memory generation entirely (`--memory-paths ""`).
- Omit the flag and Supermemory keeps whatever the container tag already has, falling back to `user.md` and `memory.md`.
### profile.md
Every mount has a virtual file at the root called `profile.md`. It's auto-generated, read-only, and backed by Supermemory. The model can `cat profile.md` to get a live digest of everything in the container without walking every file. Useful as a first call at the start of a session.
```bash
cat agent_memory/profile.md
```
You can't write to it. As the underlying memories change, Supermemory regenerates it.
## Sync modes
Three modes plus a force-sync command. Pick by what your agent actually needs.
### Bidirectional (default)
Local reads hit the cache. Local writes queue and push to Supermemory in the background. Remote changes are pulled on a poll. This is what you get if you pass no flags.
```bash
smfs mount agent_memory
```
Use this when more than one writer (you, another agent, the dashboard) might touch the container.
### No-sync
Writes still push to Supermemory. Polling for remote changes is off. The agent sees a view that doesn't shift under it mid-task.
```bash
smfs mount agent_memory --no-sync
```
Use this when your agent is the only writer, or when you want predictable reads.
### Ephemeral
Cache is in memory only. Nothing persists after unmount. Writes still push.
```bash
smfs mount agent_memory --ephemeral
```
Use this for short-lived sandboxes. CI jobs, throwaway containers, one-shot agent runs.
### Force a sync now
```bash
smfs sync
```
Pushes pending writes and pulls remote changes immediately. Useful right before tearing down a sandbox.
## All mount flags
| Flag | What it does |
| --- | --- |
| `--path <path>` | Override the default mount path (`./<container-tag>/`). |
| `--memory-paths <paths>` | Scope which files become memories. See [Memory](#memory). |
| `--no-sync` | Stop polling for remote changes. Writes still push. |
| `--clean` | Wipe local cache before mounting. Pulls fresh from the API. |
| `--ephemeral` | In-memory cache. Nothing persists after unmount. |
| `--sync-interval <secs>` | Remote-change poll interval. Default `30`. |
| `--drain-timeout <secs>` | Max time to flush pending writes during unmount. Default `30`. |
| `--foreground` | Run the daemon inline instead of detaching. |
| `--backend <name>` | Linux only. `fuse` (default) or `nfs`. |
| `--key <key>` | Pass an API key explicitly. Saved to project credentials. |
## Multiple agents and multiple containers
- **Different devices, same container tag**: fully supported. Many agents can mount the same container concurrently from different machines.
- **Same device, same container tag, mounted twice**: not supported. Use one mount per container per device.
- **Same device, different containers**: mount as many as you want in parallel.
## Commands
Every `smfs` subcommand. Click any one to expand.
<AccordionGroup>
<Accordion title="smfs mount" icon="play">
Mount a container. Defaults to `./<container-tag>/`; pass `--path` to mount elsewhere.
```bash
smfs mount agent_memory
smfs mount agent_memory --path ~/memory
```
See the flags table above for everything you can pass.
</Accordion>
<Accordion title="smfs unmount" icon="square">
Unmount a running mount. Drains pending writes up to `--drain-timeout`, then exits the daemon. Anything not drained resumes on the next mount.
```bash
smfs unmount agent_memory
```
Inside the mount, you can omit the tag and let SMFS resolve it from the nearest `.smfs` marker.
```bash
smfs unmount
smfs unmount --force
```
</Accordion>
<Accordion title="smfs list" icon="list">
List every SMFS mount running on this machine.
```bash
smfs list
```
</Accordion>
<Accordion title="smfs status" icon="activity">
Show daemon status for a mount: connectivity, queue depth, last sync. Auto-detects the tag via the nearest `.smfs` marker.
```bash
smfs status
smfs status agent_memory
smfs status --json
```
</Accordion>
<Accordion title="smfs logs" icon="scroll-text">
Tail the daemon log for a mount. Auto-detects the tag via the nearest `.smfs` marker.
```bash
smfs logs
smfs logs -f
smfs logs -n 500
```
</Accordion>
<Accordion title="smfs sync" icon="refresh-cw">
Force an immediate sync cycle. Push pending writes, pull remote changes.
```bash
smfs sync agent_memory
```
Inside the mount, the tag is optional (resolved from the nearest `.smfs` marker).
```bash
smfs sync
```
</Accordion>
<Accordion title="smfs grep" icon="search">
Semantic search across a container without being inside the mount. The optional second argument scopes the search to a subpath inside the container. Inside a mount, plain `grep` already does this; `smfs grep` is the explicit form for scripts.
```bash
smfs grep "deadline"
smfs grep "deadline" /notes/
```
</Accordion>
<Accordion title="smfs login" icon="log-in">
One-time auth. Prompts for your Supermemory API key and stores it in your global credentials. You can also pass it directly with `--key`.
```bash
smfs login
smfs login --key sm_...
```
</Accordion>
<Accordion title="smfs whoami" icon="user">
Print the currently-authenticated user, organization, and API endpoint.
```bash
smfs whoami
```
</Accordion>
<Accordion title="smfs logout" icon="log-out">
Remove stored credentials. Active mounts keep running until you `smfs unmount` them.
```bash
smfs logout
```
</Accordion>
<Accordion title="smfs init" icon="terminal">
Force-installs the shell wrapper that makes plain `grep` semantic inside mounts. Writes directly to `~/.zshrc`. `smfs mount` also installs it automatically the first time, so you only need this to refresh after an upgrade.
```bash
smfs init
```
Reopen your terminal (or `source ~/.zshrc`) after running it.
</Accordion>
<Accordion title="smfs install" icon="download">
Self-install. Copies the running binary to `~/.local/bin` and resets permissions. Run this if your `smfs` install ever feels broken.
```bash
smfs install
```
</Accordion>
</AccordionGroup>
## FAQ
<AccordionGroup>
<Accordion title="Semantic grep isn't working inside my mount" icon="circle-help">
Run `smfs init` to force-install the shell wrapper. It writes directly to `~/.zshrc`. Then reopen your terminal so the new shell picks it up.
The wrapper only triggers when you're inside a mount (it looks for the `.smfs` marker file at the mount root). Outside a mount, `grep` stays normal. Inside a mount, any flag you pass falls through to the real `grep`.
</Accordion>
<Accordion title="Can I install SMFS on Windows?" icon="circle-help">
Not yet. SMFS supports macOS (arm64, x64) and Linux (arm64, x64) for now. Windows isn't on the v0 roadmap.
On Windows, use the [Bash Tool](/smfs/bash-tool) instead. It runs anywhere TypeScript runs and gives your agent the same `ls`, `cat`, `grep`, `sgrep` surface without needing a mount.
</Accordion>
<Accordion title="My cache feels stale or out of sync" icon="circle-help">
Re-mount with `--clean` to wipe the local cache and pull everything fresh from the API:
```bash
smfs unmount agent_memory
smfs mount agent_memory --clean
```
Nothing on the server changes; only the local SQLite cache gets reset.
</Accordion>
<Accordion title="Can two agents on the same machine share a mount?" icon="circle-help">
Yes. Once a container is mounted, anything on that machine can read and write through the mount path. The constraint is one mount per container tag per device. Mount it once, point both agents at the same folder.
</Accordion>
<Accordion title="Can two separate sandboxes use the same container?" icon="circle-help">
Yes, absolutely. Mount the same container tag from each sandbox. Bidirectional sync keeps everything in step as either side writes, so Agent A in sandbox 1 sees Agent B's writes from sandbox 2 within a sync interval.
To avoid stepping on each other, give each agent its own subdirectory (`/agent_a/`, `/agent_b/`, etc.). They can still read across the whole mount, cross-reference each other's findings, and build on each other's work. The shared container is the point.
</Accordion>
</AccordionGroup>

View file

@ -0,0 +1,71 @@
---
title: "SMFS"
sidebarTitle: "Overview"
description: "Memory your agent can grep."
icon: "database"
---
**SMFS** mounts your Supermemory container as a real directory. Agents read it with `ls`, `cat`, and `grep`. No SDK to learn, no client to wire up, no embeddings to think about.
SMFS is open source and free for everyone.
## Why a filesystem
Every model already knows how a filesystem works. It can `ls`, `cat`, `grep`, `find`, redirect with `>`, pipe with `|`. You don't have to teach it a new API surface, and the grammar carries across runtimes.
The catch: a filesystem on its own isn't great for memory. Search means walking the tree. Long files burn through context. The model has to hold the directory structure in its head. None of that scales as memory grows.
SMFS fixes the catch. The shell is real, but underneath:
- **Semantic `grep` by default.** One call surfaces what matters across the whole container, ranked by meaning. Pass any flag and you fall through to the real `grep` for exact matches.
- **Memory paths get distilled.** Files marked as memory paths are extracted and indexed by Supermemory. They don't bloat the model's context.
- **Virtual `profile.md`.** A live digest of the container at the mount root. The model can `cat profile.md` for a one-shot summary instead of walking every file.
- **Bidirectional sync** runs in the background. Local reads hit cache; writes push to Supermemory.
You get filesystem ergonomics without paying the filesystem tax in tokens.
## Two ways to use SMFS
Pick by where your agent runs.
<CardGroup cols={2}>
<Card title="Mount (smfs binary)" icon="hard-drive" href="/smfs/install">
For agents and tools with a real filesystem. Claude Code, Cursor, devcontainers, Docker, Codespaces. NFSv3 on macOS, FUSE on Linux.
</Card>
<Card title="Bash Tool (TypeScript & Python)" icon="terminal" href="/smfs/bash-tool">
For agents running serverless or at the edge. Cloudflare Workers, AWS Lambda, Vercel, Modal. A virtual bash where the filesystem is your container. Available as [`@supermemory/bash`](/smfs/bash-tool) for TypeScript and [`supermemory-bash`](/smfs/bash-tool-python) for Python.
</Card>
</CardGroup>
## Use SMFS with your sandbox provider
Already using a sandbox or agent platform? Jump straight to the guide for your provider.
<CardGroup cols={2}>
<Card title="Daytona" icon="server" href="/smfs/providers/daytona">
Isolated Linux sandboxes with millisecond boot times. Mount SMFS inside or use the bash tool from your orchestrating code.
</Card>
<Card title="E2B" icon="cube" href="/smfs/providers/e2b">
Firecracker microVMs for AI code execution. Install SMFS directly or use a custom template with it pre-installed.
</Card>
<Card title="Vercel AI SDK" icon="triangle" href="/smfs/providers/vercel">
The most popular TypeScript agent framework. Add memory as a tool with one function call.
</Card>
<Card title="Cloudflare Workers" icon="cloud" href="/smfs/providers/cloudflare">
Edge-first agents. Use the bash tool in Workers, or mount SMFS in Cloudflare Containers.
</Card>
</CardGroup>
## Next steps
<CardGroup cols={2}>
<Card title="Install SMFS" icon="download" href="/smfs/install">
One curl, one mount, you're done.
</Card>
<Card title="Use the Bash Tool" icon="terminal" href="/smfs/bash-tool">
Drop SMFS into a TypeScript or Python agent without mounting anything.
</Card>
<Card title="Examples" icon="code" href="/smfs/examples">
Full working apps you can clone and run — legal docs, support agents, and more.
</Card>
</CardGroup>

View file

@ -0,0 +1,337 @@
---
title: "Cloudflare"
description: "Give your AI agent persistent memory inside a Cloudflare Container using SMFS"
---
Mount a Supermemory container inside a
[Cloudflare Container](https://developers.cloudflare.com/containers/) so your
agent can read and write memory using standard filesystem commands.
## How it works
There are two ways to wire SMFS into a Cloudflare Container — pick the one that
fits your architecture.
### Agent inside the container
The agent process runs inside the container with direct access to the SMFS
mount. The entrypoint sets up the mount and starts the agent.
```mermaid
graph LR
subgraph Cloudflare Container
Agent["Claude Agent"] -->|"cat, ls, echo"| Mount["/memory<br/>(SMFS mount)"]
end
Mount -->|sync| SM["Supermemory"]
```
### Agent outside the container
The agent runs in a Cloudflare Worker and sends commands to the container over
HTTP. The container exposes a simple exec endpoint.
```mermaid
graph LR
Agent["Worker<br/>(agent logic)"] -->|"containerFetch('/exec')"| Container
subgraph Container ["Cloudflare Container"]
Mount["/memory<br/>(SMFS mount)"]
end
Mount -->|sync| SM["Supermemory"]
```
## Prerequisites
- A [Supermemory API key](https://supermemory.ai)
- An [Anthropic API key](https://console.anthropic.com)
- A [Cloudflare account](https://dash.cloudflare.com) with Containers enabled (Workers Paid plan)
- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/)
- The [`@cloudflare/containers`](https://www.npmjs.com/package/@cloudflare/containers) package: `npm install @cloudflare/containers`
<Note>
Cloudflare Containers are implemented as container-enabled Durable Objects.
You declare a `Container` subclass, bind it as a Durable Object, and
reference its image in the `containers` array. Worker secrets are **not**
automatically visible inside the container — you have to pass them through
`envVars` when starting the container (see below).
</Note>
---
## Pattern A: Agent inside the container
SMFS and the Claude Agent SDK are baked into the container image. On startup,
the entrypoint mounts memory and runs the agent.
### Dockerfile
```dockerfile Dockerfile
FROM python:3.12-slim
RUN apt-get update && apt-get install -y fuse3 curl bash && rm -rf /var/lib/apt/lists/*
RUN echo 'user_allow_other' >> /etc/fuse.conf
RUN curl -fsSL https://smfs.ai/install | bash -s -- 0.0.1-rc2
ENV PATH="/root/.local/bin:$PATH"
RUN pip install claude-agent-sdk
COPY agent.py /app/agent.py
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
```
### Entrypoint
```bash entrypoint.sh
#!/bin/bash
set -e
smfs login --key "$SUPERMEMORY_API_KEY"
smfs mount my_agent --ephemeral --path /memory --foreground &
sleep 3
exec python3 /app/agent.py
```
### Agent code
```python agent.py
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions
MEMORY = "/memory"
async def main():
async for message in query(
prompt=f"You have a persistent memory filesystem at {MEMORY}. "
"Read profile.md to learn about the user, then create "
"session_notes.md summarizing what you found.",
options=ClaudeAgentOptions(
allowed_tools=["Bash", "Read", "Write"],
cwd=MEMORY,
),
):
print(message)
asyncio.run(main())
```
### Worker
The Worker defines the `Container` subclass and forwards Worker secrets into
the container via `envVars`:
```typescript worker.ts
import { Container, getContainer } from "@cloudflare/containers";
export class MyAgentContainer extends Container {
defaultPort = 8080;
// Forward Worker secrets into the container at start time.
// `this.env` is the Worker env object, populated from wrangler secrets.
envVars = {
SUPERMEMORY_API_KEY: this.env.SUPERMEMORY_API_KEY,
ANTHROPIC_API_KEY: this.env.ANTHROPIC_API_KEY,
};
}
export default {
async fetch(request: Request, env: Env) {
// The container runs the agent and exits; this Worker route just kicks
// it off (e.g. on a queue message or scheduled trigger).
const container = getContainer(env.MY_CONTAINER, "agent-singleton");
return container.fetch(request);
},
};
interface Env {
MY_CONTAINER: DurableObjectNamespace<MyAgentContainer>;
SUPERMEMORY_API_KEY: string;
ANTHROPIC_API_KEY: string;
}
```
### Config
```jsonc wrangler.jsonc
{
"name": "memory-agent",
"main": "worker.ts",
"compatibility_date": "2025-04-03",
"containers": [
{
"class_name": "MyAgentContainer",
"image": "./Dockerfile",
"max_instances": 5
}
],
"durable_objects": {
"bindings": [
{ "name": "MY_CONTAINER", "class_name": "MyAgentContainer" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["MyAgentContainer"] }
]
}
```
```bash
wrangler secret put SUPERMEMORY_API_KEY
wrangler secret put ANTHROPIC_API_KEY
wrangler deploy
```
---
## Pattern B: Agent outside the container
The agent logic lives in a Worker. The container just runs SMFS and exposes an
HTTP endpoint for executing commands against the mount.
<Warning>
The `/exec` endpoint below runs arbitrary shell commands inside the
container. **Only call it from your Worker** — never expose it publicly,
and never pass user input straight into `command` without validation.
Cloudflare Containers are addressable only through their Worker by default,
so this is safe as long as you don't add a public route that proxies to
`/exec`.
</Warning>
### Container (exec server)
The Dockerfile and entrypoint are nearly identical to Pattern A — the only
differences are the Python deps (`flask` instead of `claude-agent-sdk`) and
the file we exec at the end.
```dockerfile Dockerfile
FROM python:3.12-slim
RUN apt-get update && apt-get install -y fuse3 curl bash && rm -rf /var/lib/apt/lists/*
RUN echo 'user_allow_other' >> /etc/fuse.conf
RUN curl -fsSL https://smfs.ai/install | bash -s -- 0.0.1-rc2
ENV PATH="/root/.local/bin:$PATH"
RUN pip install flask gunicorn
COPY server.py /app/server.py
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
```
The entrypoint differs from Pattern A only in the final `exec` line — we run
gunicorn against the Flask app instead of `python3 agent.py`:
```bash entrypoint.sh
#!/bin/bash
set -e
smfs login --key "$SUPERMEMORY_API_KEY"
smfs mount my_agent --ephemeral --path /memory --foreground &
sleep 3
exec gunicorn -b 0.0.0.0:8080 --chdir /app server:app
```
```python server.py
import subprocess
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route("/exec", methods=["POST"])
def exec_command():
cmd = request.json["command"]
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True, cwd="/memory", timeout=10
)
return jsonify(stdout=result.stdout, stderr=result.stderr, code=result.returncode)
```
<Note>
We use gunicorn instead of `app.run(...)` because Flask's built-in dev
server isn't meant for production traffic. If you'd rather just see it
work, you can replace the `exec` line with
`exec python3 /app/server.py` and add `app.run(host="0.0.0.0", port=8080)`
to `server.py` — but switch back to gunicorn before you ship.
</Note>
### Worker (agent logic)
```typescript worker.ts
import { Container, getContainer } from "@cloudflare/containers";
export class ExecContainer extends Container {
defaultPort = 8080;
envVars = {
SUPERMEMORY_API_KEY: this.env.SUPERMEMORY_API_KEY,
};
}
export default {
async fetch(_request: Request, env: Env) {
const container = getContainer(env.MY_CONTAINER, "agent-singleton");
const profile = await container
.fetch(new Request("http://container/exec", {
method: "POST",
body: JSON.stringify({ command: "cat /memory/profile.md" }),
headers: { "Content-Type": "application/json" },
}))
.then((r) => r.json<{ stdout: string }>());
return Response.json({ profile: profile.stdout });
},
};
interface Env {
MY_CONTAINER: DurableObjectNamespace<ExecContainer>;
SUPERMEMORY_API_KEY: string;
}
```
### Config
```jsonc wrangler.jsonc
{
"name": "memory-exec",
"main": "worker.ts",
"compatibility_date": "2025-04-03",
"containers": [
{
"class_name": "ExecContainer",
"image": "./Dockerfile",
"max_instances": 5
}
],
"durable_objects": {
"bindings": [
{ "name": "MY_CONTAINER", "class_name": "ExecContainer" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["ExecContainer"] }
]
}
```
```bash
wrangler secret put SUPERMEMORY_API_KEY
wrangler deploy
```
---
## Tips
- Use `--ephemeral` for container mounts — keeps the cache in memory only, but
writes still push to Supermemory
- Use `smfs grep 'query'` for semantic search across all files
- Worker secrets aren't automatically visible inside the container. Pass each
one through the `envVars` field on your `Container` subclass (see the Worker
snippets above)
- Use `containerFetch` from within a Container class method (e.g., lifecycle
hooks) to call the container's own HTTP server. From the Worker, use the
stub's `.fetch()` method instead

View file

@ -0,0 +1,282 @@
---
title: "Daytona"
description: "Give your AI agent persistent memory inside a Daytona sandbox using SMFS"
---
Mount a Supermemory container inside a [Daytona](https://daytona.io) sandbox so
your agent can read and write memory using standard filesystem commands.
<Warning>
Daytona sandboxes currently cannot reach `api.supermemory.ai` from their
datacenter IPs. The SMFS binary still installs (we download it directly from
GitHub Releases), the FUSE mount still starts, and `pip install
claude-agent-sdk` still works — but the runtime sync to Supermemory fails. We're
working with Daytona to resolve this. In the meantime, use
[E2B](/smfs/providers/e2b) or a [self-hosted mount](/smfs/providers/vercel).
</Warning>
## How it works
There are two ways to wire SMFS into a Daytona sandbox — pick the one that fits
your architecture.
### Agent inside the sandbox
The agent process runs inside the sandbox and accesses the SMFS mount directly.
```mermaid
graph LR
subgraph Daytona Sandbox
Agent["Claude Agent"] -->|"cat, ls, echo"| Mount["/home/daytona/memory<br/>(SMFS mount)"]
end
Mount -->|sync| SM["Supermemory"]
```
### Agent outside the sandbox
The agent runs in your orchestrating code and executes commands inside the
sandbox remotely.
```mermaid
graph LR
Agent["Claude Agent<br/>(your server)"] -->|"sandbox.process.exec()"| Sandbox
subgraph Sandbox ["Daytona Sandbox"]
Mount["/home/daytona/memory<br/>(SMFS mount)"]
end
Mount -->|sync| SM["Supermemory"]
```
## Prerequisites
- A [Supermemory API key](https://supermemory.ai)
- A [Daytona API key](https://app.daytona.io) — go to **API Keys** in the sidebar
- An [Anthropic API key](https://console.anthropic.com)
---
## Install SMFS in a Daytona sandbox
Both patterns below run the same setup snippet inside the sandbox before
mounting. Daytona can't reach `smfs.ai`, so we download the binary directly
from GitHub Releases and add `~/.local/bin` to PATH.
<Tabs>
<Tab title="Python">
```python
SMFS_INSTALL = (
"mkdir -p $HOME/.local/bin && "
"curl -sL https://github.com/supermemoryai/smfs/releases/download/"
"v0.0.1-rc2/smfs-linux-x64 -o $HOME/.local/bin/smfs && "
"chmod +x $HOME/.local/bin/smfs && "
"echo 'user_allow_other' | sudo tee -a /etc/fuse.conf > /dev/null && "
"pip install claude-agent-sdk"
)
```
</Tab>
<Tab title="TypeScript">
```typescript
const SMFS_INSTALL =
"mkdir -p $HOME/.local/bin && " +
"curl -sL https://github.com/supermemoryai/smfs/releases/download/" +
"v0.0.1-rc2/smfs-linux-x64 -o $HOME/.local/bin/smfs && " +
"chmod +x $HOME/.local/bin/smfs && " +
"echo 'user_allow_other' | sudo tee -a /etc/fuse.conf > /dev/null && " +
"pip install claude-agent-sdk";
```
</Tab>
</Tabs>
---
## Pattern A: Agent inside the sandbox
### Agent code
```python agent.py
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions
MEMORY = "/home/daytona/memory"
async def main():
async for message in query(
prompt=f"You have a persistent memory filesystem at {MEMORY}. "
"Read profile.md to learn about the user, then create "
"session_notes.md summarizing what you found.",
options=ClaudeAgentOptions(
allowed_tools=["Bash", "Read", "Write"],
cwd=MEMORY,
),
):
print(message)
asyncio.run(main())
```
### Orchestration
<Tabs>
<Tab title="Python">
```python run.py
import os
from pathlib import Path
from daytona_sdk import Daytona, DaytonaConfig
daytona = Daytona(DaytonaConfig(
api_key=os.environ["DAYTONA_API_KEY"],
))
sandbox = daytona.create(
env_vars={
"SUPERMEMORY_API_KEY": os.environ["SUPERMEMORY_API_KEY"],
"ANTHROPIC_API_KEY": os.environ["ANTHROPIC_API_KEY"],
},
)
# See "Install SMFS in a Daytona sandbox" above
sandbox.process.exec(SMFS_INSTALL)
# Mount memory
sandbox.process.exec("$HOME/.local/bin/smfs login --key $SUPERMEMORY_API_KEY")
sandbox.process.exec(
"bash -c '$HOME/.local/bin/smfs mount my_agent --ephemeral"
" --path /home/daytona/memory --foreground &' && sleep 3"
)
# Upload and run the agent
sandbox.fs.upload_file(Path("agent.py").read_bytes(), "agent.py")
result = sandbox.process.exec("python3 agent.py")
print(result.result)
daytona.delete(sandbox)
```
</Tab>
<Tab title="TypeScript">
```typescript run.ts
import { Daytona } from "@daytonaio/sdk";
import { readFileSync } from "fs";
const daytona = new Daytona({
apiKey: process.env.DAYTONA_API_KEY!,
});
const sandbox = await daytona.create({
envVars: {
SUPERMEMORY_API_KEY: process.env.SUPERMEMORY_API_KEY!,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!,
},
});
// See "Install SMFS in a Daytona sandbox" above
await sandbox.process.exec(SMFS_INSTALL);
// Mount memory
await sandbox.process.exec(
"$HOME/.local/bin/smfs login --key $SUPERMEMORY_API_KEY"
);
await sandbox.process.exec(
"bash -c '$HOME/.local/bin/smfs mount my_agent --ephemeral " +
"--path /home/daytona/memory --foreground &' && sleep 3"
);
// Upload and run the agent
await sandbox.fs.uploadFile(readFileSync("agent.py"), "agent.py");
const result = await sandbox.process.exec("python3 agent.py");
console.log(result.result);
await daytona.delete(sandbox);
```
</Tab>
</Tabs>
---
## Pattern B: Agent outside the sandbox
The agent runs in your server process and executes commands inside the sandbox
remotely via `sandbox.process.exec()`.
<Tabs>
<Tab title="Python">
```python run.py
import os
from daytona_sdk import Daytona, DaytonaConfig
daytona = Daytona(DaytonaConfig(
api_key=os.environ["DAYTONA_API_KEY"],
))
sandbox = daytona.create(
env_vars={
"SUPERMEMORY_API_KEY": os.environ["SUPERMEMORY_API_KEY"],
},
)
# See "Install SMFS in a Daytona sandbox" above
sandbox.process.exec(SMFS_INSTALL)
sandbox.process.exec("$HOME/.local/bin/smfs login --key $SUPERMEMORY_API_KEY")
sandbox.process.exec(
"bash -c '$HOME/.local/bin/smfs mount my_agent --ephemeral"
" --path /home/daytona/memory --foreground &' && sleep 3"
)
# Agent runs here — executes commands in the sandbox
profile = sandbox.process.exec("cat /home/daytona/memory/profile.md")
print("Profile:", profile.result)
sandbox.process.exec(
"bash -c 'echo \"Session started at $(date)\" > /home/daytona/memory/session_notes.md'"
)
files = sandbox.process.exec("ls /home/daytona/memory")
print("Files:", files.result)
daytona.delete(sandbox)
```
</Tab>
<Tab title="TypeScript">
```typescript run.ts
import { Daytona } from "@daytonaio/sdk";
const daytona = new Daytona({
apiKey: process.env.DAYTONA_API_KEY!,
});
const sandbox = await daytona.create({
envVars: {
SUPERMEMORY_API_KEY: process.env.SUPERMEMORY_API_KEY!,
},
});
// See "Install SMFS in a Daytona sandbox" above
await sandbox.process.exec(SMFS_INSTALL);
await sandbox.process.exec(
"$HOME/.local/bin/smfs login --key $SUPERMEMORY_API_KEY"
);
await sandbox.process.exec(
"bash -c '$HOME/.local/bin/smfs mount my_agent --ephemeral " +
"--path /home/daytona/memory --foreground &' && sleep 3"
);
// Agent runs here — executes commands in the sandbox
const profile = await sandbox.process.exec("cat /home/daytona/memory/profile.md");
console.log("Profile:", profile.result);
await sandbox.process.exec(
`bash -c 'echo "Session started at $(date)" > /home/daytona/memory/session_notes.md'`
);
const files = await sandbox.process.exec("ls /home/daytona/memory");
console.log("Files:", files.result);
await daytona.delete(sandbox);
```
</Tab>
</Tabs>
---
## Tips
- FUSE is available in Daytona sandboxes but `user_allow_other` needs to be
added to `/etc/fuse.conf`
- We invoke SMFS as `$HOME/.local/bin/smfs` in the examples because Daytona's
default zsh PATH doesn't include `~/.local/bin`. Alternatively, prepend it
once with `export PATH=$HOME/.local/bin:$PATH`
- Use `pip install claude-agent-sdk` to install the agent SDK (PyPI is reachable)

View file

@ -0,0 +1,265 @@
---
title: "E2B"
description: "Give your AI agent persistent memory inside an E2B sandbox using SMFS"
---
Mount a Supermemory container inside an [E2B](https://e2b.dev) sandbox so your
agent can read and write memory using standard filesystem commands.
## How it works
There are two ways to wire SMFS into an E2B sandbox — pick the one that fits
your architecture.
### Agent inside the sandbox
The agent process runs inside the sandbox and accesses the SMFS mount directly.
Your orchestrating code just boots the sandbox and kicks off the agent.
```mermaid
graph LR
subgraph E2B Sandbox
Agent["Claude Agent"] -->|"cat, ls, echo"| Mount["/home/user/memory<br/>(SMFS mount)"]
end
Mount -->|sync| SM["Supermemory"]
```
### Agent outside the sandbox
The agent runs in your orchestrating code and executes commands inside the
sandbox remotely. Useful when you want to keep the agent loop in your own
infra.
```mermaid
graph LR
Agent["Claude Agent<br/>(your server)"] -->|"sbx.commands.run()"| Sandbox
subgraph Sandbox ["E2B Sandbox"]
Mount["/home/user/memory<br/>(SMFS mount)"]
end
Mount -->|sync| SM["Supermemory"]
```
## Prerequisites
- A [Supermemory API key](https://supermemory.ai)
- An [E2B API key](https://e2b.dev)
- An [Anthropic API key](https://console.anthropic.com)
## 1. Create a custom template
Bake SMFS and the Claude Agent SDK into a template so sandboxes start ready:
```dockerfile e2b.Dockerfile
FROM e2b/code-interpreter:latest
RUN apt-get update && apt-get install -y fuse3 && rm -rf /var/lib/apt/lists/*
RUN echo 'user_allow_other' >> /etc/fuse.conf
RUN curl -fsSL https://smfs.ai/install | bash -s -- 0.0.1-rc2
ENV PATH="/root/.local/bin:$PATH"
RUN pip install claude-agent-sdk
```
```bash
e2b template build -d e2b.Dockerfile
```
---
## Pattern A: Agent inside the sandbox
The agent runs inside the sandbox as a Python script. Your orchestrating code
just sets up the mount and starts it.
### Agent code
```python agent.py
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions
MEMORY = "/home/user/memory"
async def main():
async for message in query(
prompt=f"You have a persistent memory filesystem at {MEMORY}. "
"Read profile.md to learn about the user, then create "
"session_notes.md summarizing what you found.",
options=ClaudeAgentOptions(
allowed_tools=["Bash", "Read", "Write"],
cwd=MEMORY,
),
):
print(message)
asyncio.run(main())
```
### Orchestration
<Tabs>
<Tab title="Python">
```python run.py
import os
from pathlib import Path
from e2b_code_interpreter import Sandbox
sbx = Sandbox.create(
template="your-template-id",
timeout=300,
envs={
"SUPERMEMORY_API_KEY": os.environ["SUPERMEMORY_API_KEY"],
"ANTHROPIC_API_KEY": os.environ["ANTHROPIC_API_KEY"],
},
)
# /dev/fuse exists in E2B but is root-only by default. chmod once per sandbox.
sbx.commands.run("sudo chmod 666 /dev/fuse")
# Mount memory. We background the foreground daemon so this command returns,
# then sleep briefly to let the FUSE mount come up before the agent reads it.
sbx.commands.run("smfs login --key $SUPERMEMORY_API_KEY")
sbx.commands.run(
"bash -c 'smfs mount my_agent --ephemeral"
" --path /home/user/memory --foreground &' && sleep 3"
)
# Upload and run the agent
sbx.files.write("/home/user/agent.py", Path("agent.py").read_text())
result = sbx.commands.run("python3 /home/user/agent.py", timeout=120)
print(result.stdout)
sbx.kill()
```
</Tab>
<Tab title="TypeScript">
```typescript run.ts
import { Sandbox } from "@e2b/code-interpreter";
import { readFileSync } from "fs";
const sbx = await Sandbox.create({
template: "your-template-id",
timeoutMs: 300_000,
envs: {
SUPERMEMORY_API_KEY: process.env.SUPERMEMORY_API_KEY!,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!,
},
});
// /dev/fuse exists in E2B but is root-only by default. chmod once per sandbox.
await sbx.commands.run("sudo chmod 666 /dev/fuse");
// Mount memory. We background the foreground daemon so this command returns,
// then sleep briefly to let the FUSE mount come up before the agent reads it.
await sbx.commands.run("smfs login --key $SUPERMEMORY_API_KEY");
await sbx.commands.run(
"bash -c 'smfs mount my_agent --ephemeral --path /home/user/memory --foreground &' && sleep 3"
);
// Upload and run the agent
await sbx.files.write("/home/user/agent.py", readFileSync("agent.py", "utf-8"));
const result = await sbx.commands.run("python3 /home/user/agent.py", {
timeoutMs: 120_000,
});
console.log(result.stdout);
await sbx.kill();
```
</Tab>
</Tabs>
---
## Pattern B: Agent outside the sandbox
The agent runs in your server process and executes commands inside the sandbox
remotely via `sbx.commands.run()`. The SMFS mount lives inside the sandbox —
the agent never touches the filesystem directly.
<Note>
The FUSE mount is owned by root inside the sandbox. When writing to it from
outside the agent, wrap the command in `sudo bash -c '…'` so the redirect
runs with the right permissions. You'll see this in the write examples below.
</Note>
<Tabs>
<Tab title="Python">
```python run.py
import os
from e2b_code_interpreter import Sandbox
sbx = Sandbox.create(
template="your-template-id",
timeout=300,
envs={
"SUPERMEMORY_API_KEY": os.environ["SUPERMEMORY_API_KEY"],
},
)
# Set up SMFS inside the sandbox
sbx.commands.run("sudo chmod 666 /dev/fuse")
sbx.commands.run("smfs login --key $SUPERMEMORY_API_KEY")
sbx.commands.run(
"bash -c 'smfs mount my_agent --ephemeral"
" --path /home/user/memory --foreground &' && sleep 3"
)
# Agent runs here — executes commands in the sandbox
profile = sbx.commands.run("cat /home/user/memory/profile.md").stdout
print("Profile:", profile)
sbx.commands.run(
"sudo bash -c 'echo \"Session started at $(date)\" > /home/user/memory/session_notes.md'"
)
files = sbx.commands.run("ls /home/user/memory").stdout
print("Files:", files)
sbx.kill()
```
</Tab>
<Tab title="TypeScript">
```typescript run.ts
import { Sandbox } from "@e2b/code-interpreter";
const sbx = await Sandbox.create({
template: "your-template-id",
timeoutMs: 300_000,
envs: {
SUPERMEMORY_API_KEY: process.env.SUPERMEMORY_API_KEY!,
},
});
// Set up SMFS inside the sandbox
await sbx.commands.run("sudo chmod 666 /dev/fuse");
await sbx.commands.run("smfs login --key $SUPERMEMORY_API_KEY");
await sbx.commands.run(
"bash -c 'smfs mount my_agent --ephemeral --path /home/user/memory --foreground &' && sleep 3"
);
// Agent runs here — executes commands in the sandbox
const profile = await sbx.commands.run("cat /home/user/memory/profile.md");
console.log("Profile:", profile.stdout);
await sbx.commands.run(
`sudo bash -c 'echo "Session started at $(date)" > /home/user/memory/session_notes.md'`
);
const files = await sbx.commands.run("ls /home/user/memory");
console.log("Files:", files.stdout);
await sbx.kill();
```
</Tab>
</Tabs>
---
## Tips
- Use `--ephemeral` for sandbox mounts — keeps the cache in memory only, but
writes still push to Supermemory
- Use `smfs grep 'query'` for semantic search across all files in the container
- Without a custom template, add the install steps to your run script:
```python
sbx.commands.run("curl -fsSL https://smfs.ai/install | bash -s -- 0.0.1-rc2", timeout=60)
sbx.commands.run("pip install claude-agent-sdk", timeout=60)
```

View file

@ -0,0 +1,169 @@
---
title: "Vercel AI SDK"
description: "Give your AI agent persistent memory using SMFS with the Vercel AI SDK"
---
This guide is about the [Vercel AI SDK](https://ai-sdk.dev) — the TypeScript
agent framework — not Vercel hosting. The choice of pattern depends on where
your code actually runs:
- **Self-hosted Node** (your own VM, ECS, Fly.io, Railway, a Vercel Sandbox,
etc.): you can mount SMFS as a real filesystem on the server.
- **Vercel Functions / serverless / edge**: there's no long-lived process to
hold a FUSE mount, so use the [Bash Tool](/smfs/bash-tool)
(`@supermemory/bash`) instead. The container becomes the filesystem; no mount
needed.
## How it works
### Self-hosted Node (real mount)
The agent runs as a separate process with direct access to the SMFS mount.
Best when you want full bash, read, and write capabilities and your server is
long-lived.
```mermaid
graph LR
subgraph Your Server
Agent["Claude Agent"] -->|"cat, ls, echo"| Mount["./memory<br/>(SMFS mount)"]
end
Mount -->|sync| SM["Supermemory"]
```
### Vercel Functions / serverless (Bash Tool)
The agent runs inside `generateText` and accesses memory through `@supermemory/bash`,
which proxies bash commands to your Supermemory container over HTTP. No mount,
no FUSE, no long-lived process required.
```mermaid
graph LR
subgraph Vercel Function
AI["generateText()"] -->|"bash tool"| Bash["@supermemory/bash"]
end
Bash -->|HTTPS| SM["Supermemory"]
```
## Prerequisites
- A [Supermemory API key](https://supermemory.ai)
- An [Anthropic API key](https://console.anthropic.com)
- For Pattern A only: SMFS installed on your server (`curl -fsSL https://smfs.ai/install | bash`)
---
## Pattern A: Claude Agent SDK on self-hosted Node
Use this when the Vercel AI SDK is just the orchestrator and your real workload
is a Claude agent running on a long-lived server you control.
Start the mount once when your server boots — not per-request:
```bash
smfs login --key $SUPERMEMORY_API_KEY
smfs mount my_agent --path ./memory
```
<Note>
This won't work on Vercel Functions or any serverless runtime: there's no
process between requests to hold the mount, and FUSE isn't available. For
those targets, jump to Pattern B.
</Note>
Write a standalone agent script. Nothing server-specific — just Python that
reads and writes files:
```python agent.py
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions
MEMORY = "./memory"
async def main():
async for message in query(
prompt=f"You have a persistent memory filesystem at {MEMORY}. "
"Read profile.md to learn about the user, then create "
"session_notes.md summarizing what you found.",
options=ClaudeAgentOptions(
allowed_tools=["Bash", "Read", "Write"],
cwd=MEMORY,
),
):
print(message)
asyncio.run(main())
```
```bash
python3 agent.py
```
---
## Pattern B: Vercel AI SDK + Bash Tool (serverless-friendly)
`@supermemory/bash` exposes your Supermemory container as a single agent tool
— `run_bash(command)` — without mounting anything. It runs anywhere TypeScript
runs, including Vercel Functions, edge runtimes, and Lambda.
```bash
npm install @supermemory/bash ai @ai-sdk/anthropic zod
```
```typescript api/agent.ts
import { generateText, tool } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { createBash } from "@supermemory/bash";
import { z } from "zod";
export async function POST(req: Request) {
const { prompt } = await req.json();
const { bash, toolDescription } = await createBash({
apiKey: process.env.SUPERMEMORY_API_KEY!,
containerTag: "my_agent",
});
const result = await generateText({
model: anthropic("claude-sonnet-4-5"),
tools: {
bash: tool({
description: toolDescription,
inputSchema: z.object({ cmd: z.string() }),
execute: async ({ cmd }) => bash.exec(cmd),
}),
},
maxSteps: 10,
prompt,
});
return Response.json({ text: result.text });
}
```
A few things worth calling out:
- **`maxSteps: 10`** lets the agent chain multiple bash calls per request
(read `profile.md`, then `cat` a few notes, then write a summary). Bump it
if your agent needs deeper chains; lower it to cap cost per request.
- **`toolDescription`** is a pre-written description of the available bash
surface (semantic `sgrep`, `cat`, `ls`, redirects, etc.). Hand it straight
to the model — don't roll your own.
- **No timeout/abort plumbing.** `bash.exec` already runs against the
container over HTTPS, so it returns when the command returns. No event-loop
blocking and no FUSE.
See the [Bash Tool reference](/smfs/bash-tool) for the full command surface,
memory path configuration, and other framework integrations.
---
## Tips
- **Pattern A**: mount SMFS once when your server starts, not per-request.
Use `--ephemeral` if you don't need a local cache on the server.
- **Pattern B**: configure memory paths once at startup with
`configureMemoryPaths(["/notes/", "/journal.md"])` to control which files
get distilled into Supermemory memories.
- Both: use `smfs grep 'query'` (Pattern A) or `sgrep 'query'` inside the
bash tool (Pattern B) for semantic search across all files.

View file

@ -228,7 +228,10 @@ ${result.searchResults?.results.map(m => m.memory).join('\n') || 'None'}
import { openai } from "@ai-sdk/openai"
// Profiles automatically injected
const model = withSupermemory(openai("gpt-4"), "user-123")
const model = withSupermemory(openai("gpt-4"), {
containerTag: "user-123",
customId: "conv-1",
})
const result = await generateText({
model,

View file

@ -352,8 +352,11 @@ import { generateText } from "ai"
import { withSupermemory } from "@supermemory/tools/ai-sdk"
import { openai } from "@ai-sdk/openai"
// One line setup - profiles automatically injected
const model = withSupermemory(openai("gpt-4"), "user-123")
// Simple setup - profiles automatically injected
const model = withSupermemory(openai("gpt-4"), {
containerTag: "user-123",
customId: "conv-1",
})
const result = await generateText({
model,

View file

@ -143,7 +143,10 @@ const result = await streamText({
// Option 2: Profile middleware (automatic context injection)
import { withSupermemory } from '@supermemory/tools/ai-sdk'
const modelWithMemory = withSupermemory(anthropic('claude-3-5-sonnet-20241022'), userId)
const modelWithMemory = withSupermemory(anthropic('claude-3-5-sonnet-20241022'), {
containerTag: userId,
customId: 'conversation-1',
})
const result = await generateText({
model: modelWithMemory,

View file

@ -2,9 +2,11 @@
"name": "supermemory-mcp",
"version": "4.0.0",
"type": "module",
"portless": { "name": "mcp.dev.supermemory", "script": "dev:app" },
"scripts": {
"build:ui": "vite build",
"dev": "vite build && wrangler dev --port 8788",
"dev": "portless",
"dev:app": "vite build && wrangler dev --port ${PORT:-8788}",
"deploy": "vite build && wrangler deploy --minify",
"cf-typegen": "wrangler types --env-interface CloudflareBindings"
},

View file

@ -59,6 +59,7 @@ app.get("/", (c) => {
// MCP clients use this to discover the authorization server
app.get("/.well-known/oauth-protected-resource", (c) => {
const apiUrl = c.env.API_URL || DEFAULT_API_URL
const host = c.req.header("x-forwarded-host") || c.req.header("host")
const proto = c.req.header("x-forwarded-proto") || "https"
const resourceUrl = host ? `${proto}://${host}` : "https://mcp.supermemory.ai"

View file

@ -24,9 +24,12 @@ type Props = {
name?: string
}
const CONTAINER_TAGS_TTL_MS = 5 * 60 * 1000
export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
private clientInfo: { name: string; version?: string } | null = null
private cachedContainerTags: string[] = []
private containerTagsLastFetchedAt: number | null = null
server = new McpServer({
name: "supermemory",
@ -168,8 +171,8 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
"supermemory://projects",
{},
async () => {
const client = this.getClient()
const projects = await client.getProjects()
await this.ensureContainerTagsFresh()
const projects = this.cachedContainerTags
return {
contents: [
@ -193,15 +196,19 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
refresh: z
.boolean()
.optional()
.default(true)
.describe("Refresh the list from the server (default: true)"),
.default(false)
.describe(
"Force refresh from the server (default: false; uses cache with TTL)",
),
}),
},
// @ts-expect-error - zod type inference issue with MCP SDK
async (args: { refresh?: boolean }) => {
try {
if (args.refresh !== false) {
if (args.refresh === true) {
await this.refreshContainerTags()
} else {
await this.ensureContainerTagsFresh()
}
const projects = this.cachedContainerTags
@ -566,6 +573,10 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
const result = await client.createMemory(content)
if (!this.cachedContainerTags.includes(result.containerTag)) {
await this.refreshContainerTags()
}
// Track memory added event
posthog
.memoryAdded({
@ -758,10 +769,21 @@ export class SupermemoryMCP extends McpAgent<Env, unknown, Props> {
return this.ctx.id.name || "unknown"
}
private async ensureContainerTagsFresh(): Promise<void> {
const now = Date.now()
const needsRefresh =
this.containerTagsLastFetchedAt === null ||
now - this.containerTagsLastFetchedAt > CONTAINER_TAGS_TTL_MS
if (needsRefresh) {
await this.refreshContainerTags()
}
}
private async refreshContainerTags(): Promise<void> {
try {
const client = this.getClient()
this.cachedContainerTags = await client.getProjects()
this.containerTagsLastFetchedAt = Date.now()
} catch (error) {
console.error("Failed to fetch container tags:", error)
}

View file

@ -13,7 +13,9 @@
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "src",
"baseUrl": "."
"paths": {
"*": ["./*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules"]

View file

@ -2,8 +2,10 @@
"name": "memory-graph-playground",
"version": "0.1.0",
"private": true,
"portless": { "name": "graph.dev.supermemory", "script": "dev:app" },
"scripts": {
"dev": "next dev",
"dev": "portless",
"dev:app": "next dev --port ${PORT:-3004}",
"build": "next build",
"start": "next start"
},

View file

@ -2,12 +2,14 @@
import { EnsureWorkspace } from "@/components/ensure-workspace"
import { MobileBanner } from "@/components/mobile-banner"
import { NextAppResearchCta } from "@/components/next-app-research-cta"
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<>
<MobileBanner />
<EnsureWorkspace>{children}</EnsureWorkspace>
<NextAppResearchCta />
</>
)
}

View file

@ -1,109 +0,0 @@
"use client"
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from "react"
import { useAuth } from "@lib/auth-context"
export type MemoryFormData = {
twitter: string
linkedin: string
description: string
otherLinks: string[]
} | null
interface OnboardingContextValue {
name: string
setName: (name: string) => void
memoryFormData: MemoryFormData
setMemoryFormData: (data: MemoryFormData) => void
resetOnboarding: () => void
}
const OnboardingContext = createContext<OnboardingContextValue | null>(null)
export function useOnboardingContext() {
const ctx = useContext(OnboardingContext)
if (!ctx) {
throw new Error("useOnboardingContext must be used within OnboardingLayout")
}
return ctx
}
export default function OnboardingLayout({
children,
}: {
children: ReactNode
}) {
const { user } = useAuth()
const [name, setNameState] = useState<string>("")
const [memoryFormData, setMemoryFormDataState] =
useState<MemoryFormData>(null)
useEffect(() => {
const storedName = localStorage.getItem("onboarding_name")
const storedMemoryFormData = localStorage.getItem(
"onboarding_memoryFormData",
)
if (storedName) {
setNameState(storedName)
} else if (user?.displayUsername) {
setNameState(user.displayUsername)
localStorage.setItem("onboarding_name", user.displayUsername)
} else if (user?.name) {
setNameState(user.name)
localStorage.setItem("onboarding_name", user.name)
}
if (storedMemoryFormData) {
try {
setMemoryFormDataState(JSON.parse(storedMemoryFormData))
} catch {
// ignore parse errors
}
}
}, [user?.displayUsername, user?.name])
const setName = useCallback((newName: string) => {
setNameState(newName)
localStorage.setItem("onboarding_name", newName)
localStorage.setItem("username", newName)
}, [])
const setMemoryFormData = useCallback((data: MemoryFormData) => {
setMemoryFormDataState(data)
if (data) {
localStorage.setItem("onboarding_memoryFormData", JSON.stringify(data))
} else {
localStorage.removeItem("onboarding_memoryFormData")
}
}, [])
const resetOnboarding = useCallback(() => {
localStorage.removeItem("onboarding_name")
localStorage.removeItem("onboarding_memoryFormData")
setNameState("")
setMemoryFormDataState(null)
}, [])
const contextValue: OnboardingContextValue = {
name,
setName,
memoryFormData,
setMemoryFormData,
resetOnboarding,
}
return (
<OnboardingContext.Provider value={contextValue}>
{children}
</OnboardingContext.Provider>
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,87 +0,0 @@
"use client"
import {
createContext,
useContext,
useCallback,
useEffect,
useRef,
type ReactNode,
} from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useOnboardingContext, type MemoryFormData } from "../layout"
import { analytics } from "@/lib/analytics"
export const SETUP_STEPS = ["relatable", "integrations"] as const
export type SetupStep = (typeof SETUP_STEPS)[number]
interface SetupContextValue {
memoryFormData: MemoryFormData
currentStep: SetupStep
goToStep: (step: SetupStep) => void
goToWelcome: (step?: string) => void
finishOnboarding: () => void
}
const SetupContext = createContext<SetupContextValue | null>(null)
export function useSetupContext() {
const ctx = useContext(SetupContext)
if (!ctx) {
throw new Error("useSetupContext must be used within SetupLayout")
}
return ctx
}
export default function SetupLayout({ children }: { children: ReactNode }) {
const router = useRouter()
const searchParams = useSearchParams()
const { memoryFormData, resetOnboarding } = useOnboardingContext()
const stepParam = searchParams.get("step")
const currentStep: SetupStep = SETUP_STEPS.includes(stepParam as SetupStep)
? (stepParam as SetupStep)
: "relatable"
const hasTrackedInitialStep = useRef(false)
const goToStep = useCallback(
(step: SetupStep) => {
analytics.onboardingStepViewed({ step, trigger: "user" })
router.push(`/onboarding/setup?step=${step}`)
},
[router],
)
const goToWelcome = useCallback(
(step = "input") => {
router.push(`/onboarding/welcome?step=${step}`)
},
[router],
)
const finishOnboarding = useCallback(() => {
resetOnboarding()
router.push("/")
}, [router, resetOnboarding])
useEffect(() => {
if (!hasTrackedInitialStep.current) {
analytics.onboardingStepViewed({ step: currentStep, trigger: "user" })
hasTrackedInitialStep.current = true
}
}, [currentStep])
const contextValue: SetupContextValue = {
memoryFormData,
currentStep,
goToStep,
goToWelcome,
finishOnboarding,
}
return (
<SetupContext.Provider value={contextValue}>
{children}
</SetupContext.Provider>
)
}

View file

@ -1,74 +0,0 @@
"use client"
import { motion, AnimatePresence } from "motion/react"
import { RelatableQuestion } from "@/components/onboarding/setup/relatable-question"
import { IntegrationsStep } from "@/components/onboarding/setup/integrations-step"
import { SetupHeader } from "@/components/onboarding/setup/header"
import { ChatSidebar } from "@/components/onboarding/setup/chat-sidebar"
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
import { useIsMobile } from "@hooks/use-mobile"
import { useSetupContext, type SetupStep } from "./layout"
function StepNotFound({ goToStep }: { goToStep: (step: SetupStep) => void }) {
return (
<motion.div
className="text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<h2 className="text-white text-2xl mb-4">Unknown step</h2>
<button
type="button"
onClick={() => goToStep("relatable")}
className="text-blue-400 underline"
>
Go to first step
</button>
</motion.div>
)
}
export default function SetupPage() {
const { memoryFormData, currentStep, goToStep } = useSetupContext()
const isMobile = useIsMobile()
const renderStep = () => {
switch (currentStep) {
case "relatable":
return <RelatableQuestion key="relatable" />
case "integrations":
return <IntegrationsStep key="integrations" />
default:
return <StepNotFound key="not-found" goToStep={goToStep} />
}
}
return (
<div className="h-screen overflow-hidden bg-black">
<SetupHeader />
<AnimatedGradientBackground animateFromBottom={false} />
<main className="relative min-h-screen">
<div className="relative z-10">
<div className="flex flex-col lg:flex-row h-[calc(100vh-90px)] relative">
<div className="flex-1 flex flex-col items-center justify-start p-4 md:p-8">
<AnimatePresence mode="wait">{renderStep()}</AnimatePresence>
</div>
{!isMobile && (
<AnimatePresence mode="popLayout">
<ChatSidebar formData={memoryFormData} />
</AnimatePresence>
)}
</div>
</div>
</main>
{isMobile && <ChatSidebar formData={memoryFormData} />}
</div>
)
}

View file

@ -1,161 +0,0 @@
"use client"
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
useRef,
type ReactNode,
} from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useOnboardingContext, type MemoryFormData } from "../layout"
import { analytics } from "@/lib/analytics"
export const WELCOME_STEPS = [
"input",
"greeting",
"welcome",
"username",
"features",
"memories",
] as const
export type WelcomeStep = (typeof WELCOME_STEPS)[number]
interface WelcomeContextValue {
name: string
setName: (name: string) => void
isSubmitting: boolean
setIsSubmitting: (value: boolean) => void
showWelcomeContent: boolean
memoryFormData: MemoryFormData
setMemoryFormData: (data: MemoryFormData) => void
currentStep: WelcomeStep
goToStep: (step: WelcomeStep) => void
goToSetup: (step?: string) => void
}
const WelcomeContext = createContext<WelcomeContextValue | null>(null)
export function useWelcomeContext() {
const ctx = useContext(WelcomeContext)
if (!ctx) {
throw new Error("useWelcomeContext must be used within WelcomeLayout")
}
return ctx
}
export default function WelcomeLayout({ children }: { children: ReactNode }) {
const router = useRouter()
const searchParams = useSearchParams()
const { name, setName, memoryFormData, setMemoryFormData } =
useOnboardingContext()
const stepParam = searchParams.get("step")
const currentStep: WelcomeStep = WELCOME_STEPS.includes(
stepParam as WelcomeStep,
)
? (stepParam as WelcomeStep)
: "input"
const [isSubmitting, setIsSubmitting] = useState(false)
const [showWelcomeContent, setShowWelcomeContent] = useState(false)
const isMountedRef = useRef(true)
const hasTrackedInitialStep = useRef(false)
useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
}
}, [])
useEffect(() => {
if (currentStep === "input") {
setShowWelcomeContent(false)
const timer = setTimeout(() => {
if (isMountedRef.current) {
setShowWelcomeContent(true)
}
}, 1000)
return () => clearTimeout(timer)
}
setShowWelcomeContent(true)
}, [currentStep])
useEffect(() => {
const timers: NodeJS.Timeout[] = []
if (currentStep === "greeting") {
timers.push(
setTimeout(() => {
if (isMountedRef.current) {
analytics.onboardingStepViewed({ step: "welcome", trigger: "auto" })
router.replace("/onboarding/welcome?step=welcome")
}
}, 2000),
)
} else if (currentStep === "welcome") {
timers.push(
setTimeout(() => {
if (isMountedRef.current) {
analytics.onboardingStepViewed({
step: "username",
trigger: "auto",
})
router.replace("/onboarding/welcome?step=username")
}
}, 2000),
)
}
return () => {
timers.forEach(clearTimeout)
}
}, [currentStep, router])
useEffect(() => {
if (!hasTrackedInitialStep.current) {
analytics.onboardingStepViewed({
step: currentStep,
trigger: "user",
})
hasTrackedInitialStep.current = true
}
}, [currentStep])
const goToStep = useCallback(
(step: WelcomeStep) => {
analytics.onboardingStepViewed({ step, trigger: "user" })
router.push(`/onboarding/welcome?step=${step}`)
},
[router],
)
const goToSetup = useCallback(
(step = "relatable") => {
router.push(`/onboarding/setup?step=${step}`)
},
[router],
)
const contextValue: WelcomeContextValue = {
name,
setName,
isSubmitting,
setIsSubmitting,
showWelcomeContent,
memoryFormData,
setMemoryFormData,
currentStep,
goToStep,
goToSetup,
}
return (
<WelcomeContext.Provider value={contextValue}>
{children}
</WelcomeContext.Provider>
)
}

View file

@ -1,265 +0,0 @@
"use client"
import { motion, AnimatePresence } from "motion/react"
import { cn } from "@lib/utils"
import { InputStep } from "@/components/onboarding/welcome/input-step"
import { GreetingStep } from "@/components/onboarding/welcome/greeting-step"
import { WelcomeStep } from "@/components/onboarding/welcome/welcome-step"
import { OnboardingContentStep } from "@/components/onboarding/welcome/continue-step"
import { InitialHeader } from "@/components/initial-header"
import { Logo } from "@ui/assets/Logo"
import NovaOrb from "@/components/nova/nova-orb"
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
import {
useWelcomeContext,
type WelcomeStep as WelcomeStepType,
} from "./layout"
import { gapVariants, orbVariants } from "@/lib/variants"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import { analytics } from "@/lib/analytics"
import { toast } from "sonner"
function generateSlugFromName(value: string) {
return (
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "org"
)
}
function generateOrgSlug(name: string) {
const base = generateSlugFromName(name.trim())
const randomNum = Math.floor(100000 + Math.random() * 900000)
return `${base}-${randomNum}`
}
function generateUsername(name: string) {
const base = generateSlugFromName(name.trim()).replace(/-/g, "_")
const randomNum = Math.floor(100000 + Math.random() * 900000)
return `${base}${randomNum}`
}
function UserSupermemory({ name }: { name: string }) {
return (
<motion.div
className="absolute inset-0 top-[-34px] flex items-center justify-center z-10"
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 0 }}
transition={{ duration: 1, ease: "easeOut" }}
>
<Logo className="h-14 text-white" />
<div className="flex flex-col items-start justify-center ml-4">
<p className="text-white text-[25px] font-medium leading-none">
{name.split(" ")[0]}'s
</p>
<p className="text-white font-bold text-4xl leading-none -mt-2">
supermemory
</p>
</div>
</motion.div>
)
}
function StepNotFound({
goToStep,
}: {
goToStep: (step: WelcomeStepType) => void
}) {
return (
<motion.div
className="text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<h2 className="text-white text-2xl mb-4">Unknown step</h2>
<button
type="button"
onClick={() => goToStep("input")}
className="text-blue-400 underline"
>
Start from beginning
</button>
</motion.div>
)
}
export default function WelcomePage() {
const {
name,
setName,
isSubmitting,
setIsSubmitting,
showWelcomeContent,
setMemoryFormData,
currentStep,
goToStep,
} = useWelcomeContext()
const { refetchOrganizations, setActiveOrg } = useAuth()
const handleSubmit = async () => {
localStorage.setItem("username", name)
if (name.trim()) {
setIsSubmitting(true)
try {
await authClient.updateUser({
displayUsername: name.trim(),
username: generateUsername(name.trim()),
})
const refetchResult = await refetchOrganizations()
const refetchData = (
refetchResult as { data?: unknown[] | null | undefined }
)?.data
const existingOrgs = Array.isArray(refetchData) ? refetchData : []
if (existingOrgs.length > 0) {
analytics.onboardingNameSubmitted({
name_length: name.trim().length,
})
goToStep("greeting")
return
}
const uniqueSlug = generateOrgSlug(name.trim())
const completedAt = new Date().toISOString()
const newOrg = await authClient.organization.create({
name: name.trim(),
slug: uniqueSlug,
metadata: {
signupSource: "consumer",
webOnboarding: {
completedAt: null,
steps: {
welcomeInput: {
startedAt: completedAt,
completedAt,
data: {},
},
},
},
},
})
await setActiveOrg(newOrg.slug)
analytics.onboardingNameSubmitted({ name_length: name.trim().length })
goToStep("greeting")
} catch (error) {
console.error("Onboarding submit failed:", error)
toast.error(
error instanceof Error
? error.message
: "Could not set up your workspace. Please try again.",
)
} finally {
setIsSubmitting(false)
}
}
}
const renderStep = () => {
switch (currentStep) {
case "input":
return (
<InputStep
key="input"
name={name}
setName={setName}
handleSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
)
case "greeting":
return <GreetingStep key="greeting" name={name} />
case "welcome":
return <WelcomeStep key="welcome" />
case "username":
case "features":
case "memories":
return (
<OnboardingContentStep
key="onboarding-content"
currentView={
currentStep === "username"
? "continue"
: currentStep === "features"
? "features"
: "memories"
}
onSubmit={setMemoryFormData}
/>
)
default:
return <StepNotFound key="not-found" goToStep={goToStep} />
}
}
const minimizeNovaOrb = ["features", "memories"].includes(currentStep)
const novaSize = currentStep === "memories" ? 150 : 300
const showUserSupermemory = currentStep === "username"
return (
<div className="h-screen overflow-hidden bg-black">
<InitialHeader
showUserSupermemory={
currentStep === "features" || currentStep === "memories"
}
name={name}
/>
{currentStep === "input" && (
<AnimatedGradientBackground animateFromBottom={true} />
)}
{showWelcomeContent && (
<div className="fixed inset-0 flex flex-col items-center justify-center overflow-y-auto">
<motion.div
className="absolute inset-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat pointer-events-none"
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
style={{
mixBlendMode: "soft-light",
opacity: 0.6,
}}
/>
<motion.div
className={cn(
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex flex-col items-center justify-center",
)}
variants={gapVariants}
animate={minimizeNovaOrb ? "minimized" : "default"}
>
<motion.div
variants={orbVariants}
animate={
currentStep === "features"
? "features"
: currentStep === "memories"
? "memories"
: "default"
}
initial={{
padding: 0,
paddingTop: 0,
y: 60,
}}
className="relative"
>
<NovaOrb size={novaSize} />
{showUserSupermemory && <UserSupermemory name={name} />}
</motion.div>
<AnimatePresence mode="wait">{renderStep()}</AnimatePresence>
</motion.div>
</div>
)}
</div>
)
}

View file

@ -1,12 +1,27 @@
"use client"
import { useState, useCallback, useEffect } from "react"
import {
useState,
useCallback,
useEffect,
useMemo,
useRef,
useSyncExternalStore,
} from "react"
import { AnimatePresence, motion } from "motion/react"
import { useQueryState } from "nuqs"
import { Header } from "@/components/header"
import { ChatSidebar } from "@/components/chat"
import { Header, PublicHeader } from "@/components/header"
import { ChatSidebar, HomeChatComposer } from "@/components/chat"
import { DashboardView } from "@/components/dashboard-view"
import { MemoriesGrid } from "@/components/memories-grid"
import { GraphLayoutView } from "@/components/graph-layout-view"
import { IntegrationsView } from "@/components/integrations-view"
import { IntegrationsView, DetailWrapper } from "@/components/integrations-view"
import { MCPDetailView } from "@/components/mcp-modal/mcp-detail-view"
import { XBookmarksDetailView } from "@/components/onboarding/x-bookmarks-detail-view"
import { ChromeDetail } from "@/components/integrations/chrome-detail"
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 { AddDocumentModal } from "@/components/add-document"
import { DocumentModal } from "@/components/document-modal"
@ -15,7 +30,6 @@ import { FullscreenNoteModal } from "@/components/fullscreen-note-modal"
import type { HighlightItem } from "@/components/highlights-card"
import { HotkeysProvider } from "react-hotkeys-hook"
import { useHotkeys } from "react-hotkeys-hook"
import { AnimatePresence } from "motion/react"
import { useIsMobile } from "@hooks/use-mobile"
import { useAuth } from "@lib/auth-context"
import { useProject } from "@/stores"
@ -26,11 +40,14 @@ import {
useQuickNoteDraft,
} from "@/stores/quick-note-draft"
import { analytics } from "@/lib/analytics"
import type { ModelId } from "@/lib/models"
import { useDocumentMutations } from "@/hooks/use-document-mutations"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { useViewMode } from "@/lib/view-mode-context"
import type { MemoryOfDay } from "@/components/dashboard-view"
import { ErrorBoundary } from "@/components/error-boundary"
import { cn } from "@lib/utils"
import {
@ -39,15 +56,35 @@ import {
qParam,
docParam,
fullscreenParam,
chatParam,
integrationParam,
pluginsPanelParam,
threadParam,
type IntegrationParamValue,
} from "@/lib/search-params"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
function subscribeViewportWidth(cb: () => void) {
window.addEventListener("resize", cb)
return () => window.removeEventListener("resize", cb)
}
function getViewportWidth() {
return window.innerWidth
}
const GRADIENT_TOP_WIDTH_MAX = 1440
function gradientTopPositionForWidth(width: number) {
const minW = 320
const pctWide = 15
const pctNarrow = 55
const w = Math.min(GRADIENT_TOP_WIDTH_MAX, Math.max(minW, width))
const t = (w - minW) / (GRADIENT_TOP_WIDTH_MAX - minW)
const eased = t * t
return `${Math.round(pctNarrow + eased * (pctWide - pctNarrow))}%`
}
function ViewErrorFallback() {
return (
<div className="flex-1 flex items-center justify-center p-8">
@ -68,34 +105,28 @@ function ViewErrorFallback() {
export default function NewPage() {
const isMobile = useIsMobile()
const { user, session } = useAuth()
const {
selectedProject,
isNovaSpaces,
novaContainerTags,
selectedProjects,
setSelectedProjects,
} = useProject()
const selectedProjectTag = selectedProjects[0]
const isNovaContext =
isNovaSpaces ||
(selectedProjectTag !== undefined &&
novaContainerTags.includes(selectedProjectTag))
const { allProjects } = useContainerTags()
const emptyStateSpaceName =
!isNovaSpaces && selectedProjectTag
? selectedProjectTag === DEFAULT_PROJECT_ID
? "My Space"
: (allProjects.find((p) => p.containerTag === selectedProjectTag)
?.name ?? selectedProjectTag)
: undefined
const handleSwitchToAllSpacesFromEmptyState = useCallback(() => {
analytics.spaceSwitched({ space_id: "nova_spaces" })
setSelectedProjects([])
}, [setSelectedProjects])
const { selectedProject, selectedProjects } = useProject()
const selectedProjectTag = selectedProjects[0]
const { allProjects } = useContainerTags()
const dashboardSpaceLabel = useMemo(
() =>
getChatSpaceDisplayLabel({
selectedProject,
allProjects,
}),
[selectedProject, allProjects],
)
const emptyStateSpaceName = selectedProjectTag
? selectedProjectTag === DEFAULT_PROJECT_ID
? "My Space"
: (allProjects.find((p) => p.containerTag === selectedProjectTag)?.name ??
selectedProjectTag)
: undefined
const { viewMode, setViewMode } = useViewMode()
const queryClient = useQueryClient()
const [highlightsForceAt, setHighlightsForceAt] = useState(0)
// Chrome extension auth: send session token via postMessage so the content script can store it
useEffect(() => {
@ -122,22 +153,15 @@ export default function NewPage() {
"fullscreen",
fullscreenParam,
)
const [isChatOpen, setIsChatOpen] = useQueryState("chat", chatParam)
const [integrationFromUrl, setIntegration] = useQueryState(
"integration",
integrationParam,
)
const [pluginsPanelFromUrl] = useQueryState("plugins", pluginsPanelParam)
useEffect(() => {
if (integrationFromUrl || pluginsPanelFromUrl === true) {
void setViewMode("integrations")
}
}, [integrationFromUrl, pluginsPanelFromUrl, setViewMode])
const [, setThreadIdUrl] = useQueryState("thread", threadParam)
// Ephemeral local state (not worth URL-encoding)
const [fullscreenInitialContent, setFullscreenInitialContent] = useState("")
const [queuedChatSeed, setQueuedChatSeed] = useState<string | null>(null)
const [queuedChatModel, setQueuedChatModel] = useState<ModelId | null>(null)
const [queuedMessageSource, setQueuedMessageSource] = useState<
"highlight" | "home"
>("highlight")
const [selectedDocument, setSelectedDocument] =
useState<DocumentWithMemories | null>(null)
@ -146,6 +170,10 @@ export default function NewPage() {
if (!docId) setSelectedDocument(null)
}, [docId])
useEffect(() => {
if (viewMode === "dashboard") void setThreadIdUrl(null)
}, [viewMode, setThreadIdUrl])
// Resolve document from cache when loading with ?doc=<id> (deep link / refresh)
useEffect(() => {
if (!docId || selectedDocument) return
@ -177,6 +205,8 @@ export default function NewPage() {
const resetDraft = useQuickNoteDraftReset(selectedProject)
const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "")
const quickNoteDraftRef = useRef(quickNoteDraft)
quickNoteDraftRef.current = quickNoteDraft
const { noteMutation, bulkDeleteMutation } = useDocumentMutations({
onClose: () => {
@ -247,20 +277,31 @@ export default function NewPage() {
const HIGHLIGHTS_CACHE_NAME = "space-highlights-v1"
const HIGHLIGHTS_MAX_AGE = 4 * 60 * 60 * 1000 // 4 hours
const handleResetHighlights = useCallback(async () => {
toast.success("Refreshing daily brief…")
try {
await caches.delete(HIGHLIGHTS_CACHE_NAME)
} catch {}
setHighlightsForceAt(Date.now())
}, [])
const { data: highlightsData, isLoading: isLoadingHighlights } =
useQuery<SpaceHighlightsResponse>({
queryKey: ["space-highlights", selectedProject],
queryKey: ["space-highlights", selectedProject, highlightsForceAt],
queryFn: async (): Promise<SpaceHighlightsResponse> => {
const spaceId = selectedProject || "sm_project_default"
const forceRefresh = highlightsForceAt > 0
const cacheKey = `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights?spaceId=${spaceId}`
const cache = await caches.open(HIGHLIGHTS_CACHE_NAME)
const cached = await cache.match(cacheKey)
if (cached) {
const age =
Date.now() - Number(cached.headers.get("x-cached-at") || 0)
if (age < HIGHLIGHTS_MAX_AGE) {
return cached.json()
if (!forceRefresh) {
const cache = await caches.open(HIGHLIGHTS_CACHE_NAME)
const cached = await cache.match(cacheKey)
if (cached) {
const age =
Date.now() - Number(cached.headers.get("x-cached-at") || 0)
if (age < HIGHLIGHTS_MAX_AGE) {
return cached.json()
}
}
}
@ -276,6 +317,7 @@ export default function NewPage() {
questionsCount: 4,
includeHighlights: true,
includeQuestions: true,
forceRefresh,
}),
},
)
@ -286,13 +328,21 @@ export default function NewPage() {
const data = await response.json()
const cacheResponse = new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
"x-cached-at": String(Date.now()),
},
})
await cache.put(cacheKey, cacheResponse)
// Update browser cache with fresh data (works for both normal and forced refresh)
try {
const freshCache = await caches.open(HIGHLIGHTS_CACHE_NAME)
const cacheResponse = new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
"x-cached-at": String(Date.now()),
},
})
await freshCache.put(cacheKey, cacheResponse)
} catch {}
// Reset force flag after the forced fetch completes so future project-switches
// use the normal cache path instead of always bypassing it.
if (forceRefresh) setHighlightsForceAt(0)
return data
},
@ -300,6 +350,37 @@ export default function NewPage() {
refetchOnWindowFocus: false,
})
const { data: memoryOfDay = null } = useQuery<MemoryOfDay | null>({
queryKey: [
"memory-of-day",
user?.id,
new Date().toISOString().slice(0, 10),
],
queryFn: async (): Promise<MemoryOfDay | null> => {
const cacheKey = `memory-of-day:${user?.id}:${new Date().toISOString().slice(0, 10)}`
try {
const stored = localStorage.getItem(cacheKey)
if (stored) return JSON.parse(stored) as MemoryOfDay
} catch {}
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/memory-of-day`,
{ credentials: "include" },
)
if (!response.ok) return null
const data = (await response.json()) as MemoryOfDay | null
if (data) {
try {
localStorage.setItem(cacheKey, JSON.stringify(data))
} catch {}
}
return data
},
staleTime: 24 * 60 * 60 * 1000,
refetchOnWindowFocus: false,
enabled: !!user,
})
useHotkeys("c", () => {
analytics.addDocumentModalOpened()
setAddDoc("note")
@ -321,10 +402,22 @@ export default function NewPage() {
[setDocId],
)
// Separate from handleOpenDocument because the graph view only has a document ID,
// not the full document object. The modal will fetch the document via the docId
// query param, so there may be a brief loading state (unlike handleOpenDocument
// which pre-populates via setSelectedDocument).
const handleOpenDocumentById = useCallback(
(documentId: string) => {
analytics.documentModalOpened({ document_id: documentId })
setDocId(documentId)
},
[setDocId],
)
const handleQuickNoteSave = useCallback(
(content: string) => {
if (content.trim()) {
const hadPreviousContent = quickNoteDraft.trim().length > 0
const hadPreviousContent = quickNoteDraftRef.current.trim().length > 0
noteMutation.mutate(
{ content, project: selectedProject },
{
@ -339,7 +432,7 @@ export default function NewPage() {
)
}
},
[selectedProject, noteMutation, quickNoteDraft],
[selectedProject, noteMutation],
)
const handleFullScreenSave = useCallback(
@ -375,11 +468,29 @@ export default function NewPage() {
const handleHighlightsChat = useCallback(
(seed: string) => {
setQueuedChatSeed(seed)
setIsChatOpen(true)
setQueuedChatModel(null)
setQueuedMessageSource("highlight")
void setViewMode("chat")
},
[setIsChatOpen],
[setViewMode],
)
const handleHomeChatStart = useCallback(
(message: string, model: ModelId) => {
setQueuedChatSeed(message)
setQueuedChatModel(model)
setQueuedMessageSource("home")
void setViewMode("chat")
},
[setViewMode],
)
const consumeQueuedChat = useCallback(() => {
setQueuedChatSeed(null)
setQueuedChatModel(null)
setQueuedMessageSource("highlight")
}, [])
const handleHighlightsShowRelated = useCallback(
(query: string) => {
analytics.searchOpened({ source: "highlight_related" })
@ -391,16 +502,15 @@ export default function NewPage() {
const handleOpenIntegrations = useCallback(
(integration?: IntegrationParamValue) => {
setViewMode("integrations")
if (integration) {
setIntegration(integration)
} else {
setIntegration(null)
}
void setViewMode(integration ?? "integrations")
},
[setViewMode, setIntegration],
[setViewMode],
)
const handleOpenPlugins = useCallback(() => {
void setViewMode("plugins")
}, [setViewMode])
const handleAddMemory = useCallback(
(tab: "note" | "link") => {
analytics.addDocumentModalOpened()
@ -409,120 +519,207 @@ export default function NewPage() {
[setAddDoc],
)
const chatOpen = isChatOpen !== null ? isChatOpen : !isMobile
const isGraphMode = viewMode === "graph" && !isMobile
const viewportWidth = useSyncExternalStore(
subscribeViewportWidth,
getViewportWidth,
() => GRADIENT_TOP_WIDTH_MAX,
)
const gradientTopPosition = gradientTopPositionForWidth(viewportWidth)
const isChatView = viewMode === "chat"
const showNovaBackdrop =
viewMode === "graph" || viewMode === "list" || viewMode === "dashboard"
const isDashboardShell =
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
const isGraphMode = viewMode === "graph"
return (
<HotkeysProvider>
<div
className={cn(
"bg-black min-h-screen",
isGraphMode && "h-screen overflow-hidden",
"relative flex min-h-dvh flex-col bg-[#05080D]",
isGraphMode && "h-dvh overflow-hidden",
)}
>
<AnimatedGradientBackground
topPosition="15%"
animateFromBottom={false}
/>
{isGraphMode && (
<div
id="graph-dotted-grid"
className="absolute inset-0 pointer-events-none bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.25)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_60%,transparent_100%)]"
{showNovaBackdrop && (
<>
<AnimatedGradientBackground
animateFromBottom={false}
topPosition={gradientTopPosition}
/>
<div
className="pointer-events-none absolute inset-0 z-0 bg-[#05080D]/50"
aria-hidden
/>
<div
id="graph-dotted-grid"
className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.25)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_60%,transparent_100%)]"
/>
</>
)}
{!session && viewMode === "mcp" ? (
<PublicHeader />
) : (
<Header
onAddMemory={() => {
analytics.addDocumentModalOpened()
setAddDoc("note")
}}
onOpenSearch={() => {
analytics.searchOpened({ source: "header" })
setIsSearchOpen(true)
}}
/>
)}
<Header
onAddMemory={() => {
analytics.addDocumentModalOpened()
setAddDoc("note")
}}
onOpenChat={() => setIsChatOpen(true)}
onOpenSearch={() => {
analytics.searchOpened({ source: "header" })
setIsSearchOpen(true)
}}
/>
<main
key={`main-container-${chatOpen}-${viewMode}`}
className={cn(
"z-10 relative",
isGraphMode && "h-[calc(100vh-86px)] overflow-hidden",
)}
>
<div className={cn("relative z-10 flex flex-col md:flex-row h-full")}>
<ErrorBoundary fallback={<ViewErrorFallback />}>
{viewMode === "integrations" ? (
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
<IntegrationsView />
</div>
) : viewMode === "graph" && !isMobile ? (
<div className="flex-1">
<GraphLayoutView isChatOpen={chatOpen} />
</div>
) : (
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
<MemoriesGrid
isChatOpen={chatOpen}
onOpenDocument={handleOpenDocument}
isSelectionMode={isSelectionMode}
selectedDocumentIds={selectedDocumentIds}
onEnterSelectionMode={handleEnterSelectionMode}
onToggleSelection={handleToggleSelection}
onClearSelection={handleClearSelection}
onSelectAllVisible={handleSelectAllVisible}
onBulkDelete={handleBulkDelete}
isBulkDeleting={bulkDeleteMutation.isPending}
quickNoteProps={{
onSave: handleQuickNoteSave,
onMaximize: handleMaximize,
isSaving: noteMutation.isPending,
}}
highlightsProps={{
items: highlightsData?.highlights || [],
onChat: handleHighlightsChat,
onShowRelated: handleHighlightsShowRelated,
isLoading: isLoadingHighlights,
}}
emptyStateProps={
isNovaContext
? {
onAddMemory: handleAddMemory,
onOpenIntegrations: handleOpenIntegrations,
isAllSpaces: isNovaSpaces,
spaceName: emptyStateSpaceName,
onSwitchToAllSpaces: isNovaSpaces
? undefined
: handleSwitchToAllSpacesFromEmptyState,
}
: undefined
}
/>
</div>
<AnimatePresence mode="wait">
<motion.main
key={`main-container-${viewMode}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.22, ease: [0.4, 0, 0.2, 1] }}
className={cn(
"relative z-10 flex min-h-0 flex-1 flex-col",
(isGraphMode || isChatView) && "overflow-hidden",
)}
>
<div
className={cn(
"relative z-10 flex min-h-0 flex-1 flex-col md:flex-row",
)}
</ErrorBoundary>
<div className="hidden md:block md:sticky md:top-0 md:h-screen">
<AnimatePresence mode="popLayout">
<ErrorBoundary>
<ChatSidebar
isChatOpen={chatOpen}
setIsChatOpen={(open) => setIsChatOpen(open)}
queuedMessage={queuedChatSeed}
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
emptyStateSuggestions={highlightsData?.questions}
>
<ErrorBoundary fallback={<ViewErrorFallback />}>
{isChatView ? (
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col md:self-stretch">
<ChatSidebar
layout="page"
isChatOpen
setIsChatOpen={(open) => {
if (!open) void setViewMode("dashboard")
}}
queuedMessage={queuedChatSeed}
onConsumeQueuedMessage={consumeQueuedChat}
queuedMessageSource={queuedMessageSource}
initialSelectedModel={queuedChatModel}
emptyStateSuggestions={highlightsData?.questions}
/>
</div>
) : viewMode === "integrations" ? (
<div className="min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0">
<IntegrationsView />
</div>
) : viewMode === "mcp" ? (
<MCPDetailView
onBack={() => void setViewMode("integrations")}
/>
</ErrorBoundary>
</AnimatePresence>
) : viewMode === "plugins" ? (
<DetailWrapper
onBack={() => void setViewMode("integrations")}
>
<PluginsDetail />
</DetailWrapper>
) : viewMode === "chrome" ? (
<DetailWrapper
onBack={() => void setViewMode("integrations")}
>
<ChromeDetail />
</DetailWrapper>
) : viewMode === "shortcuts" ? (
<DetailWrapper
onBack={() => void setViewMode("integrations")}
>
<ShortcutsDetail />
</DetailWrapper>
) : viewMode === "raycast" ? (
<DetailWrapper
onBack={() => void setViewMode("integrations")}
>
<RaycastDetail />
</DetailWrapper>
) : viewMode === "import" ? (
<XBookmarksDetailView
onBack={() => void setViewMode("integrations")}
/>
) : viewMode === "graph" ? (
<div className="min-h-0 min-w-0 flex-1">
<GraphLayoutView onOpenDocument={handleOpenDocumentById} />
</div>
) : viewMode === "list" ? (
<div
className={cn(
"min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0",
"pb-10 md:pb-12",
)}
>
<MemoriesGrid
isChatOpen={false}
onOpenDocument={handleOpenDocument}
isSelectionMode={isSelectionMode}
selectedDocumentIds={selectedDocumentIds}
onEnterSelectionMode={handleEnterSelectionMode}
onToggleSelection={handleToggleSelection}
onClearSelection={handleClearSelection}
onSelectAllVisible={handleSelectAllVisible}
onBulkDelete={handleBulkDelete}
isBulkDeleting={bulkDeleteMutation.isPending}
quickNoteProps={{
onSave: handleQuickNoteSave,
onMaximize: handleMaximize,
isSaving: noteMutation.isPending,
}}
highlightsProps={{
items: highlightsData?.highlights || [],
onChat: handleHighlightsChat,
onShowRelated: handleHighlightsShowRelated,
isLoading: isLoadingHighlights,
}}
emptyStateProps={{
onAddMemory: handleAddMemory,
onOpenIntegrations: handleOpenIntegrations,
isAllSpaces: false,
spaceName: emptyStateSpaceName,
onSwitchToAllSpaces: undefined,
}}
/>
</div>
) : (
<DashboardView
spaceLabel={dashboardSpaceLabel}
headerNotice={undefined}
highlights={highlightsData?.highlights ?? []}
isLoadingHighlights={isLoadingHighlights}
onAddMemory={handleAddMemory}
onOpenSearch={() => {
analytics.searchOpened({ source: "header" })
setIsSearchOpen(true)
}}
onOpenIntegrations={handleOpenIntegrations}
onOpenPlugins={handleOpenPlugins}
onNavigateToMemories={() => void setViewMode("list")}
onNavigateToGraph={() => void setViewMode("graph")}
onOpenDocument={handleOpenDocument}
onHighlightsChat={handleHighlightsChat}
onHighlightsShowRelated={handleHighlightsShowRelated}
onResetHighlights={handleResetHighlights}
memoryOfDay={memoryOfDay}
/>
)}
</ErrorBoundary>
</div>
</motion.main>
</AnimatePresence>
{isDashboardShell && (
<div
className={cn(
"pointer-events-none fixed inset-x-0 z-30 bg-gradient-to-t from-black via-black/40 to-transparent pt-12",
isMobile ? "bottom-[4.5rem]" : "bottom-0",
)}
>
<div className="pointer-events-auto">
<HomeChatComposer onStartChat={handleHomeChatStart} />
</div>
</div>
</main>
{isMobile && (
<ChatSidebar
isChatOpen={chatOpen}
setIsChatOpen={(open) => setIsChatOpen(open)}
queuedMessage={queuedChatSeed}
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
emptyStateSuggestions={highlightsData?.questions}
/>
)}
<AddDocumentModal
@ -536,7 +733,6 @@ export default function NewPage() {
if (!open) setSearchPrefill("")
}}
projectId={selectedProject}
novaContainerTags={isNovaSpaces ? novaContainerTags : undefined}
onOpenDocument={handleOpenDocument}
onAddMemory={() => {
analytics.addDocumentModalOpened()

View file

@ -6,7 +6,7 @@ import { motion } from "motion/react"
import NovaOrb from "@/components/nova/nova-orb"
import { useState, useEffect, useRef } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import Account from "@/components/settings/account"
import Integrations from "@/components/settings/integrations"
import ConnectionsMCP from "@/components/settings/connections-mcp"
@ -16,7 +16,11 @@ 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 { Sun } from "lucide-react"
import { LogOut, RotateCcw, Trash2, Sun, LoaderIcon } from "lucide-react"
import { authClient } from "@lib/auth"
import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog"
import { useResetOrganization } from "@/hooks/use-reset-organization"
import { useDeleteUserAccount } from "@/hooks/use-account-settings"
const TABS = ["account", "integrations", "connections", "support"] as const
type SettingsTab = (typeof TABS)[number]
@ -28,6 +32,14 @@ type NavItem = {
icon: React.ReactNode
}
type DangerItem = {
id: "logout" | "reset" | "delete"
label: string
description: string
icon: React.ReactNode
color: "neutral" | "amber" | "red"
}
const NAV_ITEMS: NavItem[] = [
{
id: "account",
@ -103,6 +115,51 @@ const NAV_ITEMS: NavItem[] = [
},
]
const DANGER_ITEMS: DangerItem[] = [
{
id: "logout",
label: "Log out",
description: "Sign out of your account on this device",
icon: <LogOut className="size-5" />,
color: "neutral",
},
{
id: "reset",
label: "Reset data",
description: "Erase all memories, connections and spaces",
icon: <RotateCcw className="size-5" />,
color: "amber",
},
{
id: "delete",
label: "Delete account",
description: "Permanently delete your account and all data",
icon: <Trash2 className="size-5" />,
color: "red",
},
]
const DANGER_COLORS: Record<
DangerItem["color"],
{ idle: string; hover: string; icon: string }
> = {
neutral: {
idle: "text-white/50",
hover: "hover:text-white",
icon: "text-white/40",
},
amber: {
idle: "text-[#7A6030]",
hover: "hover:text-[#C7991B]",
icon: "text-[#7A6030]",
},
red: {
idle: "text-[#6B2A2A]",
hover: "hover:text-[#C73B1B]",
icon: "text-[#6B2A2A]",
},
}
function parseHashToTab(hash: string): SettingsTab {
const cleaned = hash.replace("#", "").toLowerCase()
return TABS.includes(cleaned as SettingsTab)
@ -133,13 +190,40 @@ export function UserSupermemory({ name }: { name: string }) {
}
export default function SettingsPage() {
const { user } = useAuth()
const { user, org } = useAuth()
const [activeTab, setActiveTab] = useState<SettingsTab>("account")
const hasInitialized = useRef(false)
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 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
@ -167,10 +251,14 @@ export default function SettingsPage() {
}, [])
const headerDisplayName =
user?.displayUsername || localStorageUsername || user?.name || ""
user?.displayUsername ||
localStorageUsername ||
user?.name ||
user?.email?.split("@")[0] ||
""
const headerPossessive = headerDisplayName
? `${headerDisplayName.split(" ")[0]}'s`
: ""
: "Your"
return (
<div className="h-screen flex flex-col overflow-hidden">
@ -215,12 +303,12 @@ export default function SettingsPage() {
</header>
<main className="flex-1 min-h-0 overflow-y-auto md:overflow-hidden">
<div className="flex flex-col md:flex-row md:justify-center gap-4 md:gap-8 lg:gap-12 px-4 md:px-6 pt-4 pb-6 md:h-full">
<div className="md:w-auto md:max-w-[380px] shrink-0">
<div className="md:flex md:h-full md:min-h-0 md:w-auto md:max-w-[380px] md:flex-col md:overflow-hidden shrink-0">
{!isMobile && (
<motion.div
animate={{
scale: 1,
padding: 48,
padding: 28,
paddingTop: 0,
}}
transition={{
@ -230,16 +318,16 @@ export default function SettingsPage() {
}}
className="relative flex items-center justify-center"
>
<NovaOrb size={175} className="blur-[3px]!" />
<UserSupermemory name={user?.name ?? ""} />
<NovaOrb size={140} className="blur-[3px]!" />
<UserSupermemory name={headerDisplayName} />
</motion.div>
)}
<nav
className={cn(
"flex gap-2",
"flex",
isMobile
? "flex-row overflow-x-auto pb-2 scrollbar-thin"
: "flex-col",
? "flex-row gap-2 overflow-x-auto pb-2 scrollbar-thin"
: "min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
dmSansClassName(),
)}
>
@ -254,7 +342,7 @@ export default function SettingsPage() {
}}
className={cn(
"rounded-xl transition-colors flex items-start gap-3 shrink-0",
isMobile ? "px-3 py-2 text-sm" : "text-left p-4",
isMobile ? "px-3 py-2 text-sm" : "text-left px-3 py-2.5",
activeTab === item.id
? "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-[#14161A] hover:shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
@ -270,13 +358,62 @@ export default function SettingsPage() {
) : (
<div className="flex flex-col gap-0.5">
<span className="font-medium">{item.label}</span>
<span className="text-sm text-white/50">
<span className="text-xs leading-snug text-white/50">
{item.description}
</span>
</div>
)}
</button>
))}
{/* Divider */}
{!isMobile && <div className="my-0.5 h-px bg-[#0F1621]" />}
{DANGER_ITEMS.map((item) => {
const colors = DANGER_COLORS[item.color]
const handleClick = () => {
if (item.id === "logout") handleLogout()
else if (item.id === "reset") setIsResetDialogOpen(true)
else if (item.id === "delete") setIsDeleteDialogOpen(true)
}
return (
<button
key={item.id}
type="button"
onClick={handleClick}
className={cn(
"rounded-xl transition-colors flex items-start gap-3 shrink-0 group",
isMobile ? "px-3 py-2 text-sm" : "text-left px-3 py-2.5",
"hover:bg-[#14161A] hover:shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
colors.idle,
colors.hover,
)}
>
<span
className={cn(
"shrink-0",
!isMobile && "mt-0.5",
colors.icon,
`group-hover:${colors.hover.replace("hover:", "")}`,
)}
>
{item.icon}
</span>
{isMobile ? (
<span className="font-medium whitespace-nowrap">
{item.label}
</span>
) : (
<div className="flex flex-col gap-0.5">
<span className="font-medium">{item.label}</span>
<span className="text-xs leading-snug opacity-60">
{item.description}
</span>
</div>
)}
</button>
)
})}
</nav>
</div>
<div className="flex-1 flex flex-col gap-4 md:overflow-y-auto md:max-w-2xl [scrollbar-gutter:stable] md:pr-[17px]">
@ -303,6 +440,169 @@ export default function SettingsPage() {
</div>
</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
}
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
}
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>
)
}

View file

@ -204,18 +204,12 @@ export default function LoginPage() {
email_domain: email.split("@")[1] || "unknown",
})
try {
await signIn.magicLink({
callbackURL: getCallbackURL(),
email,
})
setSubmittedEmail(email)
setPendingLoginMethod("magic_link")
// Track successful magic link send
posthog.capture("login_magic_link_sent", {
email_domain: email.split("@")[1] || "unknown",
})
} catch (error) {
const { error } = await signIn.magicLink({
callbackURL: getCallbackURL(),
email,
})
if (error) {
console.error(error)
// Track login failure
@ -232,6 +226,12 @@ export default function LoginPage() {
return
}
setSubmittedEmail(email)
setPendingLoginMethod("magic_link")
posthog.capture("login_magic_link_sent", {
email_domain: email.split("@")[1] || "unknown",
})
setIsLoading(false)
setIsLoadingEmail(false)
}
@ -313,7 +313,7 @@ export default function LoginPage() {
<ExternalAuthButton
authIcon={
<svg
className="w-4 h-4 sm:w-5 sm:h-5"
className="size-4 sm:size-5"
fill="none"
height="25"
viewBox="0 0 24 25"
@ -322,19 +322,19 @@ export default function LoginPage() {
>
<title>Google</title>
<path
d="M21.8055 10.2563H21V10.2148H12V14.2148H17.6515C16.827 16.5433 14.6115 18.2148 12 18.2148C8.6865 18.2148 6 15.5283 6 12.2148C6 8.90134 8.6865 6.21484 12 6.21484C13.5295 6.21484 14.921 6.79184 15.9805 7.73434L18.809 4.90584C17.023 3.24134 14.634 2.21484 12 2.21484C6.4775 2.21484 2 6.69234 2 12.2148C2 17.7373 6.4775 22.2148 12 22.2148C17.5225 22.2148 22 17.7373 22 12.2148C22 11.5443 21.931 10.8898 21.8055 10.2563Z"
d="M21.81 10.26H21V10.21H12V14.21H17.65C16.83 16.54 14.61 18.21 12 18.21C8.69 18.21 6 15.53 6 12.21C6 8.9 8.69 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C6.48 2.21 2 6.69 2 12.21C2 17.74 6.48 22.21 12 22.21C17.52 22.21 22 17.74 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
fill="#FFC107"
/>
<path
d="M3.15234 7.56034L6.43784 9.96984C7.32684 7.76884 9.47984 6.21484 11.9993 6.21484C13.5288 6.21484 14.9203 6.79184 15.9798 7.73434L18.8083 4.90584C17.0223 3.24134 14.6333 2.21484 11.9993 2.21484C8.15834 2.21484 4.82734 4.38334 3.15234 7.56034Z"
d="M3.15 7.56L6.44 9.97C7.33 7.77 9.48 6.21 12 6.21C13.53 6.21 14.92 6.79 15.98 7.73L18.81 4.91C17.02 3.24 14.63 2.21 12 2.21C8.16 2.21 4.83 4.38 3.15 7.56Z"
fill="#FF3D00"
/>
<path
d="M12.0002 22.2152C14.5832 22.2152 16.9302 21.2267 18.7047 19.6192L15.6097 17.0002C14.5721 17.7897 13.3039 18.2166 12.0002 18.2152C9.39916 18.2152 7.19066 16.5567 6.35866 14.2422L3.09766 16.7547C4.75266 19.9932 8.11366 22.2152 12.0002 22.2152Z"
d="M12 22.22C14.58 22.22 16.93 21.23 18.7 19.62L15.61 17C14.57 17.79 13.3 18.22 12 18.22C9.4 18.22 7.19 16.56 6.36 14.24L3.1 16.75C4.75 19.99 8.11 22.22 12 22.22Z"
fill="#4CAF50"
/>
<path
d="M21.8055 10.2563H21V10.2148H12V14.2148H17.6515C17.2571 15.3231 16.5467 16.2914 15.608 17.0003L15.6095 16.9993L18.7045 19.6183C18.4855 19.8173 22 17.2148 22 12.2148C22 11.5443 21.931 10.8898 21.8055 10.2563Z"
d="M21.81 10.26H21V10.21H12V14.21H17.65C17.26 15.32 16.55 16.29 15.61 17L15.61 17L18.7 19.62C18.49 19.82 22 17.21 22 12.21C22 11.54 21.93 10.89 21.81 10.26Z"
fill="#1976D2"
/>
</svg>
@ -355,6 +355,9 @@ export default function LoginPage() {
callbackURL: getCallbackURL(),
provider: "google",
})
.catch((err: unknown) => {
setError(getErrorMessage(err))
})
.finally(() => {
setIsLoading(false)
})
@ -375,7 +378,7 @@ export default function LoginPage() {
<ExternalAuthButton
authIcon={
<svg
className="w-4 h-4 sm:w-5 sm:h-5 text-foreground"
className="size-4 sm:size-5 text-foreground"
fill="none"
height="25"
viewBox="0 0 26 25"
@ -386,7 +389,7 @@ export default function LoginPage() {
<g clipPath="url(#clip0_2579_3356)">
<path
clipRule="evenodd"
d="M12.9635 0.214844C6.20975 0.214844 0.75 5.71484 0.75 12.5191C0.75 17.9581 4.24825 22.5621 9.10125 24.1916C9.708 24.3141 9.93025 23.9268 9.93025 23.6011C9.93025 23.3158 9.91025 22.3381 9.91025 21.3193C6.51275 22.0528 5.80525 19.8526 5.80525 19.8526C5.25925 18.4266 4.45025 18.0601 4.45025 18.0601C3.33825 17.3063 4.53125 17.3063 4.53125 17.3063C5.76475 17.3878 6.412 18.5693 6.412 18.5693C7.50375 20.4433 9.263 19.9138 9.97075 19.5878C10.0718 18.7933 10.3955 18.2433 10.7393 17.9378C8.0295 17.6526 5.1785 16.5933 5.1785 11.8671C5.1785 10.5226 5.6635 9.42259 6.432 8.56709C6.31075 8.26159 5.886 6.99834 6.5535 5.30759C6.5535 5.30759 7.58475 4.98159 9.91 6.57059C10.9055 6.30126 11.9322 6.16425 12.9635 6.16309C13.9948 6.16309 15.046 6.30584 16.0168 6.57059C18.3423 4.98159 19.3735 5.30759 19.3735 5.30759C20.041 6.99834 19.616 8.26159 19.4948 8.56709C20.2835 9.42259 20.7485 10.5226 20.7485 11.8671C20.7485 16.5933 17.8975 17.6321 15.1675 17.9378C15.6125 18.3248 15.9965 19.0581 15.9965 20.2193C15.9965 21.8693 15.9765 23.1936 15.9765 23.6008C15.9765 23.9268 16.199 24.3141 16.8055 24.1918C21.6585 22.5618 25.1568 17.9581 25.1568 12.5191C25.1768 5.71484 19.697 0.214844 12.9635 0.214844Z"
d="M12.96 0.21C6.21 0.21 0.75 5.71 0.75 12.52C0.75 17.96 4.25 22.56 9.1 24.19C9.71 24.31 9.93 23.93 9.93 23.6C9.93 23.32 9.91 22.34 9.91 21.32C6.51 22.05 5.81 19.85 5.81 19.85C5.26 18.43 4.45 18.06 4.45 18.06C3.34 17.31 4.53 17.31 4.53 17.31C5.76 17.39 6.41 18.57 6.41 18.57C7.5 20.44 9.26 19.91 9.97 19.59C10.07 18.79 10.4 18.24 10.74 17.94C8.03 17.65 5.18 16.59 5.18 11.87C5.18 10.52 5.66 9.42 6.43 8.57C6.31 8.26 5.89 7 6.55 5.31C6.55 5.31 7.58 4.98 9.91 6.57C10.91 6.3 11.93 6.16 12.96 6.16C13.99 6.16 15.05 6.31 16.02 6.57C18.34 4.98 19.37 5.31 19.37 5.31C20.04 7 19.62 8.26 19.49 8.57C20.28 9.42 20.75 10.52 20.75 11.87C20.75 16.59 17.9 17.63 15.17 17.94C15.61 18.32 16 19.06 16 20.22C16 21.87 15.98 23.19 15.98 23.6C15.98 23.93 16.2 24.31 16.81 24.19C21.66 22.56 25.16 17.96 25.16 12.52C25.18 5.71 19.7 0.21 12.96 0.21Z"
fill="currentColor"
fillRule="evenodd"
/>
@ -396,7 +399,7 @@ export default function LoginPage() {
<rect
fill="currentColor"
height="24"
transform="translate(0.75 0.214844)"
transform="translate(0.75 0.21)"
width="24.5"
/>
</clipPath>
@ -419,6 +422,9 @@ export default function LoginPage() {
callbackURL: getCallbackURL(),
provider: "github",
})
.catch((err: unknown) => {
setError(getErrorMessage(err))
})
.finally(() => {
setIsLoading(false)
})

View file

@ -3,7 +3,7 @@ import { ImageResponse } from "next/og"
export async function GET() {
return new ImageResponse(
<div tw="w-full h-full flex flex-col justify-center items-center">
<div tw="size-full flex flex-col justify-center items-center">
<img
src="https://pub-1be2b1df2c7e456f8e21149e972f4caf.r2.dev/bust.png"
alt="Google Logo"

View file

@ -169,7 +169,7 @@ function AuthConnectContent() {
setIsUpgrading(true)
const safeSuccessUrl = `${window.location.origin}${window.location.pathname}?callback=${encodeURIComponent(callback ?? "")}&client=${encodeURIComponent(validClient ?? "")}`
await autumn.attach({
productId: "api_pro",
planId: "api_pro",
successUrl: safeSuccessUrl,
})
} catch (err) {
@ -191,7 +191,7 @@ function AuthConnectContent() {
<div className={pageWrapperClass}>
<div className={cardClass}>
<div className="flex flex-col items-center gap-5">
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
{pluginInfo ? (
<Image
alt={pluginInfo.name}
@ -270,7 +270,7 @@ function AuthConnectContent() {
<div className={pageWrapperClass}>
<div className={cardClass}>
<div className="flex flex-col items-center gap-5">
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
{pluginInfo ? (
<Image
alt={pluginInfo.name}
@ -340,7 +340,7 @@ function AuthConnectContent() {
{isUpgrading || autumn.isLoading ? (
<>
<Loader className="size-4 animate-spin mr-2" />
Upgrading...
Upgrading
</>
) : (
"Upgrade to Pro \u2014 $19/month"
@ -349,7 +349,7 @@ function AuthConnectContent() {
</button>
<a
href="https://app.supermemory.ai/?plugins=true"
href="https://app.supermemory.ai/?view=plugins"
className={dmSans125ClassName(
"text-[12px] text-[#737373] hover:text-[#FAFAFA] transition-colors",
)}
@ -418,9 +418,9 @@ function AuthConnectContent() {
<div className="flex flex-col items-center gap-3">
<div className="size-6 border-2 border-[#4BA0FA] border-t-transparent rounded-full animate-spin" />
<p className={dmSans125ClassName("text-sm text-[#737373]")}>
{status === "creating" && `Connecting ${displayName}...`}
{status === "creating" && `Connecting ${displayName}`}
{status === "success" &&
`Success! Redirecting back to ${displayName}...`}
`Success! Redirecting back to ${displayName}`}
</p>
</div>
</div>

View file

@ -82,8 +82,8 @@ export default function ReferralPage() {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-[#0f1419]">
<div className="flex flex-col items-center gap-4">
<LoaderIcon className="w-8 h-8 text-orange-500 animate-spin" />
<p className="text-white/60">Checking invitation...</p>
<LoaderIcon className="size-8 text-orange-500 animate-spin" />
<p className="text-white/60">Checking invitation</p>
</div>
</div>
)
@ -119,8 +119,8 @@ export default function ReferralPage() {
{/* Welcome Card */}
<Card className="bg-[#1a1f2a] border-white/10">
<CardHeader className="text-center">
<div className="mx-auto mb-4 w-16 h-16 rounded-full bg-orange-500/10 flex items-center justify-center">
<ShareIcon className="w-8 h-8 text-orange-500" />
<div className="mx-auto mb-4 size-16 rounded-full bg-orange-500/10 flex items-center justify-center">
<ShareIcon className="size-8 text-orange-500" />
</div>
<CardTitle className="text-2xl font-bold text-white">
You're invited to supermemory!
@ -181,9 +181,9 @@ export default function ReferralPage() {
className="shrink-0 border-white/10 hover:bg-white/5"
>
{copiedLink ? (
<CheckIcon className="w-4 h-4" />
<CheckIcon className="size-4" />
) : (
<CopyIcon className="w-4 h-4" />
<CopyIcon className="size-4" />
)}
</Button>
</div>
@ -193,7 +193,7 @@ export default function ReferralPage() {
variant="outline"
className="w-full border-white/10 text-white hover:bg-white/5"
>
<ShareIcon className="w-4 h-4" />
<ShareIcon className="size-4" />
Share this link
</Button>
</div>

View file

@ -16,8 +16,8 @@ export default function ReferralHomePage() {
<div className="min-h-screen flex items-center justify-center p-4 bg-[#0f1419]">
<Card className="max-w-md w-full bg-[#1a1f2a] border-white/10">
<CardHeader className="text-center">
<div className="mx-auto mb-4 w-16 h-16 rounded-full bg-orange-500/10 flex items-center justify-center">
<ShareIcon className="w-8 h-8 text-orange-500" />
<div className="mx-auto mb-4 size-16 rounded-full bg-orange-500/10 flex items-center justify-center">
<ShareIcon className="size-8 text-orange-500" />
</div>
<CardTitle className="text-2xl font-bold text-white">
Missing Referral Code

View file

@ -152,7 +152,7 @@ export default function MigrateMCPPage() {
<div className="relative">
<div className="absolute inset-0 bg-blue-500/20 rounded-full blur-xl" />
<div className="relative bg-blue-500/10 p-3 rounded-full border border-blue-500/20">
<Zap className="w-6 h-6 text-blue-400" />
<Zap className="size-6 text-blue-400" />
</div>
</div>
</div>
@ -177,7 +177,7 @@ export default function MigrateMCPPage() {
className="text-sm font-medium text-slate-200 flex items-center gap-2"
htmlFor="mcpUrl"
>
<Upload className="w-4 h-4" />
<Upload className="size-4" />
MCP URL
</label>
<div className="relative">
@ -231,13 +231,13 @@ export default function MigrateMCPPage() {
>
{migrateMutation.isPending ? (
<>
<Spinner className="mr-2 w-4 h-4" />
Migrating documents...
<Spinner className="mr-2 size-4" />
Migrating documents
</>
) : (
<>
Start Upgrade
<ArrowRight className="ml-2 w-4 h-4" />
<ArrowRight className="ml-2 size-4" />
</>
)}
</Button>
@ -260,7 +260,7 @@ export default function MigrateMCPPage() {
<div className="relative p-4 border border-green-500/20 rounded-xl">
<div className="text-green-400">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="w-5 h-5" />
<CheckCircle className="size-5" />
<p className="font-medium">
Migration completed successfully!
</p>

View file

@ -6,15 +6,38 @@ import type { ConnectionResponseSchema } from "@repo/validation/api"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
import { useCustomer } from "autumn-js/react"
import { Check, Loader, Trash2, Zap } from "lucide-react"
import {
Check,
ChevronDown,
Clock,
FolderOpen,
Loader,
Trash2,
Zap,
} from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import type { z } from "zod"
import { dmSansClassName } from "@/lib/fonts"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import type { Project } from "@lib/types"
import { Button } from "@ui/components/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import { RemoveConnectionDialog } from "@/components/remove-connection-dialog"
type GDriveSyncScope = "scoped" | "full"
const GDRIVE_SCOPE_LABELS: Record<GDriveSyncScope, string> = {
scoped: "Files & Folders",
full: "Whole Drive",
}
type Connection = z.infer<typeof ConnectionResponseSchema>
type ConnectorProvider = "google-drive" | "notion" | "onedrive"
@ -24,26 +47,178 @@ const CONNECTORS: Record<
{
title: string
description: string
documentLabel: string
icon: React.ComponentType<{ className?: string }>
}
> = {
"google-drive": {
title: "Google Drive",
description: "Connect your Google docs, sheets and slides",
documentLabel: "documents",
icon: GoogleDrive,
},
notion: {
title: "Notion",
description: "Import your Notion pages and databases",
documentLabel: "pages",
icon: Notion,
},
onedrive: {
title: "OneDrive",
description: "Access your Microsoft Office documents",
documentLabel: "documents",
icon: OneDrive,
},
} as const
function formatRelativeTime(date: string | null | undefined): string {
if (!date) return "Never"
const d = new Date(date)
const diffMs = Date.now() - d.getTime()
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffHours / 24)
if (diffHours < 1) return "Just now"
if (diffHours < 24) return `${diffHours}h ago`
if (diffDays === 1) return "Yesterday"
if (diffDays < 7) return `${diffDays} days ago`
return d.toLocaleDateString()
}
function ConnectionRow({
connection,
onDelete,
isDeleting,
projects,
}: {
connection: Connection
onDelete: () => void
isDeleting: boolean
projects: Project[]
}) {
const config = CONNECTORS[connection.provider as ConnectorProvider]
if (!config) return null
const Icon = config.icon
const isConnected =
!connection.expiresAt || new Date(connection.expiresAt) > new Date()
const getProjectName = (tag: string): string => {
if (tag === DEFAULT_PROJECT_ID) return "Default"
return (
projects.find((p) => p.containerTag === tag)?.name ??
tag.replace(/^sm_project_/, "").replace(/_/g, " ")
)
}
const documentCount = (connection.metadata?.documentCount as number) ?? 0
const containerTags = (
connection as Connection & { containerTags?: string[] }
).containerTags
const projectName = containerTags?.[0]
? getProjectName(containerTags[0])
: null
return (
<div
className={cn(
"bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] px-4 py-3",
"shadow-[0px_1px_2px_0px_rgba(0,43,87,0.1)]",
)}
>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4">
<Icon className="size-6 shrink-0" />
<div className="flex-1 flex flex-col gap-1">
<div className="flex items-center gap-3">
<span
className={cn(
dmSans125ClassName(),
"font-medium text-[16px] text-[#FAFAFA]",
)}
>
{config.title}
</span>
<div className="flex items-center gap-2">
<div
className={cn(
"size-[7px] rounded-full",
isConnected ? "bg-[#00AC3F]" : "bg-[#737373]",
)}
/>
<span
className={cn(
dmSans125ClassName(),
"text-[14px]",
isConnected ? "text-[#00AC3F]" : "text-[#737373]",
)}
>
{isConnected ? "Connected" : "Disconnected"}
</span>
</div>
</div>
<span
className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}
>
{connection.email || "Unknown"}
</span>
</div>
<button
type="button"
onClick={onDelete}
disabled={isDeleting}
className="text-[#737373] hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 className="size-[22px]" />
</button>
</div>
<div className="flex items-center gap-3 pt-2.5 border-t border-[rgba(82,89,102,0.12)]">
<div className="flex items-center gap-2 flex-1 flex-wrap">
{projectName && (
<div className="flex items-center gap-1">
<FolderOpen className="size-3 text-[#4B5563]" />
<span
className={cn(
dmSans125ClassName(),
"text-[12px] text-[#737373] capitalize",
)}
>
{projectName}
</span>
</div>
)}
<div className="flex items-center gap-1">
<Clock className="size-3 text-[#4B5563]" />
<span
className={cn(
dmSans125ClassName(),
"text-[12px] text-[#737373]",
)}
>
{formatRelativeTime(connection.createdAt)}
</span>
</div>
</div>
<div className="flex items-baseline gap-1 shrink-0">
<span
className={cn(
dmSans125ClassName(),
"text-[14px] font-semibold text-[#FAFAFA]",
)}
>
{documentCount}
</span>
<span
className={cn(dmSans125ClassName(), "text-[12px] text-[#737373]")}
>
{config.documentLabel}
</span>
</div>
</div>
</div>
</div>
)
}
interface ConnectContentProps {
selectedProject: string
}
@ -51,32 +226,43 @@ interface ConnectContentProps {
export function ConnectContent({ selectedProject }: ConnectContentProps) {
const queryClient = useQueryClient()
const autumn = useCustomer()
const isProUser = hasActivePlan(autumn.customer?.products, "api_pro")
const isProUser = hasActivePlan(autumn.data?.subscriptions, "api_pro")
const [connectingProvider, setConnectingProvider] =
useState<ConnectorProvider | null>(null)
const [gdriveSyncScope, setGdriveSyncScope] =
useState<GDriveSyncScope>("scoped")
const [isUpgrading, setIsUpgrading] = useState(false)
const [removeDialog, setRemoveDialog] = useState<{
open: boolean
connection: Connection | null
}>({ open: false, connection: null })
const projects = (queryClient.getQueryData<Project[]>(["projects"]) ||
[]) as Project[]
const handleUpgrade = async () => {
setIsUpgrading(true)
try {
await autumn.attach({
productId: "api_pro",
const result = await autumn.attach({
planId: "api_pro",
successUrl: window.location.href,
})
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
}
autumn.refetch?.()
} catch (error) {
console.error("Upgrade error:", error)
toast.error("Failed to start upgrade process")
} finally {
setIsUpgrading(false)
}
}
const connectionsFeature = autumn.customer?.features?.connections
const connectionsUsed = connectionsFeature?.usage ?? 0
const connectionsLimit = connectionsFeature?.included_usage ?? 10
const connectionsBalance = autumn.data?.balances?.connections
const connectionsUsed = connectionsBalance?.usage ?? 0
const connectionsLimit = connectionsBalance?.granted ?? 10
const canAddConnection = connectionsUsed < connectionsLimit
// Fetch connections
@ -114,7 +300,13 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
// Connect mutation
const addConnectionMutation = useMutation({
mutationFn: async (provider: ConnectorProvider) => {
mutationFn: async ({
provider,
syncScope,
}: {
provider: ConnectorProvider
syncScope?: GDriveSyncScope
}) => {
if (!canAddConnection && !isProUser) {
throw new Error(
"Free plan doesn't include connections. Upgrade to Pro for unlimited connections.",
@ -126,6 +318,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
body: {
redirectUrl: window.location.href,
containerTags: [selectedProject],
metadata:
provider === "google-drive" && syncScope === "full"
? { syncScope: "full" }
: undefined,
},
})
@ -165,7 +361,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
onSuccess: (_data, variables) => {
toast.success(
variables.deleteDocuments
? "Connection removal has started. supermemory will permanently delete all documents related to the connection in the next few minutes."
? "Connection removal has started. Documents will be permanently deleted in the next few minutes."
: "Connection removed. Your memories have been kept.",
)
setRemoveDialog({ open: false, connection: null })
@ -180,76 +376,38 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
const handleConnect = (provider: ConnectorProvider) => {
setConnectingProvider(provider)
addConnectionMutation.mutate(provider)
}
const handleDisconnect = (connection: Connection) => {
setRemoveDialog({ open: true, connection })
addConnectionMutation.mutate({
provider,
syncScope: provider === "google-drive" ? gdriveSyncScope : undefined,
})
}
const hasConnections = connections.length > 0
// Helper function to format connection subtext safely
const getConnectionSubtext = (connection: Connection): string => {
if (connection.email) {
return connection.email
}
return "Connected"
}
const isAnyConnecting =
connectingProvider !== null || addConnectionMutation.isPending
return (
<div className="h-full flex flex-col pt-4 space-y-4">
<div className="flex items-center justify-between px-2">
<p className="text-[16px] font-semibold">Supermemory Connections</p>
<span className="bg-[#4BA0FA] text-black text-[12px] font-bold px-1 py-[3px] rounded-[3px]">
PRO
</span>
</div>
{/* Connector section - conditional layout based on hasConnections */}
{hasConnections ? (
<div className="grid grid-cols-3 gap-3">
{Object.entries(CONNECTORS).map(([provider, config]) => {
const Icon = config.icon
const isConnecting =
connectingProvider === provider ||
(addConnectionMutation.isPending &&
addConnectionMutation.variables === provider)
return (
<button
key={provider}
type="button"
onClick={() => handleConnect(provider as ConnectorProvider)}
disabled={
!isProUser || isConnecting || addConnectionMutation.isPending
}
className="bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] px-4 py-3 flex items-center justify-center gap-2 hover:bg-[#1B1F24] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Icon className="w-6 h-6 text-[#737373]" />
<p className="text-[14px] font-medium text-center">
{config.title}
</p>
{isConnecting && (
<Loader className="h-4 w-4 animate-spin text-[#4BA0FA]" />
)}
</button>
)
})}
{/* Top header — only when empty; once connected, the Add CTA moves into the list header below */}
{!hasConnections && (
<div className="flex items-center justify-between px-2">
<p className="text-[16px] font-semibold">Add a connection</p>
<span className="bg-[#4BA0FA] text-black text-[12px] font-bold px-1 py-[3px] rounded-[3px]">
PRO
</span>
</div>
) : (
)}
{/* Provider rows — only on empty state. Each is a labelled, descriptive CTA. */}
{!hasConnections && (
<div className="space-y-3">
{Object.entries(CONNECTORS).map(([provider, config]) => {
const Icon = config.icon
const connection = connections.find(
(conn) => conn.provider === provider,
)
const isConnected = !!connection
const isConnecting =
connectingProvider === provider ||
(addConnectionMutation.isPending &&
addConnectionMutation.variables === provider)
addConnectionMutation.variables?.provider === provider)
return (
<div
@ -257,34 +415,67 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
className="bg-[#14161A] rounded-[12px] px-4 py-3 flex items-center justify-between gap-3"
>
<div className="flex items-center gap-3 flex-1">
<Icon className="w-6 h-6 text-[#737373]" />
<Icon className="size-6 text-[#737373]" />
<div className="space-y-[6px] flex-1">
<div className="flex items-center gap-2">
<p className="text-[16px] font-medium">{config.title}</p>
{isConnected && (
<span className="text-[12px] text-[#4BA0FA] font-medium">
{connection.metadata?.syncInProgress
? "Syncing..."
: "Connected"}
</span>
)}
</div>
<p className="text-[16px] font-medium">{config.title}</p>
<p className="text-[16px] text-[#737373]">
{config.description}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isConnected ? (
<Button
variant="ghost"
size="sm"
onClick={() => handleDisconnect(connection)}
disabled={deleteConnectionMutation.isPending}
className="text-[#737373] hover:text-white hover:bg-[#1B1F24] h-8 w-8 p-0"
>
<Trash2 className="h-4 w-4" />
</Button>
{provider === "google-drive" ? (
<div className="flex items-center rounded-md overflow-hidden">
<button
type="button"
onClick={() => handleConnect("google-drive")}
disabled={
!isProUser ||
isConnecting ||
addConnectionMutation.isPending
}
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 text-[14px] font-medium px-3 h-8 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isConnecting ? (
<Loader className="size-4 animate-spin" />
) : (
"Connect"
)}
</button>
<div className="w-px h-5 bg-black/20" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 px-1.5 h-8 flex items-center transition-colors"
>
<ChevronDown className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{(
Object.entries(GDRIVE_SCOPE_LABELS) as [
GDriveSyncScope,
string,
][]
).map(([scope, label]) => (
<DropdownMenuItem
key={scope}
onClick={(e) => {
e.stopPropagation()
setGdriveSyncScope(scope)
}}
className="flex items-center justify-between"
>
{label}
{gdriveSyncScope === scope && (
<Check className="size-3 text-[#4BA0FA]" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
<Button
onClick={() =>
@ -298,7 +489,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 text-[14px] font-medium px-3 py-1.5 h-8"
>
{isConnecting ? (
<Loader className="h-4 w-4 animate-spin" />
<Loader className="size-4 animate-spin" />
) : (
"Connect"
)}
@ -311,56 +502,150 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
</div>
)}
{/* Connected list panel - only when hasConnections */}
{/* Connected list - rich rows with status / project / last sync / doc count */}
{hasConnections && (
<div className="bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] shadow-inside-out px-4 py-4 space-y-4">
<div className="flex items-center justify-between">
<p className="text-[16px] font-semibold">
Connected to Supermemory
</p>
{connectionsLimit > 0 && (
<p className="text-[12px] text-[#737373]">
{connections.length}/{connectionsLimit} connections used
</p>
)}
</div>
<div className="space-y-3">
{connections.map((connection) => {
const config =
CONNECTORS[connection.provider as ConnectorProvider]
if (!config) return null
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3 px-1">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<p className="text-[16px] font-semibold">
Connected to Supermemory
</p>
<span className="bg-[#4BA0FA] text-black text-[10px] font-bold px-1 py-[2px] rounded-[3px]">
PRO
</span>
</div>
{connectionsLimit > 0 && (
<p className="text-[12px] text-[#737373]">
{connections.length}/{connectionsLimit} connections used
</p>
)}
</div>
const Icon = config.icon
const subtext = getConnectionSubtext(connection)
return (
<div
key={connection.id}
className="flex items-center justify-between gap-3"
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={!isProUser || isAnyConnecting}
className="flex items-center gap-1.5 bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 disabled:opacity-50 disabled:cursor-not-allowed text-[13px] font-medium rounded-full h-8 px-3 transition-colors shrink-0"
>
<div className="flex items-center gap-3 flex-1">
<Icon className="w-6 h-6 text-[#737373]" />
<div className="flex-1 min-w-0">
<p className="text-[16px] font-medium truncate">
{config.title}
</p>
<p className="text-[14px] text-[#737373] truncate">
{subtext}
</p>
</div>
{isAnyConnecting ? (
<Loader className="size-3.5 animate-spin" />
) : (
<>
<span>+ Add a connection</span>
<ChevronDown className="size-3" />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={cn(
"min-w-[260px] p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
dmSansClassName(),
)}
style={{
background:
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<div className="flex flex-col gap-2">
<div className="px-3 py-1">
<span className="text-[10px] uppercase tracking-wider text-[#737373] font-medium">
Choose a service
</span>
</div>
<div className="flex flex-col">
<DropdownMenuItem
onClick={() => {
setConnectingProvider("google-drive")
addConnectionMutation.mutate({
provider: "google-drive",
syncScope: "scoped",
})
}}
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"
>
<GoogleDrive className="size-5 mt-0.5 shrink-0" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[14px] font-medium text-[#FAFAFA] leading-tight">
Google Drive
</span>
<span className="text-[11px] text-[#737373] leading-tight">
Pick specific files & folders
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setConnectingProvider("google-drive")
addConnectionMutation.mutate({
provider: "google-drive",
syncScope: "full",
})
}}
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"
>
<GoogleDrive className="size-5 mt-0.5 shrink-0" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[14px] font-medium text-[#FAFAFA] leading-tight">
Google Drive
</span>
<span className="text-[11px] text-[#737373] leading-tight">
Sync entire drive
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setConnectingProvider("notion")
addConnectionMutation.mutate({ provider: "notion" })
}}
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"
>
<Notion className="size-5 mt-0.5 shrink-0" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[14px] font-medium text-[#FAFAFA] leading-tight">
Notion
</span>
<span className="text-[11px] text-[#737373] leading-tight">
Pages and databases
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setConnectingProvider("onedrive")
addConnectionMutation.mutate({ provider: "onedrive" })
}}
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"
>
<OneDrive className="size-5 mt-0.5 shrink-0" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[14px] font-medium text-[#FAFAFA] leading-tight">
OneDrive
</span>
<span className="text-[11px] text-[#737373] leading-tight">
Office documents
</span>
</div>
</DropdownMenuItem>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDisconnect(connection)}
disabled={deleteConnectionMutation.isPending}
className="text-[#737373] hover:text-white hover:bg-[#1B1F24] h-8 w-8 p-0 shrink-0"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex flex-col gap-3">
{connections.map((connection) => (
<ConnectionRow
key={connection.id}
connection={connection}
projects={projects}
onDelete={() => setRemoveDialog({ open: true, connection })}
isDeleting={deleteConnectionMutation.isPending}
/>
))}
</div>
</div>
)}
@ -371,14 +656,14 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
id="no-active-connections"
className="bg-[#14161A] shadow-inside-out rounded-[12px] px-4 py-6 h-full mb-4 flex flex-col justify-center items-center"
>
<Zap className="w-6 h-6 text-[#737373] mb-3" />
<Zap className="size-6 text-[#737373] mb-3" />
{!isProUser ? (
<>
<p className="text-[14px] text-[#737373] mb-4 text-center">
{isUpgrading || autumn.isLoading ? (
<span className="inline-flex items-center gap-2">
<Loader className="h-4 w-4 animate-spin" />
Upgrading...
<Loader className="size-4 animate-spin" />
Upgrading
</span>
) : (
<>
@ -397,19 +682,19 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
</p>
<div className="space-y-2 text-[14px]">
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-[#4BA0FA]" />
<Check className="size-4 text-[#4BA0FA]" />
<span>Unlimited memories</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-[#4BA0FA]" />
<Check className="size-4 text-[#4BA0FA]" />
<span>10 connections</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-[#4BA0FA]" />
<Check className="size-4 text-[#4BA0FA]" />
<span>Advanced search</span>
</div>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-[#4BA0FA]" />
<Check className="size-4 text-[#4BA0FA]" />
<span>Priority support</span>
</div>
</div>
@ -429,6 +714,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
)}
</div>
)}
<RemoveConnectionDialog
open={removeDialog.open}
onOpenChange={(open) => {

View file

@ -210,10 +210,10 @@ export function FileContent({
multiple
onChange={handleFileSelect}
disabled={isSubmitting}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
className="absolute inset-0 size-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
accept={FILE_ACCEPT}
/>
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-[#0F1217]">
<div className="flex items-center justify-center size-12 rounded-full bg-[#0F1217]">
<FileIcon className="size-6 text-[#737373]" />
</div>
{hasItems ? (

View file

@ -5,7 +5,7 @@ import { useQueryState } from "nuqs"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { FileTextIcon, GlobeIcon, ZapIcon, Loader2 } from "lucide-react"
import { FileTextIcon, GlobeIcon, ZapIcon, Loader2, XIcon } from "lucide-react"
import { Button } from "@ui/components/button"
import { ConnectContent } from "./connections"
import { NoteContent } from "./note"
@ -16,7 +16,7 @@ import { toast } from "sonner"
import { useDocumentMutations } from "../../hooks/use-document-mutations"
import { useCustomer } from "autumn-js/react"
import { useTokenUsage } from "@/hooks/use-token-usage"
import { tokensToCredits, formatUsageNumber } from "@/lib/billing-utils"
import { formatUsageNumber } from "@/lib/billing-utils"
import { SpaceSelector } from "../space-selector"
import { useIsMobile } from "@hooks/use-mobile"
import { addDocumentParam } from "@/lib/search-params"
@ -35,10 +35,10 @@ export function AddDocumentModal({ isOpen, onClose }: AddDocumentModalProps) {
<Dialog open={isOpen} onOpenChange={(open: boolean) => !open && onClose()}>
<DialogContent
className={cn(
"border-none bg-[#1B1F24] flex flex-col p-3 md:p-4 gap-3",
"border-none bg-[#1B1F24] flex flex-col",
isMobile
? "w-[calc(100vw-1rem)]! h-[calc(100dvh-1rem)]! max-w-none! max-h-none! rounded-xl"
: "w-[80%]! max-w-[1000px]! h-[80%]! max-h-[800px]! rounded-[22px]",
? "top-2! left-2! translate-x-0! translate-y-0! w-[calc(100vw-1rem)]! h-[calc(100dvh-1rem)]! max-w-none! max-h-none! rounded-[18px] p-0 gap-0 overflow-hidden"
: "w-[80%]! max-w-[1000px]! h-[80%]! max-h-[800px]! rounded-[22px] p-4 gap-3",
dmSansClassName(),
)}
style={{
@ -48,7 +48,7 @@ export function AddDocumentModal({ isOpen, onClose }: AddDocumentModalProps) {
showCloseButton={false}
>
<DialogTitle className="sr-only">Add Document</DialogTitle>
<div className="flex-1 overflow-hidden">
<div className="min-h-0 flex-1 overflow-hidden">
<AddDocument onClose={onClose} isOpen={isOpen} />
</div>
</DialogContent>
@ -127,11 +127,8 @@ export function AddDocument({
const autumn = useCustomer()
const {
tokensUsed,
tokensLimit,
tokensPercent,
searchesUsed,
searchesLimit,
searchesPercent,
planUsagePct,
hasPaidPlan,
isLoading: isLoadingUsage,
} = useTokenUsage(autumn)
@ -259,19 +256,44 @@ export function AddDocument({
activeTab === "file" && (!fileTabHasPending || isSubmitting)
return (
<div className="h-full flex flex-col md:flex-row text-white md:space-x-5 space-y-3 md:space-y-0">
<div className="flex h-full min-h-0 flex-col overflow-hidden text-white md:flex-row md:space-x-5">
<div
className={cn(
"flex flex-col justify-between",
isMobile ? "w-full" : "w-1/3",
isMobile
? "w-full shrink-0 border-b border-[#0F1621] bg-[#1B1F24] px-3 pt-3 pb-3"
: "w-1/3",
)}
>
{isMobile && (
<div className="mb-3 flex items-center justify-between">
<div>
<p
className={cn(
"text-sm font-medium text-white",
dmSansClassName(),
)}
>
Add memory
</p>
<p className="text-xs text-[#737373]">
Save something to recall later
</p>
</div>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="flex size-9 items-center justify-center rounded-full border border-[#1F2937] bg-[#0D121A] text-[#8B8B8B] transition-colors hover:text-white disabled:opacity-50"
aria-label="Close add memory"
>
<XIcon className="size-4" />
</button>
</div>
)}
<div
className={cn(
"flex gap-1",
isMobile
? "flex-row overflow-x-auto pb-2 scrollbar-thin"
: "flex-col",
isMobile ? "grid grid-cols-4 gap-1" : "flex flex-col gap-1",
)}
>
{tabs.map((tab) => (
@ -288,6 +310,58 @@ export function AddDocument({
))}
</div>
{isMobile && (
<div className="mt-3 flex flex-col gap-2">
<div className="flex justify-between items-center">
<span
className={cn(
"text-[#FAFAFA] text-sm font-medium",
dmSansClassName(),
)}
>
Plan usage
</span>
<span
className={cn(
"text-sm font-medium tabular-nums",
hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]",
dmSansClassName(),
)}
>
{isLoadingUsage
? "…"
: `${planUsagePct < 1 && planUsagePct > 0 ? "< 1" : Math.round(planUsagePct)}% used`}
</span>
</div>
<div className="h-2 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
<div
className="h-full rounded-[40px]"
style={{
width: `${planUsagePct}%`,
background:
planUsagePct > 80
? "#ef4444"
: hasPaidPlan
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
: "#0054AD",
}}
title={`${formatUsageNumber(tokensUsed)} tokens · ${formatUsageNumber(searchesUsed)} queries`}
/>
</div>
{!isLoadingUsage && (
<p
className={cn(
"text-xs text-[#737373] tabular-nums",
dmSansClassName(),
)}
>
{formatUsageNumber(tokensUsed)} tokens ·{" "}
{formatUsageNumber(searchesUsed)} queries
</p>
)}
</div>
)}
{!isMobile && (
<div data-testid="usage-counter" className="flex flex-col gap-3 mr-4">
<div className="flex flex-col gap-2">
@ -298,72 +372,46 @@ export function AddDocument({
dmSansClassName(),
)}
>
Credits
Plan usage
</span>
<span
className={cn(
"text-sm font-medium",
"text-sm font-medium tabular-nums",
hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]",
dmSansClassName(),
)}
>
{isLoadingUsage
? "…"
: `${tokensToCredits(tokensUsed)} / ${tokensToCredits(tokensLimit)}`}
: `${planUsagePct < 1 && planUsagePct > 0 ? "< 1" : Math.round(planUsagePct)}% used`}
</span>
</div>
<div className="h-2 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
<div
className="h-full rounded-[40px]"
style={{
width: `${tokensPercent}%`,
width: `${planUsagePct}%`,
background:
tokensPercent > 80
planUsagePct > 80
? "#ef4444"
: hasPaidPlan
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
: "#0054AD",
}}
title={`${formatUsageNumber(tokensUsed)} tokens · ${formatUsageNumber(searchesUsed)} queries`}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<span
{!isLoadingUsage && (
<p
className={cn(
"text-[#FAFAFA] text-sm font-medium",
"text-xs text-[#737373] tabular-nums",
dmSansClassName(),
)}
>
Search Queries
</span>
<span
className={cn(
"text-sm font-medium",
hasPaidPlan ? "text-[#4BA0FA]" : "text-[#737373]",
dmSansClassName(),
)}
>
{isLoadingUsage
? "…"
: `${formatUsageNumber(searchesUsed)} / ${formatUsageNumber(searchesLimit)}`}
</span>
</div>
<div className="h-2 w-full rounded-[40px] bg-[#2E353D] p-px overflow-hidden">
<div
className="h-full rounded-[40px]"
style={{
width: `${searchesPercent}%`,
background:
searchesPercent > 80
? "#ef4444"
: hasPaidPlan
? "linear-gradient(to right, #4BA0FA 80%, #002757 100%)"
: "#0054AD",
}}
/>
</div>
{formatUsageNumber(tokensUsed)} tokens ·{" "}
{formatUsageNumber(searchesUsed)} queries
</p>
)}
</div>
{!hasPaidPlan && (
@ -372,13 +420,19 @@ export function AddDocument({
onClick={async () => {
setIsUpgrading(true)
try {
await autumn.attach({
productId: "api_pro",
successUrl: "https://app.supermemory.ai/settings#account",
const result = await autumn.attach({
planId: "api_pro",
successUrl: `${window.location.origin}/settings#account`,
})
window.location.reload()
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
}
autumn.refetch?.()
} catch (error) {
console.error(error)
toast.error("Failed to start checkout. Please try again.")
} finally {
setIsUpgrading(false)
}
}}
@ -400,7 +454,7 @@ export function AddDocument({
{isUpgrading ? (
<>
<Loader2 className="size-3 animate-spin mr-1.5" />
Upgrading...
Upgrading
</>
) : (
"Upgrade to Pro"
@ -414,11 +468,11 @@ export function AddDocument({
<div
className={cn(
"flex flex-col flex-1 min-h-0 px-1",
isMobile ? "w-full" : "w-2/3",
"flex min-h-0 flex-1 flex-col",
isMobile ? "w-full px-3 pt-3" : "w-2/3 px-1",
)}
>
<div className="overflow-auto flex-1 min-h-0 scrollbar-thin">
<div className="min-h-0 flex-1 overflow-auto scrollbar-thin">
{activeTab === "note" && (
<NoteContent
onSubmit={handleNoteSubmit}
@ -452,8 +506,10 @@ export function AddDocument({
</div>
<div
className={cn(
"flex gap-2 pt-3 shrink-0",
isMobile ? "flex-col" : "justify-between",
"flex shrink-0 gap-2",
isMobile
? "mx-[-0.75rem] mt-3 border-t border-[#0F1621] bg-[#1B1F24] px-3 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]"
: "justify-between pt-3",
)}
>
{!isMobile && (
@ -467,13 +523,19 @@ export function AddDocument({
/>
)}
<div
className={cn("flex items-center gap-2", isMobile && "justify-end")}
className={cn(
"flex items-center gap-2",
isMobile && "w-full justify-end",
)}
>
<Button
variant="ghost"
onClick={onClose}
disabled={isSubmitting}
className="text-[#737373] cursor-pointer rounded-full"
className={cn(
"cursor-pointer rounded-full text-[#737373]",
isMobile && "h-11 px-4",
)}
>
Cancel
</Button>
@ -484,11 +546,12 @@ export function AddDocument({
disabled={
activeTab === "file" ? fileTabSubmitDisabled : isSubmitting
}
className={cn(isMobile && "h-11 min-w-[8rem] px-5")}
>
{isSubmitting ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Adding...
Adding
</>
) : (
<>
@ -537,19 +600,24 @@ function TabButton({
type="button"
onClick={onClick}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-full text-left transition-colors whitespace-nowrap focus:outline-none focus:ring-0 shrink-0",
active ? "bg-[#14161A] shadow-inside-out" : "hover:bg-[#14161A]/50",
"relative flex h-14 min-w-0 flex-col items-center justify-center gap-1 rounded-xl px-1 text-center transition-colors focus:outline-none focus:ring-0",
active
? "bg-[#0F141B] text-white shadow-inside-out ring-1 ring-[#2261CA33]"
: "text-[#8B8B8B] hover:bg-[#14161A]/50",
dmSansClassName(),
)}
>
<Icon className={cn("size-4 shrink-0 text-white")} />
<Icon className="size-3.5 shrink-0" />
<span
className={cn("font-medium text-white text-sm", dmSansClassName())}
className={cn(
"min-w-0 truncate text-xs font-medium leading-none",
dmSansClassName(),
)}
>
{title.split(" ")[0]}
</span>
{isPro && (
<span className="bg-[#4BA0FA] text-black text-[8px] font-semibold px-1 py-0.5 rounded">
<span className="absolute top-1 right-1 rounded bg-[#4BA0FA] px-1 py-0.5 text-[7px] font-semibold leading-none text-black">
PRO
</span>
)}

View file

@ -173,7 +173,7 @@ export function LinkContent({
{isPreviewLoading ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Loading...
Loading
</>
) : (
"Preview Link"
@ -216,7 +216,7 @@ export function LinkContent({
<img
src={image}
alt={title || "Link preview"}
className="w-full h-full object-cover"
className="size-full object-cover"
onError={(e) => {
e.currentTarget.style.display = "none"
e.currentTarget.parentElement?.classList.add("opacity-50")
@ -228,7 +228,7 @@ export function LinkContent({
</div>
) : (
<div className="w-full max-w-md aspect-4/2 bg-[#0F1217] opacity-50 rounded-xl flex items-center justify-center">
<ImageIcon className="w-8 h-8 text-[#737373]" />
<ImageIcon className="size-8 text-[#737373]" />
</div>
)}
</div>

View file

@ -40,11 +40,12 @@ export function NoteContent({
}, [isOpen, onContentChange])
return (
<div className="p-4 overflow-y-auto flex-1 w-full h-full mb-4! bg-[#14161A] shadow-inside-out rounded-[14px]">
<div className="flex h-full min-h-[45dvh] w-full flex-1 overflow-y-auto rounded-[14px] bg-[#10151C] p-3 shadow-inside-out ring-1 ring-[#202A36] md:mb-4! md:bg-[#14161A] md:p-4 md:ring-0">
<TextEditor
content={undefined}
onContentChange={handleContentChange}
onSubmit={handleSubmit}
debounceMs={0}
/>
</div>
)

View file

@ -150,7 +150,7 @@ export function AddSpaceModal({
</p>
</div>
<DialogPrimitive.Close
className="bg-[#0D121A] w-7 h-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"
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)",
}}
@ -243,7 +243,7 @@ export function AddSpaceModal({
{createProjectMutation.isPending ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Creating...
Creating
</>
) : (
<>

View file

@ -8,55 +8,51 @@ export function AnimatedGradientBackground({
animateFromBottom?: boolean
}) {
return (
<div className="fixed inset-0 z-0 overflow-hidden">
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
<motion.div
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-0.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
style={{ top: animateFromBottom ? undefined : topPosition }}
initial={{ y: "100%" }}
animate={{
y: 0,
opacity: animateFromBottom ? 0 : [1, 0, 1],
top: animateFromBottom ? "0%" : topPosition,
}}
transition={{
y: { duration: 0.75, ease: "easeOut" },
opacity: animateFromBottom
? { duration: 2, ease: "easeOut" }
initial={{ opacity: 0 }}
animate={
animateFromBottom
? { opacity: 1 }
: { opacity: [1, 0, 1], top: topPosition }
}
transition={
animateFromBottom
? { duration: 1, ease: "easeOut" }
: {
duration: 8,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
},
top: animateFromBottom
? { duration: 0.75, ease: "easeOut" }
: undefined,
}}
opacity: {
duration: 8,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
},
}
}
/>
<motion.div
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-1.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
style={{ top: animateFromBottom ? undefined : topPosition }}
initial={{ y: "100%" }}
animate={{
y: 0,
opacity: animateFromBottom ? 0 : [0, 1, 0],
top: animateFromBottom ? "0%" : topPosition,
}}
transition={{
y: { duration: 0.75, ease: "easeOut" },
opacity: animateFromBottom
? { duration: 2, ease: "easeOut" }
initial={{ opacity: 0 }}
animate={
animateFromBottom
? { opacity: 1 }
: { opacity: [0, 1, 0], top: topPosition }
}
transition={
animateFromBottom
? { duration: 1, ease: "easeOut", delay: 0.2 }
: {
duration: 8,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
},
top: animateFromBottom
? { duration: 0.75, ease: "easeOut" }
: undefined,
}}
opacity: {
duration: 8,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
},
}
}
/>
<motion.div
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat"
className="absolute inset-0 bg-[url('/bg-rectangle.png')] bg-cover bg-bottom bg-no-repeat"
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
style={{
mixBlendMode: "soft-light",

View file

@ -0,0 +1,57 @@
"use client"
import { useMemo } from "react"
import type { UIMessage } from "@ai-sdk/react"
import { MemoryGraph } from "@/components/memory-graph"
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
import { useProject } from "@/stores"
import { extractHighlightDocumentIdsFromMessages } from "@/lib/chat-highlight-documents"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
export function ChatGraphContextRail({
messages,
className,
}: {
messages: UIMessage[]
className?: string
}) {
const { effectiveContainerTags } = useProject()
const highlightIds = useMemo(
() => extractHighlightDocumentIdsFromMessages(messages),
[messages],
)
return (
<div
id="chat-graph-context-rail"
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col bg-[#05080D] overflow-hidden",
dmSansClassName(),
className,
)}
>
<AnimatedGradientBackground animateFromBottom={false} topPosition="55%" />
<div className="pointer-events-none absolute inset-0 z-0 bg-[#05080D]/50" />
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.25)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_60%,transparent_100%)]" />
<div className="pointer-events-none absolute top-3 left-4 z-20">
<p className="text-xs font-medium text-white/70">Memory map</p>
<p className="mt-0.5 max-w-[14rem] text-[10px] leading-snug text-white/35">
{highlightIds.length > 0
? `${highlightIds.length} memor${highlightIds.length === 1 ? "y" : "ies"} used by Nova`
: "Memories used by Nova will be highlighted here"}
</p>
</div>
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-24 bg-gradient-to-r from-transparent to-[#05080D]" />
<div className="relative z-[2] min-h-0 flex-1 pt-10">
<MemoryGraph
containerTags={effectiveContainerTags}
variant="consumer"
highlightDocumentIds={highlightIds}
highlightsVisible={highlightIds.length > 0}
maxNodes={160}
/>
</div>
</div>
)
}

View file

@ -0,0 +1,82 @@
"use client"
import { useCallback, useMemo, useState } from "react"
import ChatInput from "./input"
import ChatModelSelector from "./model-selector"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
import { useProject } from "@/stores"
import { useContainerTags } from "@/hooks/use-container-tags"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import type { ModelId } from "@/lib/models"
export function HomeChatComposer({
onStartChat,
className,
}: {
onStartChat: (message: string, model: ModelId) => void
className?: string
}) {
const [input, setInput] = useState("")
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
const { selectedProject } = useProject()
const { allProjects } = useContainerTags()
const chatSpaceLabel = useMemo(
() =>
getChatSpaceDisplayLabel({
selectedProject,
allProjects,
}),
[selectedProject, allProjects],
)
const send = useCallback(() => {
const t = input.trim()
if (!t) return
onStartChat(t, selectedModel)
setInput("")
}, [input, onStartChat, selectedModel])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
send()
}
}
return (
<div className={cn(className)}>
<div className="mx-auto w-full max-w-[720px] px-4 pt-1 pb-3 md:pb-4">
<ChatInput
value={input}
onChange={(e) => setInput(e.target.value)}
onSend={send}
onStop={() => {}}
onKeyDown={handleKeyDown}
isResponding={false}
showStatusStrip={false}
stackedToolbar={
<>
<ChatModelSelector
selectedModel={selectedModel}
onModelChange={setSelectedModel}
minimal
/>
<div
className={cn(
"inline-flex max-w-[min(160px,35vw)] min-w-0 shrink items-center rounded-full bg-fg-primary/5 px-3 py-1.5",
dmSansClassName(),
)}
title={chatSpaceLabel}
>
<span className="truncate text-sm text-fg-primary">
{chatSpaceLabel}
</span>
</div>
</>
}
/>
</div>
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -14,10 +14,10 @@ export function SendButton({
onClick={onClick}
disabled={disabled}
className={cn(
"bg-[#000000] border-[#161F2C] border p-2 rounded-lg shrink-0 transition-opacity",
"bg-surface-card border-surface-border border p-2 rounded-lg shrink-0 transition-opacity",
disabled
? "opacity-50 cursor-not-allowed"
: "cursor-pointer hover:bg-[#161F2C]",
: "cursor-pointer hover:bg-surface-hover",
)}
>
<svg
@ -29,7 +29,7 @@ export function SendButton({
>
<title>Send Icon</title>
<path
d="M12 6L10.55 7.4L7 3.85L7 16L5 16L5 3.85L1.45 7.4L-4.37114e-07 6L6 -2.62268e-07L12 6Z"
d="M12 6L10.55 7.4L7 3.85L7 16L5 16L5 3.85L1.45 7.4L-4.37e-07 6L6 -2.62e-07L12 6Z"
fill="#FAFAFA"
/>
</svg>
@ -42,7 +42,7 @@ export function StopButton({ onClick }: { onClick: () => void }) {
<button
type="button"
onClick={onClick}
className="bg-[#000000] border-[#161F2C] border p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#161F2C] transition-opacity"
className="bg-surface-card border-surface-border border p-2 rounded-lg shrink-0 cursor-pointer hover:bg-surface-hover transition-opacity"
>
<SquareIcon className="size-4 text-white fill-white" />
</button>

View file

@ -4,7 +4,7 @@ import { ChevronUpIcon } from "lucide-react"
import NovaOrb from "@/components/nova/nova-orb"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { useRef, useState } from "react"
import { type ReactNode, useRef, useState } from "react"
import { motion } from "motion/react"
import { SendButton, StopButton } from "./actions"
@ -18,6 +18,10 @@ interface ChatInputProps {
activeStatus?: string
chainOfThoughtComponent?: React.ReactNode
onExpandedChange?: (expanded: boolean) => void
/** Model + space controls on one row with send; textarea full-width above */
stackedToolbar?: ReactNode
/** Nova status row + chain-of-thought toggle (off for e.g. home composer) */
showStatusStrip?: boolean
}
export default function ChatInput({
@ -30,6 +34,8 @@ export default function ChatInput({
activeStatus,
chainOfThoughtComponent,
onExpandedChange,
stackedToolbar,
showStatusStrip = true,
}: ChatInputProps) {
const [isMultiline, setIsMultiline] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
@ -52,82 +58,122 @@ export default function ChatInput({
<motion.div
className={cn("relative z-20!")}
animate={{
padding: isExpanded ? "16px" : "0",
margin: isExpanded ? "0" : "16px",
borderRadius: isExpanded ? "0 0 12px 12px" : "12px",
backgroundColor: isExpanded ? "#000B1B" : "#01173C",
padding: showStatusStrip ? (isExpanded ? "16px" : "0") : "0",
margin: showStatusStrip ? (isExpanded ? "0" : "16px") : "0",
borderRadius: showStatusStrip
? isExpanded
? "0 0 12px 12px"
: "12px"
: "0",
backgroundColor: showStatusStrip
? isExpanded
? "#000B1B"
: "#01173C"
: "transparent",
}}
transition={{
duration: 0.3,
ease: "easeOut",
}}
>
<div
className={cn(
"absolute bottom-full left-0 right-0 overflow-hidden transition-all duration-300 ease-out bg-[#000B1B]",
isExpanded
? "max-h-[60vh] opacity-100 overflow-y-auto pt-1.5 pb-2 rounded-t-xl px-4"
: "max-h-0 opacity-0",
)}
style={{
zIndex: isExpanded ? 50 : 0,
}}
>
{chainOfThoughtComponent}
</div>
<button
type="button"
className={cn(
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left",
!chainOfThoughtComponent && "disabled:cursor-not-allowed",
)}
onClick={() => {
const newExpanded = !isExpanded
setIsExpanded(newExpanded)
onExpandedChange?.(newExpanded)
}}
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>
</div>
{chainOfThoughtComponent && (
<ChevronUpIcon
{showStatusStrip ? (
<>
<div
className={cn(
"size-4 text-[#525D6E] transition-transform duration-300",
isExpanded && "rotate-180",
"absolute bottom-full left-0 right-0 overflow-hidden transition-all duration-300 ease-out bg-[#000B1B]",
isExpanded
? "max-h-[60vh] opacity-100 overflow-y-auto pt-1.5 pb-2 rounded-t-xl px-4"
: "max-h-0 opacity-0",
)}
style={{
zIndex: isExpanded ? 50 : 0,
}}
>
{chainOfThoughtComponent}
</div>
<button
type="button"
className={cn(
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left",
!chainOfThoughtComponent && "disabled:cursor-not-allowed",
)}
onClick={() => {
const newExpanded = !isExpanded
setIsExpanded(newExpanded)
onExpandedChange?.(newExpanded)
}}
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>
</div>
{chainOfThoughtComponent && (
<ChevronUpIcon
className={cn(
"size-4 text-[#525D6E] transition-transform duration-300",
isExpanded && "rotate-180",
)}
/>
)}
</button>
</>
) : null}
{stackedToolbar ? (
<div className="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">
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
placeholder="Ask your supermemory..."
className="w-full resize-none overflow-y-auto bg-transparent p-2 text-fg-primary transition-all duration-200 placeholder:text-fg-faint focus:outline-none"
style={{ minHeight: "36px" }}
rows={1}
disabled={isResponding}
/>
)}
</button>
<div
className={cn(
"flex items-end gap-2 bg-[#070E1B] rounded-xl p-2 border-[#52596633] border focus-within:outline-[#525D6EB2] focus-within:outline-1 transition-all duration-200",
isMultiline && "flex-col",
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
placeholder="Ask your supermemory..."
className="bg-transparent w-full p-2 placeholder:text-[#525D6E] focus:outline-none resize-none overflow-y-auto transition-all duration-200"
style={{ minHeight: "36px" }}
rows={1}
disabled={isResponding}
/>
<div className="transition-all duration-200">
{isResponding ? (
<StopButton onClick={onStop} />
) : (
<SendButton onClick={onSend} disabled={!value.trim()} />
)}
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
{stackedToolbar}
</div>
<div className="shrink-0">
{isResponding ? (
<StopButton onClick={onStop} />
) : (
<SendButton onClick={onSend} disabled={!value.trim()} />
)}
</div>
</div>
</div>
</div>
) : (
<div
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",
isMultiline && "flex-col",
)}
>
<textarea
ref={textareaRef}
value={value}
onChange={handleChange}
onKeyDown={onKeyDown}
placeholder="Ask your supermemory..."
className="w-full resize-none overflow-y-auto bg-transparent p-2 text-fg-primary transition-all duration-200 placeholder:text-fg-faint focus:outline-none"
style={{ minHeight: "36px" }}
rows={1}
disabled={isResponding}
/>
<div className="transition-all duration-200">
{isResponding ? (
<StopButton onClick={onStop} />
) : (
<SendButton onClick={onSend} disabled={!value.trim()} />
)}
</div>
</div>
)}
</motion.div>
)
}

View file

@ -4,17 +4,18 @@ import { useState } from "react"
import type { UIMessage } from "@ai-sdk/react"
import { Streamdown } from "streamdown"
import {
BookOpenIcon,
ChevronDownIcon,
ChevronRightIcon,
Loader2,
SearchIcon,
GlobeIcon,
PlusIcon,
BookOpenIcon,
ClockIcon,
GlobeIcon,
ListIcon,
XCircleIcon,
Loader2,
PlusIcon,
SearchIcon,
TerminalIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react"
import { cn } from "@lib/utils"
import { isWebSearchToolName } from "@/lib/chat-web-search-tools"
@ -22,9 +23,11 @@ import { RelatedMemories } from "./related-memories"
import { MessageActions } from "./message-actions"
const TOOL_META: Record<string, { label: string; icon: typeof SearchIcon }> = {
searchMemories: { label: "Search Memories", icon: SearchIcon },
bash: { label: "Memory", icon: TerminalIcon },
web_search: { label: "Web search", icon: GlobeIcon },
google_search: { label: "Google search", icon: GlobeIcon },
// legacy tool names kept for existing persisted messages
searchMemories: { label: "Search Memories", icon: SearchIcon },
addMemory: { label: "Add Memory", icon: PlusIcon },
fetchMemory: { label: "Fetch Memory", icon: BookOpenIcon },
scheduleTask: { label: "Schedule Task", icon: ClockIcon },
@ -95,9 +98,117 @@ function WebSourcesGroup({ sources }: { sources: SourceUrlPart[] }) {
)
}
function BashToolDisplay({ part }: { part: ToolCallDisplayPart }) {
const [expanded, setExpanded] = useState(false)
const isLoading =
part.state === "input-streaming" || part.state === "input-available"
const isDone = part.state === "output-available"
const isError = part.state === "error" || part.state === "output-error"
const cmd =
part.input && typeof part.input === "object" && "cmd" in part.input
? String((part.input as { cmd: string }).cmd)
: undefined
const output =
isDone && part.output && typeof part.output === "object"
? (part.output as { stdout?: string; stderr?: string; exitCode?: number })
: undefined
const hasOutput =
output &&
((output.stdout && output.stdout.length > 0) ||
(output.stderr && output.stderr.length > 0))
const errorText = part.errorText
const hasExpandable = hasOutput || (isError && errorText)
return (
<div className="rounded-lg border border-[#1E2128] bg-[#0D121A] text-xs my-1 overflow-hidden font-mono">
<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 && hasExpandable && "border-b border-[#1E2128]",
)}
>
{isLoading ? (
<Loader2 className="size-3 animate-spin text-blue-400 shrink-0" />
) : (
<TerminalIcon
className={cn(
"size-3 shrink-0",
isDone
? output?.exitCode === 0
? "text-emerald-400"
: "text-amber-400"
: isError
? "text-red-400"
: "text-white/50",
)}
/>
)}
<span className={cn("text-white/50", isLoading && "text-blue-400/60")}>
$
</span>
<span
className={cn(
"flex-1 text-left truncate",
isDone
? output?.exitCode === 0
? "text-emerald-300"
: "text-amber-300"
: isLoading
? "text-blue-300"
: isError
? "text-red-300"
: "text-white/70",
)}
>
{cmd ?? "…"}
</span>
{isLoading && <span className="text-white/30 shrink-0">running</span>}
{isDone && !hasOutput && (
<span className="text-white/30 shrink-0">done</span>
)}
{isError && <span className="text-red-400/60 shrink-0">error</span>}
{hasExpandable &&
(expanded ? (
<ChevronDownIcon className="size-3 text-white/30 shrink-0" />
) : (
<ChevronRightIcon className="size-3 text-white/30 shrink-0" />
))}
</button>
{expanded && (hasOutput || (isError && errorText)) && (
<div className="px-3 py-2 space-y-1">
{output?.stdout && output.stdout.length > 0 && (
<pre className="text-white/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-48 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
{output.stdout}
</pre>
)}
{output?.stderr && output.stderr.length > 0 && (
<pre className="text-amber-300/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-24 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
{output.stderr}
</pre>
)}
{isError && errorText && (
<pre className="text-red-300/90 bg-[#080B10] rounded p-2 overflow-x-auto max-h-24 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
{errorText}
</pre>
)}
</div>
)}
</div>
)
}
function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
const [expanded, setExpanded] = useState(false)
const toolName = part.type.replace("tool-", "")
if (toolName === "bash") {
return <BashToolDisplay part={part} />
}
const meta =
TOOL_META[toolName] ??
(isWebSearchToolName(toolName)
@ -148,7 +259,7 @@ function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
>
{label}
</span>
{isLoading && <span className="text-white/40 ml-auto">running...</span>}
{isLoading && <span className="text-white/40 ml-auto">running</span>}
{isDone && <span className="text-white/40 ml-auto">done</span>}
{isError && <span className="text-red-400/60 ml-auto">error</span>}
{expanded ? (

View file

@ -11,13 +11,17 @@ import { analytics } from "@/lib/analytics"
interface ChatModelSelectorProps {
selectedModel?: ModelId
onModelChange?: (model: ModelId) => void
/** Compact pill matching inline send control. */
minimal?: boolean
}
export default function ChatModelSelector({
selectedModel: selectedModelProp,
onModelChange,
minimal = false,
}: ChatModelSelectorProps = {}) {
const [internalModel, setInternalModel] = useState<ModelId>("gemini-2.5-pro")
const [internalModel, setInternalModel] =
useState<ModelId>("claude-sonnet-4.6")
const [isOpen, setIsOpen] = useState(false)
const selectedModel = selectedModelProp ?? internalModel
@ -33,25 +37,44 @@ export default function ChatModelSelector({
setIsOpen(false)
}
const trigger = minimal ? (
<button
type="button"
className={cn(
"flex max-w-[min(100%,220px)] min-w-0 shrink cursor-pointer items-center gap-1.5 rounded-full bg-fg-primary/5 px-3 py-1.5 text-sm transition-colors hover:bg-fg-primary/10",
dmSansClassName(),
)}
onClick={() => setIsOpen(!isOpen)}
>
<p className="min-w-0 truncate text-left text-fg-primary">
{currentModelData.name}{" "}
<span className="text-fg-subtle">{currentModelData.version}</span>
</p>
<ChevronDownIcon className="size-3.5 shrink-0 text-fg-subtle" />
</button>
) : (
<Button
variant="headers"
className={cn(
"h-10! max-w-[min(100%,220px)] shrink gap-1 rounded-full border-[#73737333] bg-surface-base text-base",
dmSansClassName(),
)}
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
onClick={() => setIsOpen(!isOpen)}
>
<p className="truncate text-sm">
{currentModelData.name}{" "}
<span className="text-[#737373]">{currentModelData.version}</span>
</p>
<ChevronDownIcon className="size-4 text-[#737373]" />
</Button>
)
return (
<div className="relative flex items-center gap-2">
<Button
variant="headers"
className={cn(
"rounded-full text-base gap-1 h-10! border-[#73737333] bg-[#0D121A]",
dmSansClassName(),
)}
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
onClick={() => setIsOpen(!isOpen)}
>
<p className="text-sm">
{currentModelData.name}{" "}
<span className="text-[#737373]">{currentModelData.version}</span>
</p>
<ChevronDownIcon className="size-4 text-[#737373]" />
</Button>
<div className="relative flex min-w-0 shrink items-center gap-2">
{trigger}
{isOpen && (
<>
@ -63,7 +86,7 @@ export default function ChatModelSelector({
aria-label="Close model selector"
/>
<div className="absolute top-full left-0 mt-2 w-64 bg-[#0D121A] backdrop-blur-xl border border-[#73737333] rounded-lg shadow-xl z-50 overflow-hidden">
<div className="absolute bottom-full left-0 mb-2 w-64 bg-surface-card backdrop-blur-xl border border-surface-border rounded-lg shadow-xl z-50 overflow-hidden">
<div className="p-2 space-y-1">
{models.map((model) => {
const modelData = modelNames[model.id]
@ -84,11 +107,11 @@ export default function ChatModelSelector({
>
<div className="text-sm font-medium text-white">
{modelData.name}{" "}
<span className="text-[#737373]">
<span className="text-fg-subtle">
{modelData.version}
</span>
</div>
<div className="text-xs text-[#737373] truncate w-full">
<div className="text-xs text-fg-muted truncate w-full">
{model.description}
</div>
</button>

View file

@ -351,7 +351,7 @@ export function ConnectAIModal({
{/* Step 1: Client Selection */}
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold bg-accent text-accent-foreground">
<div className="size-8 rounded-full flex items-center justify-center text-sm font-semibold bg-accent text-accent-foreground">
1
</div>
<h3 className="text-sm font-medium">Select Your AI Client</h3>
@ -373,7 +373,7 @@ export function ConnectAIModal({
type="button"
>
<div className="flex items-center gap-1">
<div className="w-8 h-8 flex items-center justify-center">
<div className="size-8 flex items-center justify-center">
<Image
alt={clientName}
className="rounded object-contain"
@ -499,7 +499,7 @@ export function ConnectAIModal({
href={getCursorDeeplink()}
onClick={() => {
analytics.mcpInstallCmdCopied()
toast.success("Opening Cursor installer...")
toast.success("Opening Cursor installer")
}}
>
<img
@ -673,7 +673,7 @@ export function ConnectAIModal({
</p>
{createMcpApiKeyMutation.isPending ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<Loader2 className="size-6 animate-spin text-primary" />
</div>
) : (
<>
@ -684,7 +684,7 @@ export function ConnectAIModal({
</code>
</pre>
<Button
className="absolute top-2 right-2 h-8 w-8 cursor-pointer p-0 bg-muted/80 hover:bg-muted"
className="absolute top-2 right-2 size-8 cursor-pointer p-0 bg-muted/80 hover:bg-muted"
onClick={() =>
copyManualSnippet(remoteSnippet)
}
@ -722,7 +722,7 @@ export function ConnectAIModal({
</code>
</pre>
<Button
className="absolute top-2 right-2 h-8 w-8 cursor-pointer p-0 bg-muted/80 hover:bg-muted"
className="absolute top-2 right-2 size-8 cursor-pointer p-0 bg-muted/80 hover:bg-muted"
onClick={() => copyManualSnippet(manual.snippet)}
size="icon"
variant="ghost"
@ -788,21 +788,21 @@ export function ConnectAIModal({
<div className="flex items-center gap-2 text-xs">
{isCheckingConnection ? (
<>
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Checking...</span>
<Loader2 className="size-3 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Checking</span>
</>
) : connectionStatus?.previousLogin ? (
<>
<div className="w-2 h-2 rounded-full bg-green-500" />
<div className="size-2 rounded-full bg-green-500" />
<span className="text-green-600 font-medium">
Connected
</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-yellow-500" />
<div className="size-2 rounded-full bg-yellow-500" />
<span className="text-yellow-600 font-medium">
Waiting for connection...
Waiting for connection
</span>
</>
)}
@ -828,7 +828,7 @@ export function ConnectAIModal({
}
variant="outline"
>
<ExternalLink className="w-2 h-2 mr-2" />
<ExternalLink className="size-2 mr-2" />
Learn More
</Button>
@ -919,8 +919,8 @@ export function ConnectAIModal({
>
{migrateMCPMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Migrating...
<Loader2 className="size-4 animate-spin mr-2" />
Migrating
</>
) : (
"Migrate"

View file

@ -0,0 +1,927 @@
"use client"
import type { ReactNode } from "react"
import { useMemo, useState, useEffect } from "react"
import { useAuth } from "@lib/auth-context"
import { $fetch } from "@lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import { useQuery } from "@tanstack/react-query"
import { useRouter } from "next/navigation"
import {
ArrowRight,
ExternalLink,
FileText,
Lightbulb,
Link2,
RotateCcw,
SearchIcon,
Terminal,
} from "lucide-react"
import type { z } from "zod"
import { CHROME_EXTENSION_URL, RAYCAST_EXTENSION_URL } from "@lib/constants"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { useProject } from "@/stores"
import {
HighlightsCard,
type HighlightItem,
} from "@/components/highlights-card"
import { StaticGraphPreview } from "@/components/memory-graph/graph-card"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
import { ChromeIcon, RaycastIcon } from "@/components/integration-icons"
import { GoogleDrive, Notion, MCPIcon } from "@ui/assets/icons"
import { analytics } from "@/lib/analytics"
import type { IntegrationParamValue } from "@/lib/search-params"
import { motion, AnimatePresence } from "motion/react"
import {
usePersonalization,
type Profession,
} from "@/hooks/use-personalization"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
const fadeUp = {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0 },
transition: {
duration: 0.3,
ease: [0.4, 0, 0.2, 1] as [number, number, number, number],
},
}
const CYCLE_INTERVAL_MS = 8_000
const PLUGIN_TAGLINES: Record<Profession, Partial<Record<string, string>>> = {
developer: {
mcp: "Ask Claude about your saved docs and specs from any IDE",
chrome: "Save Stack Overflow answers, docs and repos in one click",
raycast: "Search your tech docs and snippets without context switching",
notion: "Make your engineering specs and RFCs instantly findable",
"google-drive": "Query your design docs, code specs and shared files",
},
research: {
mcp: "Ask Claude across your entire reading list and notes",
chrome: "Clip papers and articles directly while you read",
raycast: "Pull up citations and notes without breaking your focus",
notion: "Keep your literature review alongside your saved papers",
"google-drive": "Index datasets, papers and research docs in one place",
},
finance: {
mcp: "Ask Claude about your saved thesis notes and research",
chrome: "Save earnings calls, market reports and articles instantly",
raycast: "Surface your research and models without breaking flow",
notion: "Make your investment thesis and portfolio notes searchable",
"google-drive": "Query your financial models, decks and reports instantly",
},
design: {
mcp: "Ask Claude about your saved briefs and design research",
chrome: "Save inspiration and references as you browse",
raycast: "Find your saved references and briefs from anywhere",
notion: "Make your design system docs and briefs searchable",
"google-drive": "Index your briefs, feedback docs and creative assets",
},
legal: {
mcp: "Ask Claude across your saved contracts and case notes",
chrome: "Clip case law, statutes and legal articles in one click",
raycast: "Surface contracts and precedents without leaving your workflow",
notion: "Keep memos, briefs and case notes instantly searchable",
"google-drive": "Index contracts, filings and legal research docs",
},
marketing: {
mcp: "Ask Claude across your saved campaigns and research",
chrome: "Save competitor pages and inspiration as you browse",
raycast: "Pull up campaign briefs and notes without context switching",
notion: "Make your content calendar and campaign briefs searchable",
"google-drive": "Query campaign reports, briefs and creative assets",
},
medical: {
mcp: "Ask Claude across your medical literature and clinical notes",
chrome: "Save studies and clinical resources while you read",
raycast: "Surface guidelines and notes without breaking your flow",
notion: "Keep clinical notes and research in one searchable place",
"google-drive": "Index guidelines, studies and patient education docs",
},
default: {
mcp: "Ask Claude using your own saved knowledge",
chrome: "Save any page in one click while you browse",
raycast: "Search your memory without leaving the keyboard",
notion: "Make every note and doc instantly searchable",
"google-drive": "Ask questions across your docs, slides and sheets",
},
}
export type MemoryOfDay = {
memories: string[]
timeLabel: string
sourceDocumentId: string | null
}
const TIPS: Record<Profession, string[]> = {
developer: [
"Use ⌘K to search code snippets and docs by intent, not just keywords",
"Connect Claude MCP to query your saved knowledge from any IDE",
"Save GitHub repos and READMEs — ask questions across all of them",
"Use 'Related' on highlights to find connected technical concepts",
"Save a Stack Overflow answer once — find it again by what it does",
"Drop in your last 3 PRs and ask Supermemory for the review patterns",
"Save your team's RFCs and surface the ones touching your work",
"Save error messages with their fixes — search by symptom next time",
"Save framework docs once — semantic search beats Cmd+F across pages",
"Connect Notion to make your engineering specs instantly findable",
"Save the docs for libraries you keep forgetting and grep them by intent",
"Use Daily Brief to resurface the design doc you skimmed last week",
"Save changelogs as you skim — pull breaking changes back later",
"Save a debugging session as a note — find it again by the symptom",
],
research: [
"Save papers and ask questions across your entire reading list",
"Use 'Related' on highlights to surface connected research",
"Connect Notion to index your notes alongside your papers",
"Semantic search means you can ask questions, not just search titles",
"Save a paper once — Supermemory finds it later by what it argued",
"Drop in 5 papers on a topic and ask for the consensus and disagreements",
"Save citations as you read — pull them back out by claim",
"Connect Google Drive to make your dataset notes searchable",
"Use Daily Brief to resurface a finding you almost forgot",
"Save a methodology note once — find it next time you need that protocol",
"Save preprints alongside your reading list — ask what's new since last week",
"Save quotes with their source — find them later by the idea",
],
finance: [
"Save articles and ask follow-up questions across your research",
"Connect Notion to keep your investment thesis searchable",
"Use ⌘K to find specific data points across all your saves",
"Daily Brief surfaces connections you may have missed",
"Save earnings call transcripts once — pull guidance back by ticker or theme",
"Save a thesis once — find it months later by the conviction, not the filename",
"Drop in three sell-side reports and ask for the disagreements",
"Save market commentary daily — surface the calls that aged well",
"Connect Google Drive to query your models without opening them",
"Save a chart with a note — find it again by what it showed",
"Save analyst takes — pull them back when the thesis matters again",
],
design: [
"Save inspiration and search by concept — 'minimalist UI' finds the right ones",
"Use ⌘K to rediscover references by meaning, not filename",
"Connect Notion to make your briefs and moodboards searchable",
"Chrome extension saves any page in one click while you browse",
"Save a screenshot with a note — find it later by what it taught you",
"Drop in 10 onboarding flows and ask Supermemory for the common patterns",
"Save your design crits — find the feedback on a specific decision later",
"Save a brand guideline once — search it by intent, not page number",
"Connect Google Drive to index your Figma exports and briefs",
"Use Daily Brief to resurface a reference that fits today's work",
"Save references by mood — pull them back when the brief calls for it",
],
legal: [
"Save documents and search across them semantically in seconds",
"Connect Notion to index your memos and case notes together",
"Use Daily Brief to resurface relevant precedents automatically",
"Google Drive sync keeps your contracts indexed and queryable",
"Save a clause once — find it next time by what it does, not where it lives",
"Save case law as you read — pull precedents back by argument",
"Drop in three contracts and ask for the diffs in indemnity language",
"Save regulator updates — surface the ones touching your matter",
"Save a memo once — search by issue, not by file name",
"Save deposition notes — find specific testimony by claim later",
],
marketing: [
"Save campaigns and resources — ask what worked across all of them",
"Chrome extension captures competitor pages in one click",
"Use 'Related' to find similar campaigns in your archive",
"Connect Notion to make your campaign briefs instantly searchable",
"Save a competitor's landing page — surface their positioning later by claim",
"Drop in five launch retros and ask for the patterns that drove growth",
"Save ad references and find them by mood, not by URL",
"Save your weekly metrics notes — pull trends back by quarter",
"Use Daily Brief to resurface a positioning note from last campaign",
"Save creative briefs — find similar ones when starting a new one",
],
medical: [
"Save studies and query across your entire reading list",
"Connect Notion to keep clinical notes alongside research",
"Use ⌘K to find specific findings across hundreds of papers",
"Daily Brief surfaces relevant research from your saves automatically",
"Save a guideline once — pull it back by clinical scenario",
"Drop in three trials and ask Supermemory for the methodological diffs",
"Save case reports — surface them later by symptom or finding",
"Connect Google Drive to index protocols across your team",
"Save teaching points from rounds — find them by topic next month",
"Save differentials as notes — pull them back when the presentation repeats",
],
default: [
"Use ⌘K to search by meaning — ask questions, not just keywords",
"Daily Brief surfaces insights from your saves each morning",
"Chrome extension saves any page in one click while you browse",
"Connect integrations to make all your knowledge searchable here",
"Save a page once — find it later by what it said, not its title",
"Save the thing you'd normally bookmark — find it again by intent",
"Drop in 10 articles on a topic and ask for the through-line",
"Save an idea — Supermemory connects it to your earlier ones",
"Use Daily Brief to resurface something useful you forgot you saved",
"Save a thread you liked — pull it back later by what it was about",
],
}
const PROFESSION_PLUGIN_ORDER: Record<Profession, string[]> = {
developer: ["mcp", "chrome", "raycast", "notion", "google-drive"],
research: ["notion", "chrome", "google-drive", "mcp", "raycast"],
finance: ["notion", "google-drive", "chrome", "mcp", "raycast"],
design: ["chrome", "notion", "raycast", "mcp", "google-drive"],
legal: ["notion", "google-drive", "chrome", "mcp", "raycast"],
marketing: ["chrome", "notion", "raycast", "google-drive", "mcp"],
medical: ["notion", "chrome", "google-drive", "mcp", "raycast"],
default: ["mcp", "chrome", "notion", "raycast", "google-drive"],
}
const PROFESSION_LABELS: {
value: Exclude<Profession, "default">
label: string
}[] = [
{ value: "developer", label: "Developer" },
{ value: "research", label: "Researcher" },
{ value: "finance", label: "Finance" },
{ value: "design", label: "Designer" },
{ value: "legal", label: "Legal" },
{ value: "marketing", label: "Marketing" },
{ value: "medical", label: "Medical" },
]
// Static plugin metadata — shared between PluginPromoCard and RecommendedPluginsCard
const PLUGIN_STATIC = [
{
id: "mcp",
name: "Claude MCP",
Icon: MCPIcon,
accentColor: "#D4A853",
tagline: "Ask Claude from your own saved knowledge, not just training data",
cta: "Set up",
},
{
id: "chrome",
name: "Chrome Extension",
Icon: ChromeIcon,
accentColor: "#4BA0FA",
tagline: "Save any page in one click — findable by meaning, forever",
cta: "Install",
},
{
id: "raycast",
name: "Raycast",
Icon: RaycastIcon,
accentColor: "#FF6363",
tagline: "Search your entire memory without leaving your keyboard",
cta: "Install",
},
{
id: "notion",
name: "Notion",
Icon: Notion,
accentColor: "#FAFAFA",
tagline: "Sync your workspace and make every note searchable everywhere",
cta: "Connect",
},
{
id: "google-drive",
name: "Google Drive",
Icon: GoogleDrive,
accentColor: "#4BA0FA",
tagline:
"Index your Drive files — ask questions across docs, slides, sheets",
cta: "Connect",
},
] as const
function RecommendedPluginsCard({
profession,
setProfession,
connectedProviders,
hasMcp,
onOpenPlugins,
onOpenIntegrations,
}: {
profession: Profession
setProfession: (p: Profession) => void
connectedProviders: Set<string>
hasMcp: boolean
onOpenPlugins: () => void
onOpenIntegrations: (integration?: IntegrationParamValue) => void
}) {
const [isEditing, setIsEditing] = useState(false)
useEffect(() => {
setIsEditing(false)
}, [])
const showPicker = profession === "default" || isEditing
const allPlugins = useMemo(() => {
const onClicks: Record<string, () => void> = {
mcp: onOpenPlugins,
chrome: () =>
window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer"),
raycast: () =>
window.open(RAYCAST_EXTENSION_URL, "_blank", "noopener,noreferrer"),
notion: () => onOpenIntegrations("notion"),
"google-drive": () => onOpenIntegrations("google-drive"),
}
const connected: Record<string, boolean> = {
mcp: hasMcp,
chrome: false,
raycast: false,
notion: connectedProviders.has("notion"),
"google-drive": connectedProviders.has("google-drive"),
}
return PLUGIN_STATIC.map((p) => ({
...p,
connected: connected[p.id] ?? false,
onClick: onClicks[p.id]!,
}))
}, [hasMcp, connectedProviders, onOpenPlugins, onOpenIntegrations])
const order = PROFESSION_PLUGIN_ORDER[profession]
const suggestions = useMemo(
() =>
order
.map((id) => allPlugins.find((p) => p.id === id))
.filter((p): p is NonNullable<typeof p> => !!p && !p.connected)
.slice(0, 3),
[order, allPlugins],
)
return (
<div
className={cn(
"bg-surface-card/60 backdrop-blur-md rounded-xl px-3 py-2 flex flex-col gap-1 shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
dmSansClassName(),
)}
>
{showPicker ? (
<div className="px-1 py-2 flex flex-col gap-2.5">
<p className="text-[11px] text-fg-muted">
{isEditing ? "Change your field:" : "What's your field?"}
</p>
<div className="flex flex-wrap gap-1.5">
{PROFESSION_LABELS.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => {
setProfession(value)
setIsEditing(false)
}}
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all cursor-pointer",
profession === value
? "border-[#4BA0FA]/55 bg-[#3374FF]/15 text-[#8BC6FF]"
: "border-surface-border text-fg-subtle hover:border-[#4BA0FA]/40 hover:text-[#6BB0FF]",
)}
>
{label}
</button>
))}
</div>
{isEditing && (
<button
type="button"
onClick={() => setIsEditing(false)}
className="text-[10px] text-fg-faint hover:text-fg-muted transition-colors text-left cursor-pointer"
>
Cancel
</button>
)}
</div>
) : suggestions.length === 0 ? (
<div className="flex items-center justify-center py-4">
<p className="text-[11px] text-fg-subtle text-center">
You're all set
</p>
</div>
) : (
<>
<ul>
{suggestions.map((plugin) => (
<li key={plugin.id}>
<button
type="button"
onClick={plugin.onClick}
className="group w-full flex items-center gap-2.5 rounded-lg px-2 py-2 hover:bg-surface-hover transition-colors cursor-pointer"
>
<plugin.Icon className="size-4 shrink-0 text-fg-subtle" />
<div className="flex-1 min-w-0 text-left">
<p className="text-[12px] text-fg-secondary group-hover:text-white transition-colors leading-tight">
{plugin.name}
</p>
<p className="text-[11px] text-fg-subtle leading-tight mt-0.5">
{PLUGIN_TAGLINES[profession][plugin.id] ?? plugin.tagline}
</p>
</div>
<span className="shrink-0 text-[10px] font-medium text-[#5EA8FF] group-hover:text-[#8BC6FF] transition-colors">
{plugin.cta}
</span>
</button>
</li>
))}
</ul>
<button
type="button"
onClick={() => setIsEditing(true)}
className="text-left px-2 pb-1 text-[10px] text-fg-faint hover:text-fg-muted transition-colors cursor-pointer"
>
Not a{" "}
{PROFESSION_LABELS.find(
(p) => p.value === profession,
)?.label.toLowerCase()}
? Change
</button>
</>
)}
</div>
)
}
function MemoryOfDayCard({ data }: { data: MemoryOfDay }) {
const router = useRouter()
const memory = data.memories[0]
if (!memory) return null
const href = data.sourceDocumentId
? `/?view=list&doc=${encodeURIComponent(data.sourceDocumentId)}`
: "/?view=list"
return (
<button
type="button"
onClick={() => router.push(href)}
className={cn(
"group size-full text-left bg-surface-card/60 backdrop-blur-md rounded-[18px] p-3 flex flex-col justify-between transition-colors cursor-pointer shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
dmSansClassName(),
)}
>
<div className="flex flex-col gap-2.5">
<span className="self-start text-[9px] font-semibold tracking-[0.12em] uppercase text-[#8BC6FF] bg-[#4BA0FA]/16 rounded-full px-2 py-0.5">
{data.timeLabel}
</span>
<p className="text-[12px] text-fg-secondary leading-relaxed line-clamp-4">
{memory}
</p>
</div>
<span className="text-[10px] text-fg-faint group-hover:text-fg-muted transition-colors">
View memories
</span>
</button>
)
}
function PluginPromoCard({
hasMcp,
connectedProviders,
onOpenPlugins,
onOpenIntegrations,
}: {
hasMcp: boolean
connectedProviders: Set<string>
onOpenPlugins: () => void
onOpenIntegrations: (integration?: IntegrationParamValue) => void
}) {
const plugins = useMemo(() => {
const onClicks: Record<string, () => void> = {
mcp: onOpenPlugins,
chrome: () =>
window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer"),
raycast: () =>
window.open(RAYCAST_EXTENSION_URL, "_blank", "noopener,noreferrer"),
notion: () => onOpenIntegrations("notion"),
"google-drive": () => onOpenIntegrations("google-drive"),
}
const connected: Record<string, boolean> = {
mcp: hasMcp,
chrome: false,
raycast: false,
notion: connectedProviders.has("notion"),
"google-drive": connectedProviders.has("google-drive"),
}
return PLUGIN_STATIC.map((p) => ({
...p,
connected: connected[p.id] ?? false,
onClick: onClicks[p.id]!,
})).filter((p) => !p.connected)
}, [hasMcp, connectedProviders, onOpenPlugins, onOpenIntegrations])
const [index, setIndex] = useState(0)
// Reset when the plugins list changes length (e.g., user connects one)
useEffect(() => {
setIndex(0)
}, [])
useEffect(() => {
if (plugins.length <= 1) return
const id = setInterval(
() => setIndex((i) => (i + 1) % plugins.length),
CYCLE_INTERVAL_MS,
)
return () => clearInterval(id)
}, [plugins.length])
const safeIndex = Math.min(index, plugins.length - 1)
const plugin = plugins[safeIndex]
return (
<div
className={cn(
"bg-surface-card/60 backdrop-blur-md rounded-[18px] p-3 flex flex-col justify-between gap-3 h-full shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
dmSansClassName(),
)}
>
{plugin ? (
<>
<AnimatePresence mode="wait">
<motion.div
key={plugin.id}
initial={{ opacity: 0, x: 16 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -16 }}
transition={{ duration: 0.3 }}
className="flex flex-col gap-3 flex-1"
>
<div className="flex items-center justify-between">
<plugin.Icon className="size-7 shrink-0" />
{plugins.length > 1 && (
<div className="flex gap-1">
{plugins.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setIndex(i)}
className={cn(
"rounded-full transition-all cursor-pointer",
i === safeIndex
? "w-3 h-1 bg-[#4BA0FA]"
: "size-1 bg-[#2A3040] hover:bg-[#3A4455]",
)}
/>
))}
</div>
)}
</div>
<div className="flex flex-col gap-1">
<p className="text-[11px] font-semibold text-fg-primary leading-tight">
{plugin.name}
</p>
<p className="text-[10px] text-fg-muted leading-normal">
{plugin.tagline}
</p>
</div>
</motion.div>
</AnimatePresence>
<button
type="button"
onClick={plugin.onClick}
className="w-full bg-surface-card border border-surface-border rounded-lg px-3 py-1.5 text-[11px] font-medium text-[#6BB0FF] hover:text-white hover:bg-surface-hover transition-colors cursor-pointer text-left flex items-center justify-between group"
style={{ boxShadow: "inset 1px 1px 2px rgba(0,0,0,0.5)" }}
>
<span>{plugin.cta}</span>
<ArrowRight className="size-3 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
</>
) : (
<div className="flex flex-col items-center justify-center h-full gap-1.5 text-center">
<Terminal className="size-4 text-fg-faint" />
<p className="text-[10px] text-fg-subtle">
All integrations connected
</p>
</div>
)}
</div>
)
}
export function DashboardView({
spaceLabel,
headerNotice,
highlights,
isLoadingHighlights,
onAddMemory,
onOpenSearch,
onOpenIntegrations,
onOpenPlugins,
onNavigateToMemories,
onNavigateToGraph,
onOpenDocument,
onHighlightsChat,
onHighlightsShowRelated,
onResetHighlights,
memoryOfDay,
}: {
spaceLabel: string
headerNotice?: ReactNode
highlights: HighlightItem[]
isLoadingHighlights: boolean
onAddMemory: (tab: "note" | "link") => void
onOpenSearch: () => void
onOpenIntegrations: (integration?: IntegrationParamValue) => void
onOpenPlugins: () => void
onNavigateToMemories: () => void
onNavigateToGraph: () => void
onOpenDocument: (document: DocumentWithMemories) => void
onHighlightsChat: (seed: string) => void
onHighlightsShowRelated: (query: string) => void
onResetHighlights: () => void
memoryOfDay: MemoryOfDay | null
}) {
const { user } = useAuth()
const { effectiveContainerTags } = useProject()
const _router = useRouter()
const { data: recentsData } = useQuery({
queryKey: ["dashboard-recents", effectiveContainerTags],
queryFn: async (): Promise<DocumentsResponse> => {
const response = await $fetch("@post/documents/documents", {
body: {
page: 1,
limit: 5,
sort: "createdAt",
order: "desc",
containerTags: effectiveContainerTags,
},
disableValidation: true,
})
if (response.error) throw new Error(response.error?.message)
return response.data as DocumentsResponse
},
staleTime: 60 * 1000,
enabled: !!user,
})
const { data: connections = [] } = useQuery({
queryKey: ["connections-list", effectiveContainerTags],
queryFn: async () => {
const response = await $fetch("@post/connections/list", {
body: { containerTags: effectiveContainerTags },
})
if (response.error) return []
return response.data ?? []
},
staleTime: 5 * 60 * 1000,
enabled: !!user,
})
const { data: mcpData } = useQuery({
queryKey: ["mcp-status"],
queryFn: async () => {
const response = await $fetch("@get/mcp/has-login")
return response.data ?? { previousLogin: false }
},
staleTime: 5 * 60 * 1000,
enabled: !!user,
})
const {
copy: personalizedCopy,
profession,
setProfession,
} = usePersonalization()
const recents = recentsData?.documents ?? []
const totalMemories = recentsData?.pagination?.totalItems ?? 0
const hasMcp = mcpData?.previousLogin ?? false
const connectedProviders = new Set(connections.map((c) => c.provider))
const tip = useMemo(() => {
const tips = TIPS[profession]
return tips[Math.floor(Math.random() * tips.length)]
}, [profession])
return (
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto p-4 pt-2! pb-32 md:p-6 md:pb-36 md:pr-0",
dmSansClassName(),
)}
>
<div className="mx-auto w-full max-w-4xl space-y-4 md:space-y-5">
{headerNotice ? <div className="space-y-2">{headerNotice}</div> : null}
{/* Header */}
<motion.header
{...fadeUp}
transition={{ ...fadeUp.transition, delay: 0 }}
className="flex items-end justify-between gap-4 border-b border-surface-border pb-4"
>
<div className="space-y-0.5">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Home
</p>
<h1 className="text-xl font-medium tracking-tight text-white md:text-2xl">
{spaceLabel}
</h1>
</div>
{totalMemories > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onNavigateToGraph}
className="group relative shrink-0 w-[140px] h-[56px] rounded-xl overflow-hidden border border-surface-border hover:border-[#3A4A63] transition-all bg-surface-card hover:scale-[1.02]"
aria-label="Open graph view"
>
<StaticGraphPreview
documentCount={totalMemories}
memoryCount={totalMemories * 6}
width={140}
height={56}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
View graph
</TooltipContent>
</Tooltip>
)}
</motion.header>
{/* Daily Brief — hero */}
<motion.section
{...fadeUp}
transition={{ ...fadeUp.transition, delay: 0.05 }}
className="space-y-2"
>
<div className="flex items-center gap-1.5">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Daily brief
</p>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onResetHighlights}
className="text-fg-faint hover:text-fg-muted transition-colors cursor-pointer"
aria-label="Refresh daily brief"
>
<RotateCcw className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Refresh daily brief
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-3 items-stretch">
<div className="flex-[4] min-w-0">
<HighlightsCard
items={highlights}
onChat={onHighlightsChat}
onShowRelated={onHighlightsShowRelated}
isLoading={isLoadingHighlights}
/>
</div>
<div className="flex-[2] hidden sm:block min-w-0">
{memoryOfDay ? (
<MemoryOfDayCard data={memoryOfDay} />
) : (
<PluginPromoCard
hasMcp={hasMcp}
connectedProviders={connectedProviders}
onOpenPlugins={onOpenPlugins}
onOpenIntegrations={onOpenIntegrations}
/>
)}
</div>
</div>
</motion.section>
{/* Actions + connection status — single unified row */}
<motion.section
{...fadeUp}
transition={{ ...fadeUp.transition, delay: 0.1 }}
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
{/* Quick actions */}
<div className="flex items-center gap-0.5 -mx-2.5">
<button
type="button"
onClick={() => onAddMemory("link")}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-fg-subtle hover:bg-surface-hover hover:text-white transition-colors cursor-pointer"
>
<Link2 className="size-3.5 shrink-0" />
{personalizedCopy.saveLink}
</button>
<span className="text-[#3A4455] select-none">·</span>
<button
type="button"
onClick={() => onAddMemory("note")}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-fg-subtle hover:bg-surface-hover hover:text-white transition-colors cursor-pointer"
>
<FileText className="size-3.5 shrink-0" />
{personalizedCopy.writeNote}
</button>
<span className="text-[#3A4455] select-none">·</span>
<button
type="button"
onClick={() => {
analytics.searchOpened({ source: "header" })
onOpenSearch()
}}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-fg-subtle hover:bg-surface-hover hover:text-white transition-colors cursor-pointer"
>
<SearchIcon className="size-3.5 shrink-0" />
Search
</button>
</div>
{/* Tip of the day */}
<p className="hidden sm:flex items-center gap-1.5 text-[11px] text-fg-subtle min-w-0 overflow-hidden">
<Lightbulb className="size-3 shrink-0 text-[#3374FF]" />
<span className="truncate">{tip}</span>
</p>
</motion.section>
{/* Recently saved + Suggested for you */}
<motion.section
{...fadeUp}
transition={{ ...fadeUp.transition, delay: 0.15 }}
className="space-y-2"
>
{recents.length > 0 ? (
<>
{/* Shared header row — both labels aligned */}
<div className="flex gap-4">
<div className="flex-[3] min-w-0">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Recently saved
</p>
</div>
<div className="flex-[2] min-w-0 hidden sm:block">
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Suggested for you
</p>
</div>
</div>
{/* Content row */}
<div className="flex gap-4 items-start">
<ul className="flex-[3] min-w-0 space-y-0.5">
{recents.map((doc) => {
const isLink = !!doc.url
return (
<li key={doc.id ?? doc.customId}>
<button
type="button"
onClick={() => onOpenDocument(doc)}
className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-surface-hover"
>
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-surface-card ring-1 ring-surface-border group-hover:bg-[#182333] transition-colors">
{isLink ? (
<ExternalLink className="size-3 text-fg-subtle" />
) : (
<FileText className="size-3 text-fg-subtle" />
)}
</div>
<span className="min-w-0 flex-1 truncate text-sm text-fg-muted group-hover:text-white transition-colors">
{doc.title?.trim() || "Untitled"}
</span>
<ArrowRight className="size-3.5 shrink-0 text-fg-faint group-hover:text-fg-muted transition-colors" />
</button>
</li>
)
})}
</ul>
<div className="flex-[2] min-w-0 hidden sm:block">
<RecommendedPluginsCard
profession={profession}
setProfession={setProfession}
connectedProviders={connectedProviders}
hasMcp={hasMcp}
onOpenPlugins={onOpenPlugins}
onOpenIntegrations={onOpenIntegrations}
/>
</div>
</div>
</>
) : (
/* No recents yet — show suggestions full-width */
<>
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
Suggested for you
</p>
<div className="max-w-sm">
<RecommendedPluginsCard
profession={profession}
setProfession={setProfession}
connectedProviders={connectedProviders}
hasMcp={hasMcp}
onOpenPlugins={onOpenPlugins}
onOpenIntegrations={onOpenIntegrations}
/>
</div>
</>
)}
</motion.section>
</div>
</div>
)
}

View file

@ -1,6 +1,6 @@
"use client"
import { memo, useState } from "react"
import { memo, useCallback, useState } from "react"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { dmSansClassName } from "@/lib/fonts"
@ -49,6 +49,7 @@ export const FilePreview = memo(function FilePreview({
document: DocumentWithMemories
}) {
const [imageError, setImageError] = useState(false)
const [retryKey, setRetryKey] = useState(0)
const { extension, color } = getFileTypeInfo(document)
const type = document.type?.toLowerCase()
@ -58,6 +59,17 @@ export const FilePreview = memo(function FilePreview({
document.url &&
!imageError
// 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 fallback file icon view.
const handleImageError = useCallback(() => {
if (retryKey === 0) {
setTimeout(() => setRetryKey(1), 500)
return
}
setImageError(true)
}, [retryKey])
return (
<div className="bg-[#0B1017] rounded-[18px] gap-3 relative overflow-hidden">
{color && (
@ -80,10 +92,11 @@ export const FilePreview = memo(function FilePreview({
/>
<div className="absolute inset-0 bg-black/20" />
<img
key={retryKey}
src={document.url}
alt={document.title || "Image preview"}
className="relative max-w-full max-h-full w-auto h-auto object-contain z-10"
onError={() => setImageError(true)}
className="relative max-w-full max-h-full size-auto object-contain z-10"
onError={handleImageError}
loading="lazy"
/>
</div>
@ -93,17 +106,17 @@ export const FilePreview = memo(function FilePreview({
<DocumentIcon
type={document.type}
url={document.url}
className="w-4 h-4"
className="size-4"
/>
<p
className={cn(dmSansClassName(), "text-[10px] font-semibold")}
className={cn(dmSansClassName(), "text-[11px] font-semibold")}
style={{ color: color }}
>
{extension}
</p>
</div>
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
<p className="text-[11px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}

View file

@ -22,22 +22,22 @@ export function GoogleDocsPreview({
<DocumentIcon
type={document.type}
url={document.url}
className="w-4 h-4"
className="size-4"
/>
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
{label}
</p>
</div>
{document.summary ? (
<p className="text-[10px] text-[#737373] line-clamp-4">
<p className="text-[11px] text-[#737373] line-clamp-4">
{document.summary}
</p>
) : document.content ? (
<p className="text-[10px] text-[#737373] line-clamp-4">
<p className="text-[11px] text-[#737373] line-clamp-4">
{document.content}
</p>
) : (
<p className="text-[10px] text-[#737373] line-clamp-4">
<p className="text-[11px] text-[#737373] line-clamp-4">
No summary available
</p>
)}

View file

@ -16,7 +16,7 @@ export function McpPreview({ document }: { document: DocumentWithMemories }) {
<p
className={cn(
dmSansClassName(),
"text-[12px] font-semibold flex items-center gap-1",
"text-[13px] font-semibold flex items-center gap-1",
)}
>
<ClaudeDesktopIcon className="size-3" />
@ -26,12 +26,12 @@ export function McpPreview({ document }: { document: DocumentWithMemories }) {
</div>
<div className="space-y-[6px]">
{document.title && (
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
{document.title}
</p>
)}
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
<p className="text-[11px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}

View file

@ -13,8 +13,8 @@ export function NotePreview({ document }: { document: DocumentWithMemories }) {
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
<div className="flex items-center gap-1">
<DocumentIcon type="note" className="w-4 h-4" />
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
<DocumentIcon type="note" className="size-4" />
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
Note
</p>
</div>
@ -23,14 +23,14 @@ export function NotePreview({ document }: { document: DocumentWithMemories }) {
<p
className={cn(
dmSansClassName(),
"text-[12px] font-semibold line-clamp-2 leading-[125%]",
"text-[13px] font-semibold line-clamp-2 leading-[125%]",
)}
>
{document.title}
</p>
)}
{document.summary && (
<p className="text-[10px] text-[#737373] line-clamp-4">
<p className="text-[11px] text-[#737373] line-clamp-4">
{document.summary}
</p>
)}

View file

@ -65,7 +65,7 @@ export function NotionPreview({
<span
className={cn(
dmSansClassName(),
"text-[10px] tracking-wide text-[#929292] uppercase",
"text-[11px] tracking-wide text-[#929292] uppercase",
)}
>
Notion
@ -73,9 +73,9 @@ export function NotionPreview({
</div>
{/* Decorative dots mimicking Notion's block handles */}
<div className="flex gap-[3px]">
<div className="w-[3px] h-[3px] rounded-full bg-[#333]" />
<div className="w-[3px] h-[3px] rounded-full bg-[#333]" />
<div className="w-[3px] h-[3px] rounded-full bg-[#333]" />
<div className="size-[3px] rounded-full bg-[#333]" />
<div className="size-[3px] rounded-full bg-[#333]" />
<div className="size-[3px] rounded-full bg-[#333]" />
</div>
</div>
@ -85,7 +85,7 @@ export function NotionPreview({
<p
className={cn(
dmSansClassName(),
"text-[12px] font-semibold text-[#E5E5E5] line-clamp-2 leading-[140%]",
"text-[13px] font-semibold text-[#E5E5E5] line-clamp-2 leading-[140%]",
)}
>
{document.title}
@ -118,7 +118,7 @@ export function NotionPreview({
<div key={i} className="flex items-start gap-1.5">
<div
className={cn(
"mt-[2px] w-[10px] h-[10px] rounded-[2px] border shrink-0",
"mt-[2px] size-[10px] rounded-[2px] border shrink-0",
block.checked
? "bg-[#2F81F7] border-[#2F81F7]"
: "border-[#3B3B3B] bg-transparent",
@ -127,7 +127,9 @@ export function NotionPreview({
{block.checked && (
<svg
viewBox="0 0 10 10"
className="w-full h-full text-white"
className="size-full text-white"
aria-label="Checked"
role="img"
>
<path
d="M2.5 5L4.5 7L7.5 3.5"
@ -142,7 +144,7 @@ export function NotionPreview({
</div>
<p
className={cn(
"text-[10px] line-clamp-1 leading-[140%]",
"text-[11px] line-clamp-1 leading-[140%]",
block.checked
? "text-[#555] line-through"
: "text-[#737373]",
@ -157,8 +159,8 @@ export function NotionPreview({
if (block.type === "bullet") {
return (
<div key={i} className="flex items-start gap-1.5">
<div className="mt-[5px] w-[4px] h-[4px] rounded-full bg-[#555] shrink-0" />
<p className="text-[10px] text-[#737373] line-clamp-1 leading-[140%]">
<div className="mt-[5px] size-[4px] rounded-full bg-[#555] shrink-0" />
<p className="text-[11px] text-[#737373] line-clamp-1 leading-[140%]">
{block.text}
</p>
</div>
@ -168,7 +170,7 @@ export function NotionPreview({
return (
<p
key={i}
className="text-[10px] text-[#737373] line-clamp-1 leading-[140%]"
className="text-[11px] text-[#737373] line-clamp-1 leading-[140%]"
>
{block.text}
</p>
@ -176,7 +178,7 @@ export function NotionPreview({
})}
</div>
) : document.summary ? (
<p className="text-[10px] text-[#737373] line-clamp-4">
<p className="text-[11px] text-[#737373] line-clamp-4">
{document.summary}
</p>
) : null}
@ -189,7 +191,7 @@ export function NotionPreview({
<p
className={cn(
dmSansClassName(),
"text-[10px] font-semibold flex items-center gap-1",
"text-[11px] font-semibold flex items-center gap-1",
)}
style={{
background:
@ -203,7 +205,7 @@ export function NotionPreview({
{document.memoryEntries.length}
</p>
)}
<p className={cn(dmSansClassName(), "text-[10px] text-[#737373]")}>
<p className={cn(dmSansClassName(), "text-[11px] text-[#737373]")}>
{new Date(document.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",

View file

@ -63,7 +63,7 @@ function CustomTweetHeader({
<div className="flex gap-0.5 items-center">
<p
className={cn(
"font-semibold leading-tight overflow-hidden text-[#fafafa] text-[12px] truncate tracking-[-0.12px]",
"font-semibold leading-tight overflow-hidden text-[#fafafa] text-[13px] truncate tracking-[-0.12px]",
dmSansClassName(),
)}
>
@ -73,7 +73,7 @@ function CustomTweetHeader({
</div>
<p
className={cn(
"font-medium leading-tight overflow-hidden text-[#737373] text-[12px] truncate tracking-[-0.12px]",
"font-medium leading-tight overflow-hidden text-[#737373] text-[13px] truncate tracking-[-0.12px]",
dmSansClassName(),
)}
>
@ -105,7 +105,7 @@ function CustomTweetMedia({
<img
src={previewUrl}
alt="Tweet media"
className="w-full h-full object-cover"
className="size-full object-cover"
/>
{isVideo && (
<div className="absolute inset-0 bg-[rgba(4,5,5,0.8)] flex items-center justify-center">

View file

@ -32,7 +32,7 @@ export const WebsitePreview = memo(function WebsitePreview({
<img
src={displayOgImage}
alt={document.title || "Website preview"}
className="w-full h-full object-cover"
className="size-full object-cover"
onError={() => setImageError(true)}
loading="lazy"
/>

View file

@ -21,12 +21,12 @@ export const YoutubePreview = memo(function YoutubePreview({
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
{document.title && (
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
{document.title}
</p>
)}
{document.content && (
<p className="text-[10px] text-[#737373] line-clamp-4">
<p className="text-[11px] text-[#737373] line-clamp-4">
{document.content}
</p>
)}
@ -42,7 +42,7 @@ export const YoutubePreview = memo(function YoutubePreview({
<iframe
src={embedUrl}
title={document.title || "YouTube video"}
className="w-full h-full"
className="size-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
loading="lazy"

View file

@ -28,8 +28,8 @@ function MCPIcon({ className }: { className?: string }) {
xmlns="http://www.w3.org/2000/svg"
>
<title>ModelContextProtocol</title>
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
<path d="M15.69 2.34a2.59 2.59 0 00-3.61 0l-9.63 9.44a.863.86 0 01-1.2 0 .823.82 0 010-1.18l9.63-9.44a4.31 4.31 0 16.02 0 4.12 4.12 0 11.2 3.54 4.3 4.3 0 13.61 1.18l.05.05a4.12 4.12 0 010 5.9l-8.71 8.54a.274.27 0 000 .393l1.79 1.75a.823.82 0 010 1.18.86.863 0 01-1.2 0l-1.79-1.75a1.92 1.92 0 010-2.75l8.71-8.54a2.47 2.47 0 000-3.54l-.05-.049a2.59 2.59 0 00-3.61-.003l-7.17 7.03-.2-.98.1a.863.86 0 01-1.2 0 .823.82 0 010-1.18l7.27-7.13a2.47 2.47 0 00-.003-3.54z" />
<path d="M14.48 4.7a.823.82 0 000-1.18.86.863 0 00-1.2 0l-7.12 6.98a4.12 4.12 0 000 5.9 4.31 4.31 0 6.02 0l7.12-6.98a.823.82 0 000-1.18.86.863 0 00-1.2 0l-7.12 6.98a2.59 2.59 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.98z" />
</svg>
)
}
@ -101,7 +101,7 @@ function YouTubeIcon({ className }: { className?: string }) {
>
<title>YouTube</title>
<path
d="M8 10L13.19 7L8 4V10ZM19.56 2.17C19.69 2.64 19.78 3.27 19.84 4.07C19.91 4.87 19.94 5.56 19.94 6.16L20 7C20 9.19 19.84 10.8 19.56 11.83C19.31 12.73 18.73 13.31 17.83 13.56C17.36 13.69 16.5 13.78 15.18 13.84C13.88 13.91 12.69 13.94 11.59 13.94L10 14C5.81 14 3.2 13.84 2.17 13.56C1.27 13.31 0.69 12.73 0.44 11.83C0.31 11.36 0.22 10.73 0.16 9.93C0.0900001 9.13 0.0599999 8.44 0.0599999 7.84L0 7C0 4.81 0.16 3.2 0.44 2.17C0.69 1.27 1.27 0.69 2.17 0.44C2.64 0.31 3.5 0.22 4.82 0.16C6.12 0.0899998 7.31 0.0599999 8.41 0.0599999L10 0C14.19 0 16.8 0.16 17.83 0.44C18.73 0.69 19.31 1.27 19.56 2.17Z"
d="M8 10L13.19 7L8 4V10ZM19.56 2.17C19.69 2.64 19.78 3.27 19.84 4.07C19.91 4.87 19.94 5.56 19.94 6.16L20 7C20 9.19 19.84 10.8 19.56 11.83C19.31 12.73 18.73 13.31 17.83 13.56C17.36 13.69 16.5 13.78 15.18 13.84C13.88 13.91 12.69 13.94 11.59 13.94L10 14C5.81 14 3.2 13.84 2.17 13.56C1.27 13.31 0.69 12.73 0.44 11.83C0.31 11.36 0.22 10.73 0.16 9.93C0.09 9.13 0.06 8.44 0.06 7.84L0 7C0 4.81 0.16 3.2 0.44 2.17C0.69 1.27 1.27 0.69 2.17 0.44C2.64 0.31 3.5 0.22 4.82 0.16C6.12 0.09 7.31 0.06 8.41 0.06L10 0C14.19 0 16.8 0.16 17.83 0.44C18.73 0.69 19.31 1.27 19.56 2.17Z"
fill="#FF0000"
/>
<path d="M8 10L13.19 7L8 4V10Z" fill="white" />
@ -119,7 +119,7 @@ function TextDocumentIcon({ className }: { className?: string }) {
>
<title>Text Document</title>
<path
d="M3.33333 8.33333H9.16667L10.8333 6.66667H3.33333V8.33333ZM3.33333 5H8.33333V3.33333H3.33333V5ZM1.66667 1.66667V10H7.5L5.83333 11.6667H0V0H16.6667V2.5H15V1.66667H1.66667ZM17.4167 6.08333C17.4861 6.15278 17.5208 6.22917 17.5208 6.3125C17.5208 6.39583 17.4861 6.47222 17.4167 6.54167L16.6667 7.29167L15.2083 5.83333L15.9583 5.08333C16.0278 5.01389 16.1042 4.97917 16.1875 4.97917C16.2708 4.97917 16.3472 5.01389 16.4167 5.08333L17.4167 6.08333ZM9.16667 13.3333V11.875L14.7083 6.33333L16.1667 7.79167L10.625 13.3333H9.16667Z"
d="M3.33 8.33H9.17L10.83 6.67H3.33V8.33ZM3.33 5H8.33V3.33H3.33V5ZM1.67 1.67V10H7.5L5.83 11.67H0V0H16.67V2.5H15V1.67H1.67ZM17.42 6.08C17.49 6.15 17.52 6.23 17.52 6.31C17.52 6.4 17.49 6.47 17.42 6.54L16.67 7.29L15.21 5.83L15.96 5.08C16.03 5.01 16.1 4.98 16.19 4.98C16.27 4.98 16.35 5.01 16.42 5.08L17.42 6.08ZM9.17 13.33V11.88L14.71 6.33L16.17 7.79L10.63 13.33H9.17Z"
fill="currentColor"
/>
</svg>
@ -128,9 +128,15 @@ function TextDocumentIcon({ className }: { className?: string }) {
function XIcon({ className }: { className?: string }) {
return (
<span className={cn("font-bold", className)} style={{ color: "#FFFFFF" }}>
𝕏
</span>
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={cn("text-white", className)}
>
<title>X (Twitter)</title>
<path d="M18.24 2.25h3.31l-7.23 8.26 8.5 11.24H16.17l-4.71-6.23-5.4 6.23H2.75l7.73-8.84L1.25 2.25H8.08l4.25 5.62 5.91-5.62zm-1.16 17.52h1.83L7.08 4.13H5.12z" />
</svg>
)
}
@ -147,7 +153,7 @@ export function DocumentIcon({
url,
className,
}: DocumentIconProps) {
const iconClassName = cn("w-4 h-4", className)
const iconClassName = cn("size-4", className)
if (source === "mcp") {
return <MCPIcon className={iconClassName} />

View file

@ -33,11 +33,11 @@ export function GoogleDocViewer({ url, customId, type }: GoogleDocViewerProps) {
}
return (
<div className="flex flex-col h-full w-full overflow-hidden">
<div className="flex flex-col size-full overflow-hidden">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-[#14161A] z-10">
<div className="flex items-center gap-2 text-gray-400">
<Loader2 className="w-5 h-5 animate-spin" />
<Loader2 className="size-5 animate-spin" />
<span>Loading {typeLabels[type]}...</span>
</div>
</div>
@ -49,7 +49,7 @@ export function GoogleDocViewer({ url, customId, type }: GoogleDocViewerProps) {
)}
<iframe
src={embedUrl}
className="flex-1 w-full h-full border-0 rounded-[14px]"
className="flex-1 size-full border-0 rounded-[14px]"
onLoad={() => setLoading(false)}
onError={() => {
setLoading(false)

View file

@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useCallback, useState } from "react"
import { cn } from "@lib/utils"
interface ImagePreviewProps {
@ -11,6 +11,19 @@ interface ImagePreviewProps {
export function ImagePreview({ url, title }: ImagePreviewProps) {
const [imageError, setImageError] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [retryKey, setRetryKey] = useState(0)
// 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
}
setImageError(true)
setIsLoading(false)
}, [retryKey])
if (imageError || !url) {
return (
@ -21,10 +34,10 @@ export function ImagePreview({ url, title }: ImagePreviewProps) {
}
return (
<div className="relative w-full h-full overflow-hidden flex items-center justify-center bg-[#0B1017]">
<div className="relative size-full overflow-hidden flex items-center justify-center bg-[#0B1017]">
{isLoading && (
<div className="absolute inset-0 bg-cover bg-center animate-pulse">
<div className="w-full h-full bg-[#1B1F24]" />
<div className="size-full bg-[#1B1F24]" />
</div>
)}
<div
@ -38,16 +51,14 @@ export function ImagePreview({ url, title }: ImagePreviewProps) {
/>
<div className="absolute inset-0 bg-black/30" />
<img
key={retryKey}
src={url}
alt={title || "Image preview"}
className={cn(
"relative max-w-full max-h-full w-auto h-auto object-contain z-10",
"relative max-w-full max-h-full size-auto object-contain z-10",
isLoading && "opacity-0",
)}
onError={() => {
setImageError(true)
setIsLoading(false)
}}
onError={handleImageError}
onLoad={() => setIsLoading(false)}
loading="lazy"
/>

View file

@ -21,7 +21,7 @@ const PdfViewer = dynamic(
ssr: false,
loading: () => (
<div className="flex items-center justify-center h-full text-gray-400">
Loading PDF viewer...
Loading PDF viewer
</div>
),
},
@ -87,6 +87,7 @@ export function DocumentContent({
<TweetContent
url={document.url}
tweetMetadata={document.metadata?.sm_internal_twitter_metadata}
content={document.content}
/>
)

View file

@ -1,7 +1,7 @@
"use client"
import { Document, Page, pdfjs } from "react-pdf"
import { useState } from "react"
import { useCallback, useState } from "react"
import "react-pdf/dist/Page/AnnotationLayer.css"
import "react-pdf/dist/Page/TextLayer.css"
@ -19,6 +19,7 @@ export function PdfViewer({ url }: PdfViewerProps) {
const [numPages, setNumPages] = useState<number | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [retryKey, setRetryKey] = useState(0)
if (!url) {
return (
@ -34,16 +35,30 @@ export function PdfViewer({ url }: PdfViewerProps) {
setError(null)
}
function onDocumentLoadError(error: Error) {
setError(error.message || "Failed to load PDF")
setLoading(false)
}
// 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
}
setError(err.message || "Failed to load PDF")
setLoading(false)
},
[retryKey],
)
return (
<div className="flex flex-col h-full w-full overflow-hidden scrollbar-thin">
<div className="flex flex-col size-full overflow-hidden scrollbar-thin">
{loading && (
<div className="flex items-center justify-center h-full text-gray-400">
Loading PDF...
Loading PDF
</div>
)}
{error && (
@ -53,6 +68,7 @@ export function PdfViewer({ url }: PdfViewerProps) {
)}
<div className="flex-1 overflow-auto w-full">
<Document
key={retryKey}
file={
url ||
"https://corsproxy.io/?" +

View file

@ -67,7 +67,7 @@ export function TextEditorContent({
{isSaving ? (
<>
<Loader2 className="size-4 animate-spin mr-1" />
Saving...
Saving
</>
) : (
<>

View file

@ -7,9 +7,14 @@ import { ExternalLinkIcon } from "lucide-react"
interface TweetContentProps {
url?: string | null
tweetMetadata?: unknown
content?: string | null
}
export function TweetContent({ url, tweetMetadata }: TweetContentProps) {
export function TweetContent({
url,
tweetMetadata,
content,
}: TweetContentProps) {
if (tweetMetadata) {
return (
<div className="flex-1 flex items-center justify-center w-full p-4 overflow-auto">
@ -18,6 +23,27 @@ export function TweetContent({ url, tweetMetadata }: TweetContentProps) {
)
}
if (content) {
return (
<div className="flex-1 flex flex-col w-full p-6 overflow-auto">
<pre className="whitespace-pre-wrap text-sm text-[#E5E5E5] font-sans leading-relaxed">
{content}
</pre>
{url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-blue-400 hover:underline mt-4"
>
View on X
<ExternalLinkIcon className="size-3.5" />
</a>
)}
</div>
)
}
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-gray-400">
<p>Tweet preview unavailable</p>
@ -29,7 +55,7 @@ export function TweetContent({ url, tweetMetadata }: TweetContentProps) {
className="flex items-center gap-1.5 text-sm text-blue-400 hover:underline"
>
View on X
<ExternalLinkIcon className="w-3.5 h-3.5" />
<ExternalLinkIcon className="size-3.5" />
</a>
)}
</div>

View file

@ -60,7 +60,7 @@ export function YoutubeVideo({ url }: YoutubeVideoProps) {
if (loading) {
return (
<div className="flex items-center justify-center h-full text-gray-400">
Loading video...
Loading video
</div>
)
}
@ -81,7 +81,7 @@ export function YoutubeVideo({ url }: YoutubeVideoProps) {
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="w-full h-full rounded-lg shadow-lg"
className="size-full rounded-lg shadow-lg"
/>
</div>
</div>

View file

@ -38,21 +38,21 @@ function VersionStatus({
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.00069 0L9.33082 2.5V7.5L5.00069 10L0.670563 7.5V2.5L5.00069 0ZM7.5008 2.5H5.0008H2.50069L2.5008 7.5H7.5008V5V2.5Z"
d="M5 0L9.33 2.5V7.5L5 10L0.67 7.5V2.5L5 0ZM7.5 2.5H5H2.5L2.5 7.5H7.5V5V2.5Z"
fill="#00FFA9"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.0008 2.5H2.50069L2.5008 7.5H7.5008V5C6.12009 5 5.0008 3.88071 5.0008 2.5Z"
d="M5 2.5H2.5L2.5 7.5H7.5V5C6.12 5 5 3.88 5 2.5Z"
fill="#005236"
/>
<path
d="M7.5008 2.5H5.0008C5.0008 3.88071 6.12009 5 7.5008 5V2.5Z"
d="M7.5 2.5H5C5 3.88 6.12 5 7.5 5V2.5Z"
fill="#00FFA9"
/>
<path
d="M9.08072 2.64453V7.35449L5.00064 9.71094L0.920563 7.35449V2.64453L5.00064 0.288086L9.08072 2.64453ZM2.25064 2.25V7.75H7.75064V2.25H2.25064ZM4.76334 2.75C4.88226 4.0691 5.93157 5.11728 7.25064 5.23633V7.25H2.75064V2.75H4.76334ZM7.25064 2.75V4.73438C6.20794 4.61894 5.3806 3.79274 5.26529 2.75H7.25064Z"
d="M9.08 2.64V7.35L5 9.71L0.92 7.35V2.64L5 0.29L9.08 2.64ZM2.25 2.25V7.75H7.75V2.25H2.25ZM4.76 2.75C4.88 4.07 5.93 5.12 7.25 5.24V7.25H2.75V2.75H4.76ZM7.25 2.75V4.73C6.21 4.62 5.38 3.79 5.27 2.75H7.25Z"
stroke="#00FFA9"
strokeWidth="0.5"
/>
@ -71,19 +71,11 @@ function VersionStatus({
xmlns="http://www.w3.org/2000/svg"
>
<title>Static</title>
<circle cx="5.00061" cy="5.00073" r="1.25" fill="#369BFD" />
<circle cx="5" cy="5" r="1.25" fill="#369BFD" />
<circle
cx="5.00061"
cy="5.00073"
r="0.833333"
stroke="#369BFD"
strokeOpacity="0.5"
strokeWidth="0.833333"
/>
<circle
cx="5.00057"
cy="5.00081"
r="2.91667"
cx="5"
cy="5"
r="0.83"
stroke="#369BFD"
strokeOpacity="0.5"
strokeWidth="0.833333"
@ -91,7 +83,15 @@ function VersionStatus({
<circle
cx="5"
cy="5"
r="4.58333"
r="2.92"
stroke="#369BFD"
strokeOpacity="0.5"
strokeWidth="0.833333"
/>
<circle
cx="5"
cy="5"
r="4.58"
stroke="#369BFD"
strokeOpacity="0.2"
strokeWidth="0.833333"
@ -114,17 +114,17 @@ function VersionStatus({
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.50066 2.5H7.50077V7.5H2.50077L2.50066 2.5Z"
d="M2.5 2.5H7.5V7.5H2.5L2.5 2.5Z"
fill="#4D2E00"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.00066 0L9.33078 2.5V7.5L5.00066 10L0.670532 7.5V2.5L5.00066 0ZM7.50077 2.5H2.50066L2.50077 7.5H7.50077V2.5Z"
d="M5 0L9.33 2.5V7.5L5 10L0.67 7.5V2.5L5 0ZM7.5 2.5H2.5L2.5 7.5H7.5V2.5Z"
fill="#FE9900"
/>
<path
d="M9.08069 2.64453V7.35449L5.00061 9.71094L0.920532 7.35449V2.64453L5.00061 0.288086L9.08069 2.64453ZM2.25061 2.25V7.75H7.75061V2.25H2.25061ZM7.25061 2.75V7.25H2.75061V2.75H7.25061Z"
d="M9.08 2.64V7.35L5 9.71L0.92 7.35V2.64L5 0.29L9.08 2.64ZM2.25 2.25V7.75H7.75V2.25H2.25ZM7.25 2.75V7.25H2.75V2.75H7.25Z"
stroke="#FE9900"
strokeWidth="0.5"
/>
@ -145,13 +145,13 @@ function VersionStatus({
<title>Forgotten</title>
<path
opacity="0.6"
d="M9.08008 2.64453V7.35449L5 9.71094L0.919922 7.35449V2.64453L5 0.288086L9.08008 2.64453Z"
d="M9.08 2.64V7.35L5 9.71L0.92 7.35V2.64L5 0.29L9.08 2.64Z"
fill="#60272C"
stroke="#FF6467"
strokeWidth="0.5"
/>
<path
d="M2.08333 2.08341L7.91677 7.91685M7.91667 2.08341L2.08333 7.91675"
d="M2.08 2.08L7.92 7.92M7.92 2.08L2.08 7.92"
stroke="#9C4044"
strokeWidth="0.5"
/>
@ -226,7 +226,7 @@ export function GraphListMemories({
</mask>
<g mask="url(#mask0_891_45456)">
<path
d="M7.00057 12.8334C6.51446 12.8334 6.10126 12.6632 5.76099 12.3229C5.42071 11.9827 5.25057 11.5695 5.25057 11.0834C5.25057 11.0347 5.253 10.9813 5.25786 10.9229C5.26272 10.8646 5.27001 10.8111 5.27974 10.7625L4.06932 10.0771C3.91376 10.2132 3.73876 10.3177 3.54432 10.3906C3.34988 10.4636 3.14085 10.5 2.91724 10.5C2.43113 10.5 2.01793 10.3299 1.67765 9.9896C1.33738 9.64933 1.16724 9.23613 1.16724 8.75002C1.16724 8.26391 1.33738 7.85071 1.67765 7.51044C2.01793 7.17016 2.43113 7.00002 2.91724 7.00002C3.15057 7.00002 3.36932 7.04377 3.57349 7.13127C3.77765 7.21877 3.96238 7.3403 4.12765 7.49585L5.86307 6.62085C5.8339 6.39724 5.84606 6.17849 5.89953 5.9646C5.953 5.75071 6.04779 5.55141 6.1839 5.36669L5.68807 4.60835C5.62001 4.6278 5.54953 4.64238 5.47661 4.6521C5.40369 4.66183 5.32835 4.66669 5.25057 4.66669C4.76446 4.66669 4.35126 4.49655 4.01099 4.15627C3.67071 3.81599 3.50057 3.4028 3.50057 2.91669C3.50057 2.43058 3.67071 2.01738 4.01099 1.6771C4.35126 1.33683 4.76446 1.16669 5.25057 1.16669C5.73668 1.16669 6.14988 1.33683 6.49015 1.6771C6.83043 2.01738 7.00057 2.43058 7.00057 2.91669C7.00057 3.11113 6.96897 3.29828 6.90578 3.47815C6.84258 3.65801 6.75751 3.82085 6.65057 3.96669L7.16099 4.72502C7.23876 4.70558 7.31168 4.69099 7.37974 4.68127C7.44779 4.67155 7.52071 4.66669 7.59849 4.66669C7.76376 4.66669 7.91932 4.68613 8.06515 4.72502C8.21099 4.76391 8.35196 4.82224 8.48807 4.90002L9.45057 4.11252C9.41168 4.0153 9.38251 3.91565 9.36307 3.81356C9.34363 3.71148 9.3339 3.60696 9.3339 3.50002C9.3339 3.01391 9.50404 2.60071 9.84432 2.26044C10.1846 1.92016 10.5978 1.75002 11.0839 1.75002C11.57 1.75002 11.9832 1.92016 12.3235 2.26044C12.6638 2.60071 12.8339 3.01391 12.8339 3.50002C12.8339 3.98613 12.6638 4.39933 12.3235 4.7396C11.9832 5.07988 11.57 5.25002 11.0839 5.25002C10.9186 5.25002 10.7631 5.22815 10.6172 5.1844C10.4714 5.14065 10.3304 5.07988 10.1943 5.0021L9.23182 5.80419C9.27071 5.90141 9.29988 6.00106 9.31932 6.10315C9.33876 6.20523 9.34849 6.30974 9.34849 6.41669C9.34849 6.9028 9.17835 7.31599 8.83807 7.65627C8.49779 7.99655 8.0846 8.16669 7.59849 8.16669C7.36515 8.16669 7.14397 8.12294 6.93494 8.03544C6.72592 7.94794 6.53876 7.82641 6.37349 7.67085L4.65265 8.53127C4.6721 8.61877 4.67939 8.70627 4.67453 8.79377C4.66967 8.88127 4.65751 8.96877 4.63807 9.05627L5.86307 9.75627C6.01863 9.62016 6.19119 9.51565 6.38078 9.44273C6.57036 9.36981 6.77696 9.33335 7.00057 9.33335C7.48668 9.33335 7.89988 9.50349 8.24015 9.84377C8.58043 10.184 8.75057 10.5972 8.75057 11.0834C8.75057 11.5695 8.58043 11.9827 8.24015 12.3229C7.89988 12.6632 7.48668 12.8334 7.00057 12.8334ZM2.91724 9.33335C3.08251 9.33335 3.22106 9.27745 3.33286 9.16565C3.44467 9.05384 3.50057 8.9153 3.50057 8.75002C3.50057 8.58474 3.44467 8.4462 3.33286 8.3344C3.22106 8.22259 3.08251 8.16669 2.91724 8.16669C2.75196 8.16669 2.61342 8.22259 2.50161 8.3344C2.38981 8.4462 2.3339 8.58474 2.3339 8.75002C2.3339 8.9153 2.38981 9.05384 2.50161 9.16565C2.61342 9.27745 2.75196 9.33335 2.91724 9.33335ZM5.25057 3.50002C5.41585 3.50002 5.55439 3.44412 5.66619 3.33231C5.778 3.22051 5.8339 3.08196 5.8339 2.91669C5.8339 2.75141 5.778 2.61287 5.66619 2.50106C5.55439 2.38926 5.41585 2.33335 5.25057 2.33335C5.08529 2.33335 4.94675 2.38926 4.83494 2.50106C4.72314 2.61287 4.66724 2.75141 4.66724 2.91669C4.66724 3.08196 4.72314 3.22051 4.83494 3.33231C4.94675 3.44412 5.08529 3.50002 5.25057 3.50002ZM7.00057 11.6667C7.16585 11.6667 7.30439 11.6108 7.41619 11.499C7.528 11.3872 7.5839 11.2486 7.5839 11.0834C7.5839 10.9181 7.528 10.7795 7.41619 10.6677C7.30439 10.5559 7.16585 10.5 7.00057 10.5C6.83529 10.5 6.69675 10.5559 6.58494 10.6677C6.47314 10.7795 6.41724 10.9181 6.41724 11.0834C6.41724 11.2486 6.47314 11.3872 6.58494 11.499C6.69675 11.6108 6.83529 11.6667 7.00057 11.6667ZM7.5839 7.00002C7.74918 7.00002 7.88772 6.94412 7.99953 6.83231C8.11133 6.72051 8.16724 6.58196 8.16724 6.41669C8.16724 6.25141 8.11133 6.11287 7.99953 6.00106C7.88772 5.88926 7.74918 5.83335 7.5839 5.83335C7.41862 5.83335 7.28008 5.88926 7.16828 6.00106C7.05647 6.11287 7.00057 6.25141 7.00057 6.41669C7.00057 6.58196 7.05647 6.72051 7.16828 6.83231C7.28008 6.94412 7.41862 7.00002 7.5839 7.00002ZM11.0839 4.08335C11.2492 4.08335 11.3877 4.02745 11.4995 3.91565C11.6113 3.80384 11.6672 3.6653 11.6672 3.50002C11.6672 3.33474 11.6113 3.1962 11.4995 3.0844C11.3877 2.97259 11.2492 2.91669 11.0839 2.91669C10.9186 2.91669 10.7801 2.97259 10.6683 3.0844C10.5565 3.1962 10.5006 3.33474 10.5006 3.50002C10.5006 3.6653 10.5565 3.80384 10.6683 3.91565C10.7801 4.02745 10.9186 4.08335 11.0839 4.08335Z"
d="M7 12.83C6.51 12.83 6.1 12.66 5.76 12.32C5.42 11.98 5.25 11.57 5.25 11.08C5.25 11.03 5.25 10.98 5.26 10.92C5.26 10.86 5.27 10.81 5.28 10.76L4.07 10.08C3.91 10.21 3.74 10.32 3.54 10.39C3.35 10.46 3.14 10.5 2.92 10.5C2.43 10.5 2.02 10.33 1.68 9.99C1.34 9.65 1.17 9.24 1.17 8.75C1.17 8.26 1.34 7.85 1.68 7.51C2.02 7.17 2.43 7 2.92 7C3.15 7 3.37 7.04 3.57 7.13C3.78 7.22 3.96 7.34 4.13 7.5L5.86 6.62C5.83 6.4 5.85 6.18 5.9 5.96C5.95 5.75 6.05 5.55 6.18 5.37L5.69 4.61C5.62 4.63 5.55 4.64 5.48 4.65C5.4 4.66 5.33 4.67 5.25 4.67C4.76 4.67 4.35 4.5 4.01 4.16C3.67 3.82 3.5 3.4 3.5 2.92C3.5 2.43 3.67 2.02 4.01 1.68C4.35 1.34 4.76 1.17 5.25 1.17C5.74 1.17 6.15 1.34 6.49 1.68C6.83 2.02 7 2.43 7 2.92C7 3.11 6.97 3.3 6.91 3.48C6.84 3.66 6.76 3.82 6.65 3.97L7.16 4.73C7.24 4.71 7.31 4.69 7.38 4.68C7.45 4.67 7.52 4.67 7.6 4.67C7.76 4.67 7.92 4.69 8.07 4.73C8.21 4.76 8.35 4.82 8.49 4.9L9.45 4.11C9.41 4.02 9.38 3.92 9.36 3.81C9.34 3.71 9.33 3.61 9.33 3.5C9.33 3.01 9.5 2.6 9.84 2.26C10.18 1.92 10.6 1.75 11.08 1.75C11.57 1.75 11.98 1.92 12.32 2.26C12.66 2.6 12.83 3.01 12.83 3.5C12.83 3.99 12.66 4.4 12.32 4.74C11.98 5.08 11.57 5.25 11.08 5.25C10.92 5.25 10.76 5.23 10.62 5.18C10.47 5.14 10.33 5.08 10.19 5L9.23 5.8C9.27 5.9 9.3 6 9.32 6.1C9.34 6.21 9.35 6.31 9.35 6.42C9.35 6.9 9.18 7.32 8.84 7.66C8.5 8 8.08 8.17 7.6 8.17C7.37 8.17 7.14 8.12 6.93 8.04C6.73 7.95 6.54 7.83 6.37 7.67L4.65 8.53C4.67 8.62 4.68 8.71 4.67 8.79C4.67 8.88 4.66 8.97 4.64 9.06L5.86 9.76C6.02 9.62 6.19 9.52 6.38 9.44C6.57 9.37 6.78 9.33 7 9.33C7.49 9.33 7.9 9.5 8.24 9.84C8.58 10.18 8.75 10.6 8.75 11.08C8.75 11.57 8.58 11.98 8.24 12.32C7.9 12.66 7.49 12.83 7 12.83ZM2.92 9.33C3.08 9.33 3.22 9.28 3.33 9.17C3.44 9.05 3.5 8.92 3.5 8.75C3.5 8.58 3.44 8.45 3.33 8.33C3.22 8.22 3.08 8.17 2.92 8.17C2.75 8.17 2.61 8.22 2.5 8.33C2.39 8.45 2.33 8.58 2.33 8.75C2.33 8.92 2.39 9.05 2.5 9.17C2.61 9.28 2.75 9.33 2.92 9.33ZM5.25 3.5C5.42 3.5 5.55 3.44 5.67 3.33C5.78 3.22 5.83 3.08 5.83 2.92C5.83 2.75 5.78 2.61 5.67 2.5C5.55 2.39 5.42 2.33 5.25 2.33C5.09 2.33 4.95 2.39 4.83 2.5C4.72 2.61 4.67 2.75 4.67 2.92C4.67 3.08 4.72 3.22 4.83 3.33C4.95 3.44 5.09 3.5 5.25 3.5ZM7 11.67C7.17 11.67 7.3 11.61 7.42 11.5C7.53 11.39 7.58 11.25 7.58 11.08C7.58 10.92 7.53 10.78 7.42 10.67C7.3 10.56 7.17 10.5 7 10.5C6.84 10.5 6.7 10.56 6.58 10.67C6.47 10.78 6.42 10.92 6.42 11.08C6.42 11.25 6.47 11.39 6.58 11.5C6.7 11.61 6.84 11.67 7 11.67ZM7.58 7C7.75 7 7.89 6.94 8 6.83C8.11 6.72 8.17 6.58 8.17 6.42C8.17 6.25 8.11 6.11 8 6C7.89 5.89 7.75 5.83 7.58 5.83C7.42 5.83 7.28 5.89 7.17 6C7.06 6.11 7 6.25 7 6.42C7 6.58 7.06 6.72 7.17 6.83C7.28 6.94 7.42 7 7.58 7ZM11.08 4.08C11.25 4.08 11.39 4.03 11.5 3.92C11.61 3.8 11.67 3.67 11.67 3.5C11.67 3.33 11.61 3.2 11.5 3.08C11.39 2.97 11.25 2.92 11.08 2.92C10.92 2.92 10.78 2.97 10.67 3.08C10.56 3.2 10.5 3.33 10.5 3.5C10.5 3.67 10.56 3.8 10.67 3.92C10.78 4.03 10.92 4.08 11.08 4.08Z"
className="fill-[#737373] group-hover:fill-white group-data-[state=active]:fill-white"
/>
</g>
@ -251,7 +251,7 @@ export function GraphListMemories({
>
<title>List</title>
<path
d="M0.00153996 1.78136H0.0032201C0.00332596 1.86499 0.0363955 1.94522 0.095258 2.00463C0.15412 2.06404 0.23403 2.09786 0.31766 2.09874L8.76792 2.09902C8.85189 2.09861 8.93229 2.06485 8.99154 2.00536C9.0508 1.94586 9.08411 1.86533 9.08418 1.78136H9.08558V0.3171C9.08539 0.23296 9.05185 0.152327 8.9923 0.0928833C8.93275 0.0334395 8.85206 3.68622e-05 8.76792 0H0.31766C0.233423 3.71008e-05 0.152646 0.0335166 0.0930814 0.0930814C0.0335167 0.152646 3.71008e-05 0.233423 0 0.31766C0 0.32312 0.00139988 0.3283 0.00167988 0.33376L0.00153996 1.78136ZM8.76778 4.49314H0.31766C0.233423 4.49318 0.152646 4.52666 0.0930814 4.58622C0.0335167 4.64579 3.71008e-05 4.72656 0 4.8108C0 4.81626 0.00139988 4.82144 0.00167988 4.8269V6.2745H0.00336002C0.00346588 6.35813 0.0365354 6.43836 0.0953979 6.49777C0.15426 6.55718 0.234171 6.591 0.3178 6.59188L8.76806 6.59216C8.85203 6.59175 8.93243 6.55799 8.99168 6.4985C9.05094 6.439 9.08425 6.35847 9.08432 6.2745H9.08572V4.81024C9.08553 4.72605 9.05195 4.64538 8.99234 4.58592C8.93273 4.52647 8.85197 4.4931 8.76778 4.49314ZM8.76778 8.98614H0.31766C0.233423 8.98618 0.152646 9.01966 0.0930814 9.07922C0.0335167 9.13879 3.71008e-05 9.21956 0 9.3038C0 9.30926 0.00139988 9.31444 0.00167988 9.3199V10.7675H0.00336002C0.00346588 10.8511 0.0365354 10.9314 0.0953979 10.9908C0.15426 11.0502 0.234171 11.084 0.3178 11.0849L8.76806 11.0852C8.85205 11.0848 8.93247 11.051 8.99173 10.9914C9.051 10.9319 9.08428 10.8514 9.08432 10.7674H9.08572V9.3031C9.0855 9.21894 9.0519 9.1383 8.99229 9.07887C8.93269 9.01945 8.85194 8.9861 8.76778 8.98614Z"
d="M0 1.78H0C0 1.86 0.04 1.95 0.1 2C0.15 2.06 0.23 2.1 0.32 2.1L8.77 2.1C8.85 2.1 8.93 2.06 8.99 2.01C9.05 1.95 9.08 1.87 9.08 1.78H9.09V0.32C9.09 0.23 9.05 0.15 8.99 0.09C8.93 0.03 8.85 3.69e-05 8.77 0H0.32C0.23 3.71e-05 0.15 0.03 0.09 0.09C0.03 0.15 3.71e-05 0.23 0 0.32C0 0.32 0 0.33 0 0.33L0 1.78ZM8.77 4.49H0.32C0.23 4.49 0.15 4.53 0.09 4.59C0.03 4.65 3.71e-05 4.73 0 4.81C0 4.82 0 4.82 0 4.83V6.27H0C0 6.36 0.04 6.44 0.1 6.5C0.15 6.56 0.23 6.59 0.32 6.59L8.77 6.59C8.85 6.59 8.93 6.56 8.99 6.5C9.05 6.44 9.08 6.36 9.08 6.27H9.09V4.81C9.09 4.73 9.05 4.65 8.99 4.59C8.93 4.53 8.85 4.49 8.77 4.49ZM8.77 8.99H0.32C0.23 8.99 0.15 9.02 0.09 9.08C0.03 9.14 3.71e-05 9.22 0 9.3C0 9.31 0 9.31 0 9.32V10.77H0C0 10.85 0.04 10.93 0.1 10.99C0.15 11.05 0.23 11.08 0.32 11.08L8.77 11.09C8.85 11.08 8.93 11.05 8.99 10.99C9.05 10.93 9.08 10.85 9.08 10.77H9.09V9.3C9.09 9.22 9.05 9.14 8.99 9.08C8.93 9.02 8.85 8.99 8.77 8.99Z"
className="fill-[#737373] group-hover:fill-white group-data-[state=active]:fill-white"
/>
</svg>
@ -261,7 +261,7 @@ export function GraphListMemories({
</TabsTrigger>
</TabsList>
<TabsContent value="graph" className="flex-1 min-h-0 mt-3">
<div className="w-full h-full rounded-lg overflow-hidden">
<div className="size-full rounded-lg overflow-hidden">
<MemoryGraph
documentIds={documentId ? [documentId] : undefined}
variant="consumer"

View file

@ -110,10 +110,10 @@ function DeleteButton({
transition={{ duration: 0.15 }}
onClick={() => setDeleteConfirmOpen(true)}
tabIndex={-1}
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
disabled={deleteMutation.isPending}
>
<Trash2Icon className="w-4 h-4 text-red-500" />
<Trash2Icon className="size-4 text-red-500" />
<span className="sr-only">Delete document</span>
</motion.button>
) : (
@ -129,21 +129,21 @@ function DeleteButton({
type="button"
onClick={() => setDeleteConfirmOpen(false)}
disabled={deleteMutation.isPending}
className="w-6 h-6 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
className="size-6 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
<XIcon className="w-4 h-4 text-[#737373]" />
<XIcon className="size-4 text-[#737373]" />
<span className="sr-only">Cancel delete</span>
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="w-6 h-6 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
className="size-6 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 text-green-500 animate-spin" />
<Loader2 className="size-4 text-green-500 animate-spin" />
) : (
<CheckIcon className="w-4 h-4 text-green-500" />
<CheckIcon className="size-4 text-green-500" />
)}
<span className="sr-only">Confirm delete</span>
</button>
@ -268,17 +268,17 @@ export function DocumentModal({
rel="noopener noreferrer"
className={cn(
"flex items-center gap-1 bg-[#0D121A] rounded-full shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]",
isMobile ? "w-7 h-7 justify-center" : "px-3 py-2",
isMobile ? "size-7 justify-center" : "px-3 py-2",
)}
>
{!isMobile && (
<span className="line-clamp-1">Visit source</span>
)}
<ArrowUpRightIcon className="w-4 h-4 text-[#737373]" />
<ArrowUpRightIcon className="size-4 text-[#737373]" />
</a>
)}
<DialogPrimitive.Close
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
className="bg-[#0D121A] size-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
data-slot="dialog-close"
type="button"
tabIndex={-1}

View file

@ -12,7 +12,7 @@ function CalendarIcon() {
>
<title>Calendar Icon</title>
<path
d="M1.33333 13.3333C0.966667 13.3333 0.652778 13.2028 0.391667 12.9417C0.130556 12.6806 0 12.3667 0 12V2.66667C0 2.3 0.130556 1.98611 0.391667 1.725C0.652778 1.46389 0.966667 1.33333 1.33333 1.33333H2V0H3.33333V1.33333H8.66667V0H10V1.33333H10.6667C11.0333 1.33333 11.3472 1.46389 11.6083 1.725C11.8694 1.98611 12 2.3 12 2.66667V6H10.6667V5.33333H1.33333V12H6V13.3333H1.33333ZM1.33333 4H10.6667V2.66667H1.33333V4ZM7.33333 13.3333V11.2833L11.0167 7.61667C11.1167 7.51667 11.2278 7.44444 11.35 7.4C11.4722 7.35556 11.5944 7.33333 11.7167 7.33333C11.85 7.33333 11.9778 7.35833 12.1 7.40833C12.2222 7.45833 12.3333 7.53333 12.4333 7.63333L13.05 8.25C13.1389 8.35 13.2083 8.46111 13.2583 8.58333C13.3083 8.70555 13.3333 8.82778 13.3333 8.95C13.3333 9.07222 13.3111 9.19722 13.2667 9.325C13.2222 9.45278 13.15 9.56667 13.05 9.66667L9.38333 13.3333H7.33333ZM8.33333 12.3333H8.96667L10.9833 10.3L10.6833 9.98333L10.3667 9.68333L8.33333 11.7V12.3333ZM10.6833 9.98333L10.3667 9.68333L10.9833 10.3L10.6833 9.98333Z"
d="M1.33 13.33C0.97 13.33 0.65 13.2 0.39 12.94C0.13 12.68 0 12.37 0 12V2.67C0 2.3 0.13 1.99 0.39 1.73C0.65 1.46 0.97 1.33 1.33 1.33H2V0H3.33V1.33H8.67V0H10V1.33H10.67C11.03 1.33 11.35 1.46 11.61 1.73C11.87 1.99 12 2.3 12 2.67V6H10.67V5.33H1.33V12H6V13.33H1.33ZM1.33 4H10.67V2.67H1.33V4ZM7.33 13.33V11.28L11.02 7.62C11.12 7.52 11.23 7.44 11.35 7.4C11.47 7.36 11.59 7.33 11.72 7.33C11.85 7.33 11.98 7.36 12.1 7.41C12.22 7.46 12.33 7.53 12.43 7.63L13.05 8.25C13.14 8.35 13.21 8.46 13.26 8.58C13.31 8.71 13.33 8.83 13.33 8.95C13.33 9.07 13.31 9.2 13.27 9.32C13.22 9.45 13.15 9.57 13.05 9.67L9.38 13.33H7.33ZM8.33 12.33H8.97L10.98 10.3L10.68 9.98L10.37 9.68L8.33 11.7V12.33ZM10.68 9.98L10.37 9.68L10.98 10.3L10.68 9.98Z"
fill="#737373"
/>
</svg>
@ -37,7 +37,8 @@ export function Summary({
<p className="text-[16px] font-semibold text-[#FAFAFA] line-clamp-1 leading-[125%]">
Summary
</p>
<div className="text-[#737373] text-[10px] leading-[150%]">
<div className="flex items-center gap-1 text-[#737373] opacity-50 text-[10px] leading-[150%]">
<SyncLogoIcon className="size-[10px]" />
powered by supermemory
</div>
</div>

View file

@ -30,7 +30,7 @@ export function Title({
)}
>
<div className="pl-1 flex items-center gap-1 shrink-0">
<DocumentIcon type={documentType} url={url} className="w-5 h-5" />
<DocumentIcon type={documentType} url={url} className="size-5" />
{extension && (
<p
className={cn(dmSansClassName(), "text-[12px] font-semibold")}

View file

@ -35,7 +35,6 @@ interface DocumentsCommandPaletteProps {
open: boolean
onOpenChange: (open: boolean) => void
projectId: string
novaContainerTags?: string[]
onOpenDocument: (document: DocumentWithMemories) => void
onAddMemory?: () => void
onOpenIntegrations?: () => void
@ -46,7 +45,6 @@ export function DocumentsCommandPalette({
open,
onOpenChange,
projectId,
novaContainerTags,
onOpenDocument,
onAddMemory,
onOpenIntegrations,
@ -131,10 +129,11 @@ export function DocumentsCommandPalette({
if (queryData?.pages) {
setCachedDocs(queryData.pages.flatMap((page) => page.documents ?? []))
}
setTimeout(() => inputRef.current?.focus(), 0)
const focusTimer = setTimeout(() => inputRef.current?.focus(), 0)
setSearch(initialSearch)
setSelectedIndex(0)
setSearchResults([])
return () => clearTimeout(focusTimer)
}
}, [open, queryClient, projectId, initialSearch])
@ -159,8 +158,7 @@ export function DocumentsCommandPalette({
body: {
q: search.trim(),
limit: 10,
containerTags:
novaContainerTags ?? (projectId ? [projectId] : undefined),
containerTags: projectId ? [projectId] : undefined,
includeSummary: true,
},
signal: controller.signal,
@ -178,7 +176,7 @@ export function DocumentsCommandPalette({
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [search, projectId, novaContainerTags])
}, [search, projectId])
// Build the item list
const hasQuery = search.trim().length > 0
@ -202,7 +200,7 @@ export function DocumentsCommandPalette({
// Reset selection on items change
useEffect(() => {
setSelectedIndex(0)
}, [search, searchResults.length])
}, [])
// Scroll selected into view
useEffect(() => {

View file

@ -1,15 +1,19 @@
"use client"
import { useEffect } from "react"
import { usePathname, useRouter } from "next/navigation"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useAuth } from "@lib/auth-context"
export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const searchParams = useSearchParams()
const { session, organizations, isRestoring } = useAuth()
const isMcpPublicPage = searchParams.get("view") === "mcp"
useEffect(() => {
if (isMcpPublicPage) return
if (isRestoring) return
if (!session) {
router.replace(
@ -20,8 +24,8 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
if (organizations === null) return
if (organizations.length > 0) return
if (pathname.startsWith("/onboarding")) return
router.replace("/onboarding/welcome?step=input")
}, [session, organizations, isRestoring, pathname, router])
router.replace("/onboarding")
}, [session, organizations, isRestoring, pathname, router, isMcpPublicPage])
return children
}

View file

@ -117,7 +117,7 @@ export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) {
<DialogPrimitive.Close
onClick={handleClose}
disabled={isSubmitting}
className="bg-[#0D121A] w-7 h-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"
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:
"0 0.711px 2.842px 0 rgba(0, 0, 0, 0.25), 0.178px 0.178px 0.178px 0 rgba(255, 255, 255, 0.10) inset",
@ -133,7 +133,7 @@ export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) {
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Share your thoughts..."
placeholder="Share your thoughts"
className={cn(
"min-h-[120px] bg-[#0D121A] border border-[rgba(115,115,115,0.2)] text-[#fafafa] placeholder:text-[#737373] focus-visible:border-[#2261CA] focus-visible:ring-[#2261CA33] resize-none",
dmSansClassName(),
@ -164,7 +164,7 @@ export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) {
{isSubmitting ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Submitting...
Submitting
</>
) : (
"Submit"

View file

@ -45,7 +45,11 @@ export function FullscreenNoteModal({
}, [isOpen, initialContent])
const displayName =
user?.displayUsername || localStorageUsername || user?.name || ""
user?.displayUsername ||
localStorageUsername ||
user?.name ||
user?.email?.split("@")[0] ||
""
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
const handleSave = useCallback(() => {
@ -198,7 +202,7 @@ export function FullscreenNoteModal({
>
<title>Command Key</title>
<path
d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
d="M6.67 0.42C6.34 0.42 6.02 0.55 5.78 0.78C5.55 1.02 5.42 1.34 5.42 1.67V6.67C5.42 7 5.55 7.32 5.78 7.55C6.02 7.78 6.34 7.92 6.67 7.92C7 7.92 7.32 7.78 7.55 7.55C7.78 7.32 7.92 7 7.92 6.67C7.92 6.34 7.78 6.02 7.55 5.78C7.32 5.55 7 5.42 6.67 5.42H1.67C1.34 5.42 1.02 5.55 0.78 5.78C0.55 6.02 0.42 6.34 0.42 6.67C0.42 7 0.55 7.32 0.78 7.55C1.02 7.78 1.34 7.92 1.67 7.92C2 7.92 2.32 7.78 2.55 7.55C2.78 7.32 2.92 7 2.92 6.67V1.67C2.92 1.34 2.78 1.02 2.55 0.78C2.32 0.55 2 0.42 1.67 0.42C1.34 0.42 1.02 0.55 0.78 0.78C0.55 1.02 0.42 1.34 0.42 1.67C0.42 2 0.55 2.32 0.78 2.55C1.02 2.78 1.34 2.92 1.67 2.92H6.67C7 2.92 7.32 2.78 7.55 2.55C7.78 2.32 7.92 2 7.92 1.67C7.92 1.34 7.78 1.02 7.55 0.78C7.32 0.55 7 0.42 6.67 0.42Z"
stroke="#737373"
strokeWidth="0.833333"
strokeLinecap="round"

View file

@ -3,6 +3,7 @@
import { memo, useCallback, useRef } from "react"
import { useQueryState } from "nuqs"
import Image from "next/image"
import { Share2 } from "lucide-react"
import { MemoryGraph } from "./memory-graph"
import { useProject } from "@/stores"
import { useGraphHighlights } from "@/stores/highlights"
@ -12,11 +13,11 @@ import { dmSansClassName } from "@/lib/fonts"
import { ShareModal } from "./share-modal"
import { shareParam } from "@/lib/search-params"
interface GraphLayoutViewProps {
isChatOpen: boolean
}
export const GraphLayoutView = memo<GraphLayoutViewProps>(({ isChatOpen }) => {
export const GraphLayoutView = memo(function GraphLayoutView({
onOpenDocument,
}: {
onOpenDocument?: (documentId: string) => void
}) {
const { effectiveContainerTags } = useProject()
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
const [isShareModalOpen, setIsShareModalOpen] = useQueryState(
@ -34,36 +35,41 @@ export const GraphLayoutView = memo<GraphLayoutViewProps>(({ isChatOpen }) => {
}, [setIsShareModalOpen])
return (
<div className="relative w-full h-[calc(100vh-86px)]">
<div className="relative h-full min-h-[calc(100dvh-8.5rem)] w-full md:min-h-0">
{/* Full-width graph */}
<div className="absolute inset-0">
<MemoryGraph
containerTags={effectiveContainerTags}
variant="consumer"
highlightDocumentIds={allHighlightDocumentIds}
highlightsVisible={isChatOpen}
highlightsVisible
maxNodes={undefined}
canvasRef={canvasRef}
onOpenDocument={onOpenDocument}
/>
</div>
{/* Share graph button - top left */}
<div className="absolute top-4 left-4 z-15">
<div className="absolute left-3 top-3 z-15 md:left-4 md:top-4">
<Button
variant="headers"
className={cn(
"rounded-full text-base gap-2 h-10!",
"size-10 rounded-full p-0 md:size-auto md:h-10! md:px-4",
"md:gap-2 md:text-base",
dmSansClassName(),
)}
onClick={handleShare}
aria-label="Share graph"
>
<Image
src="/icons/share-graph.svg"
alt="Share"
width={16}
height={16}
className="hidden md:block"
/>
Share graph
<Share2 className="size-4 md:hidden" />
<span className="hidden md:inline">Share graph</span>
</Button>
</div>

View file

@ -3,7 +3,6 @@
import { Logo } from "@ui/assets/Logo"
import { useAuth } from "@lib/auth-context"
import {
LayoutGridIcon,
Plus,
SearchIcon,
Settings,
@ -13,11 +12,12 @@ import {
ExternalLink,
MenuIcon,
MessageCircleIcon,
LifeBuoy,
LayoutGrid,
} from "lucide-react"
import { Button } from "@ui/components/button"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs"
import { GraphIcon, IntegrationsIcon } from "@/components/integration-icons"
import {
DropdownMenu,
@ -26,6 +26,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} 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"
@ -34,17 +35,16 @@ import { useIsMobile } from "@hooks/use-mobile"
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
import { UserProfileMenu } from "@/components/user-profile-menu"
import { FeedbackModal } from "./feedback-modal"
import { useViewMode, type ViewMode } from "@/lib/view-mode-context"
import { useViewMode } from "@/lib/view-mode-context"
import { useQueryState } from "nuqs"
import { feedbackParam } from "@/lib/search-params"
interface HeaderProps {
onAddMemory?: () => void
onOpenChat?: () => void
onOpenSearch?: () => void
}
export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
const { user, isRestoring } = useAuth()
const { selectedProjects, setSelectedProjects } = useProject()
const router = useRouter()
@ -62,24 +62,25 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
user?.displayUsername ||
(isRestoring ? localStorageUsername : "") ||
user?.name ||
user?.email?.split("@")[0] ||
""
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
return (
<div className="flex p-3 md:p-4 justify-between items-center gap-2">
<div className="flex items-center justify-center gap-2 md:gap-4 z-10! min-w-0">
<div className="relative z-10 flex shrink-0 items-center justify-between gap-1.5 p-2.5 md:gap-2 md:p-3">
<div className="z-10! flex min-w-0 shrink items-center justify-center gap-1.5 md:gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center rounded-lg px-2 py-1.5 -ml-2 cursor-pointer hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-colors shrink-0"
className="flex shrink-0 cursor-pointer items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none md:-ml-2"
>
<Logo className="h-7" />
<Logo className="h-6 md:h-7" />
{!isMobile && userName && (
<div className="flex flex-col items-start justify-center ml-2">
<p className="text-[#8B8B8B] text-[11px] leading-tight">
<div className="ml-1.5 flex flex-col items-start justify-center sm:ml-2">
<p className="text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
{userName}
</p>
<p className="text-white font-bold text-xl leading-none -mt-1">
<p className="-mt-0.5 text-base leading-none font-medium text-white/90 sm:text-lg">
supermemory
</p>
</div>
@ -101,7 +102,7 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<Link href="/">
<Home className="h-4 w-4 text-[#737373]" />
<Home className="size-4 text-[#737373]" />
Home
</Link>
</DropdownMenuItem>
@ -114,7 +115,7 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
target="_blank"
rel="noreferrer"
>
<Code2 className="h-4 w-4 text-[#737373]" />
<Code2 className="size-4 text-[#737373]" />
Developer console
</a>
</DropdownMenuItem>
@ -123,13 +124,12 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<a href="https://supermemory.ai" target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4 text-[#737373]" />
<ExternalLink className="size-4 text-[#737373]" />
supermemory.ai
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="self-stretch w-px bg-[#FFFFFF33] hidden md:block" />
{!isMobile && (
<SpaceSelector
selectedProjects={selectedProjects}
@ -140,47 +140,120 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
)}
</div>
{!isMobile && (
<Tabs
value={viewMode === "list" ? "grid" : viewMode}
onValueChange={(v) =>
setViewMode(v === "grid" ? "list" : (v as ViewMode))
}
>
<TabsList className="rounded-full border border-[#161F2C] h-11! z-10!">
<TabsTrigger
value="grid"
className={cn(
"rounded-full data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-4 py-4 cursor-pointer",
dmSansClassName(),
)}
>
<LayoutGridIcon className="size-4" />
Grid
</TabsTrigger>
<TabsTrigger
value="graph"
className={cn(
"rounded-full dark:data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-4 py-4 cursor-pointer",
dmSansClassName(),
)}
>
<GraphIcon className="size-4" />
Graph
</TabsTrigger>
<TabsTrigger
value="integrations"
className={cn(
"rounded-full dark:data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-4 py-4 cursor-pointer",
dmSansClassName(),
)}
>
<IntegrationsIcon className="size-4" />
Integrations
</TabsTrigger>
</TabsList>
</Tabs>
<div className="z-10! flex min-w-0 max-w-full flex-1 items-center justify-center gap-1.5 overflow-hidden px-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Home"
aria-current={viewMode === "dashboard" ? "page" : undefined}
onClick={() => void setViewMode("dashboard")}
className={cn(
"flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
viewMode === "dashboard"
? "border-[#2261CA33] bg-[#00173C] text-white"
: "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5",
dmSansClassName(),
)}
>
<Home className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Home
</TooltipContent>
</Tooltip>
<div
role="tablist"
aria-label="Content"
aria-orientation="horizontal"
className="text-muted-foreground z-10! inline-flex h-10 w-fit min-w-0 max-w-full items-center justify-center gap-0.5 overflow-x-auto rounded-full border border-[#161F2C] bg-muted p-1 [scrollbar-width:thin]"
>
{(
[
{
mode: "integrations" as const,
label: "Integrations",
icon: IntegrationsIcon,
},
{ mode: "graph" as const, label: "Graph", icon: GraphIcon },
{
mode: "list" as const,
label: "Memories",
icon: LayoutGrid,
},
] as const
).map(({ mode, label, icon: Icon }) => (
<button
key={mode}
type="button"
role="tab"
aria-selected={
mode === "integrations"
? [
"integrations",
"mcp",
"plugins",
"chrome",
"connections",
"shortcuts",
"raycast",
"import",
].includes(viewMode)
: viewMode === mode
}
onClick={() => void setViewMode(mode)}
className={cn(
"inline-flex h-[calc(100%-1px)] min-h-0 cursor-pointer items-center justify-center gap-1 rounded-full border border-transparent px-2.5 text-xs font-medium whitespace-nowrap transition-colors sm:gap-1.5 sm:px-3 sm:text-sm",
(
mode === "integrations"
? [
"integrations",
"mcp",
"plugins",
"chrome",
"connections",
"shortcuts",
"raycast",
"import",
].includes(viewMode)
: viewMode === mode
)
? "border-[#2261CA33] bg-[#00173C] text-white"
: "text-foreground hover:bg-white/5",
dmSansClassName(),
)}
>
<Icon className="size-3.5 shrink-0 sm:size-4" />
{label}
</button>
))}
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Chat"
aria-current={viewMode === "chat" ? "page" : undefined}
onClick={() => void setViewMode("chat")}
className={cn(
"flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
viewMode === "chat"
? "border-[#2261CA33] bg-[#00173C] text-white"
: "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5",
dmSansClassName(),
)}
>
<MessageCircleIcon className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Chat
</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex items-center gap-2 z-10!">
<div className="z-10! flex shrink-0 items-center gap-1.5">
{isMobile ? (
<>
<SpaceSelector
@ -214,21 +287,49 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
onClick={onAddMemory}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<Plus className="h-4 w-4 text-[#737373]" />
<Plus className="size-4 text-[#737373]" />
Add memory
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void setViewMode("dashboard")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<Home className="size-4 text-[#737373]" />
Home
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setViewMode("integrations")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<Sun className="h-4 w-4 text-[#737373]" />
<Sun className="size-4 text-[#737373]" />
Integrations
</DropdownMenuItem>
<DropdownMenuItem
onClick={onOpenChat}
onClick={() => void setViewMode("graph")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<MessageCircleIcon className="h-4 w-4 text-[#737373]" />
<GraphIcon className="size-4 text-[#737373]" />
Graph
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onOpenSearch?.()}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<SearchIcon className="size-4 text-[#737373]" />
Search
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void setViewMode("list")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<LayoutGrid className="size-4 text-[#737373]" />
Memories
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void setViewMode("chat")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<MessageCircleIcon className="size-4 text-[#737373]" />
Chat with Nova
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-[#2E3033]" />
@ -236,14 +337,14 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
onClick={handleFeedback}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<MessageCircleIcon className="h-4 w-4 text-[#737373]" />
<LifeBuoy className="size-4 text-[#737373]" />
Feedback
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push("/settings")}
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
>
<Settings className="h-4 w-4 text-[#737373]" />
<Settings className="size-4 text-[#737373]" />
Settings
</DropdownMenuItem>
</DropdownMenuContent>
@ -251,62 +352,48 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
</>
) : (
<>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
onClick={onAddMemory}
>
<div className="flex items-center gap-2">
<Plus className="size-4" />
Add memory
</div>
<span
className={cn(
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center",
dmSansClassName(),
)}
>
C
</span>
</Button>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
onClick={onOpenSearch}
>
<SearchIcon className="size-4" />
<span className="bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm text-[10px] flex items-center justify-center gap-0.5 px-1">
<svg
className="size-[7.5px]"
viewBox="0 0 9 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="headers"
className={cn(
"rounded-full! h-9! min-h-9 shrink-0",
"max-lg:w-9 max-lg:min-w-9 max-lg:justify-center max-lg:gap-0 max-lg:px-0",
"lg:min-w-0 lg:gap-1.5 lg:px-3 lg:font-medium",
dmSansClassName(),
)}
onClick={onAddMemory}
aria-label="Add memory"
>
<title>Command Key</title>
<path
d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
stroke="#737373"
strokeWidth="0.833333"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className={cn(dmSansClassName())}>K</span>
</span>
</Button>
<Button
variant="headers"
className="rounded-full text-base gap-2 h-10!"
onClick={handleFeedback}
>
<div className="flex items-center gap-2">
<MessageCircleIcon className="size-4" />
Feedback
</div>
</Button>
<Plus className="size-3.5 shrink-0 lg:size-4" />
<span className="max-lg:sr-only">Add memory</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Add memory (C)
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="headers"
className={cn(
"size-9! min-h-9 min-w-9 shrink-0 rounded-full! border-[#161F2C]/90 px-0! text-muted-foreground hover:text-foreground",
dmSansClassName(),
)}
onClick={onOpenSearch}
aria-label="Search"
>
<SearchIcon className="size-4 shrink-0" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Search (K)
</TooltipContent>
</Tooltip>
</>
)}
<UserProfileMenu />
<UserProfileMenu onOpenFeedback={handleFeedback} />
</div>
<FeedbackModal
isOpen={feedbackOpen}
@ -315,3 +402,56 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
</div>
)
}
export function PublicHeader() {
return (
<div className="relative z-10 flex shrink-0 items-center justify-between gap-2 p-2.5 md:p-3">
<Link
href="/"
className="flex items-center gap-2 hover:opacity-90 transition-opacity"
>
<Logo className="h-6 md:h-7" />
<div className="hidden sm:flex flex-col items-start">
<p className="text-[10px] leading-tight text-[#6B6B6B]">Your AI</p>
<p className="-mt-0.5 text-base leading-none font-medium text-white/90">
supermemory
</p>
</div>
</Link>
<div className="flex items-center gap-2">
<p
className={cn(
"hidden md:block text-[13px] text-[#4B5563]",
dmSansClassName(),
)}
>
Connect your tools, search everything.
</p>
<Link href="/login">
<button
type="button"
className={cn(
"text-[13px] font-medium text-[#8B8B8B] hover:text-white transition-colors px-3 h-8 cursor-pointer",
dmSansClassName(),
)}
>
Sign in
</button>
</Link>
<Link href="/login/new">
<button
type="button"
className={cn(
"flex items-center gap-1.5 text-[13px] font-medium text-white",
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 rounded-full px-4 h-8 transition-colors cursor-pointer",
dmSansClassName(),
)}
>
Get started free
</button>
</Link>
</div>
</div>
)
}

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