Merge main into MCP revamp PR

Resolve MCP revamp conflicts by keeping the new server layout, deleting legacy root MCP files, and carrying forward API timeout handling into the new auth/client modules.
This commit is contained in:
ved015 2026-06-30 20:08:28 +05:30
commit d36aa64556
66 changed files with 10359 additions and 2346 deletions

View file

@ -1,5 +1,6 @@
---
title: "Changelog"
sidebarTitle: "Supermemory"
description: "New updates and improvements to Supermemory"
---

View file

@ -0,0 +1,81 @@
---
title: "Plugin changelog"
sidebarTitle: "Plugins"
description: "Recent updates and improvements to Supermemory plugins"
---
<Update label="June 20, 2026" tags={["OpenCode", "Cursor"]}>
### OpenCode entity context
OpenCode now sends entity context with memory operations, so saved context can stay tied to the active project and conversation. The entity-context prompt was also moved out of the API client for cleaner reuse across capture and compaction flows.
### Cursor session auth
Cursor now starts the auth flow from the session hook when needed, and the OAuth success screen uses the Cursor-branded callback path.
</Update>
<Update label="June 18, 2026" tags={["Claude Code", "OpenCode"]}>
### Claude Code update notices
Claude Code now surfaces plugin update notices during sessions and includes the latest package/version metadata.
### OpenCode context prompt
OpenCode gained an entity-context prompt so memory recall and capture can carry more precise source context.
</Update>
<Update label="June 13, 2026" tags={["Claude Code", "Codex"]}>
### Claude Code marketplace polish
The Claude Code plugin manifest was polished for the official marketplace listing, including refreshed metadata and naming.
### Codex update notices
Codex now checks for plugin updates during session start and shows a user-visible notice when a newer version is available.
</Update>
<Update label="June 11, 2026" tags={["Claude Code", "Cursor"]}>
### Claude Code rename migration
Claude Code completed the rename to the `supermemory` plugin while keeping migration safe for users already on the new plugin name. Configuration also supports custom `baseUrl` values for local or self-hosted Supermemory installs.
### Cursor web OAuth
Cursor OAuth now routes through the Supermemory web app, keeping the plugin auth flow consistent with the rest of the integrations.
</Update>
<Update label="June 10, 2026" tags={["Codex", "OpenCode"]}>
### Codex auth and status tooling
Codex added status, logout, and web-auth flows, plus Windows-safe auth URL opening and entity context for saved memories. The installer now includes a `supermemory-status` skill so Codex can report connection, hook, config, and installed-skill health from inside a session.
### OAuth status refinements
Codex and OpenCode integration status now renders more clearly in the Supermemory app during OAuth connection and setup.
</Update>
<Update label="June 6, 2026" tags={["Claude Code", "Cursor", "OpenClaw", "Hermes"]}>
### Claude Code recall reasoning
Claude Code gained reasoned per-turn memory recall with auto-approve support, refreshed bundled scripts, and updated skill names for `supermemory-save` and `supermemory-search`.
### Cursor session hooks
Cursor session hooks now load reliably and persist real project sessions into the correct container.
### OpenClaw and Hermes memory attribution
Saved plugin memories now parse source attribution more accurately, and the dashboard shows the correct plugin logos and recent-memory rows for OpenClaw and Hermes.
</Update>

View file

@ -104,7 +104,11 @@
{
"group": "Manage Content",
"icon": "folder-cog",
"pages": ["document-operations", "memory-operations"]
"pages": [
"document-operations",
"memory-operations",
"memory-review"
]
},
"overview/use-cases"
]
@ -306,7 +310,7 @@
"anchors": [
{
"anchor": "Changelog",
"pages": ["changelog/overview"]
"pages": ["changelog/overview", "changelog/plugins"]
}
],
"tab": "Changelog"

View file

@ -49,7 +49,7 @@ This command:
- Copies hook and skill scripts to `~/.codex/supermemory/`
- Enables `codex_hooks = true` in `~/.codex/config.toml`
- Registers `UserPromptSubmit` (recall) and `Stop` (capture) hooks in `~/.codex/hooks.json`
- Installs `supermemory-search`, `supermemory-save`, and `supermemory-forget` skills to `~/.codex/skills/`
- Installs `supermemory-search`, `supermemory-save`, `supermemory-forget`, and `supermemory-status` skills to `~/.codex/skills/`
Restart Codex CLI after installing.
@ -81,13 +81,14 @@ Tags are generated automatically — no configuration needed. You can override t
## Explicit Memory Skills
The installer includes three skills that Codex auto-discovers from `~/.codex/skills/`. They use the same `SUPERMEMORY_CODEX_API_KEY` as the hooks — no separate login needed.
The installer includes four skills that Codex auto-discovers from `~/.codex/skills/`. They use the same `SUPERMEMORY_CODEX_API_KEY` as the hooks — no separate login needed.
| Skill | Description |
|-------|-------------|
| `supermemory-search` | Search your memories by natural-language query |
| `supermemory-save` | Save important project knowledge to memory |
| `supermemory-forget` | Remove outdated or incorrect memories |
| `supermemory-status` | Check Supermemory connection, hook, config, and skill status |
These skills let you interact with memory explicitly — for example:
@ -95,6 +96,7 @@ These skills let you interact with memory explicitly — for example:
> Remember that this project uses Vitest for unit tests and Playwright for E2E.
> What do you remember about our database schema?
> Forget the memory about the old API endpoint.
> Is Supermemory connected?
```
## Verify Installation
@ -111,7 +113,7 @@ codex-supermemory status:
API key: ✓ set (SUPERMEMORY_CODEX_API_KEY)
Hook scripts: ✓ installed at ~/.codex/supermemory
hooks.json: ✓ registered (implicit memory)
Skills: ✓ installed (supermemory-search, supermemory-save, supermemory-forget)
Skills: ✓ installed (supermemory-search, supermemory-save, supermemory-forget, supermemory-status)
config.toml: ✓ exists
All good! Memory is active.

View file

@ -121,28 +121,163 @@ Use **Create Memories** when you already know the exact facts to store (user pre
## Forget Memory
Soft-delete a memory — excluded from search results but preserved in the system. Use this when you might want to restore later.
Soft-delete a single memory — excluded from search results but preserved in the database. Identify it by `id` or by exact `content`, scoped to its `containerTag`.
<Tabs>
<Tab title="fetch">
```typescript
await fetch("https://api.supermemory.ai/v4/memories/mem_abc123/forget", {
method: "POST",
await fetch("https://api.supermemory.ai/v4/memories", {
method: "DELETE",
headers: {
"Authorization": `Bearer ${API_KEY}`
}
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
// Identify by ID or by exact content
id: "mem_abc123",
// content: "John prefers dark mode",
containerTag: "user_123",
reason: "outdated information"
})
});
```
</Tab>
<Tab title="cURL">
```bash
curl -X POST "https://api.supermemory.ai/v4/memories/mem_abc123/forget" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY"
curl -X DELETE "https://api.supermemory.ai/v4/memories" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "mem_abc123",
"containerTag": "user_123",
"reason": "outdated information"
}'
```
</Tab>
</Tabs>
The memory will no longer appear in search results but remains in the database.
### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | * | Memory ID to forget |
| `content` | string | * | Exact content match to forget (alternative to ID) |
| `containerTag` | string | yes | Container tag / space the memory belongs to |
| `reason` | string | no | Optional reason recorded as `forgetReason` |
\* Either `id` or `content` must be provided.
The memory will no longer appear in search results but remains in the database (`isForgotten=true`).
---
## Forget Matching
Forget **everything** about a topic in one call. You give a prompt or a query; the service semantically searches the container's memories, an LLM decides which ones are genuinely about your target, and those are soft-deleted. Use this for "forget everything about X" rather than deleting memories one by one.
<Warning>
This is a bulk, destructive operation. Always **`dryRun` first** to review what would be forgotten, then re-run with `dryRun: false`. The match is semantic, so a too-broad query can select more than you intend — `threshold` and `maxForget` bound the blast radius.
</Warning>
<Tabs>
<Tab title="fetch">
```typescript
// 1) Preview
const preview = await fetch("https://api.supermemory.ai/v4/memories/forget-matching", {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
query: "forget everything about Project Titan",
containerTag: "user_123",
dryRun: true
})
}).then((r) => r.json());
// preview.candidates → [{ id, memory, score }, ...]
// 2) Apply
const result = await fetch("https://api.supermemory.ai/v4/memories/forget-matching", {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
query: "forget everything about Project Titan",
containerTag: "user_123",
dryRun: false,
reason: "project cancelled"
})
}).then((r) => r.json());
// result.forgotten → [{ id, memory, score }, ...]
// result.forgetBatchId → tagged on every forgotten memory for traceability
```
</Tab>
<Tab title="cURL">
```bash
# Preview
curl -X POST "https://api.supermemory.ai/v4/memories/forget-matching" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "forget everything about Project Titan",
"containerTag": "user_123",
"dryRun": true
}'
# Apply
curl -X POST "https://api.supermemory.ai/v4/memories/forget-matching" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "forget everything about Project Titan",
"containerTag": "user_123",
"dryRun": false,
"reason": "project cancelled"
}'
```
</Tab>
</Tabs>
### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | yes | What to forget — a natural-language instruction ("forget everything about Project Titan") or a bare topic ("Project Titan") |
| `containerTag` | string | yes | Container tag / space to scope the operation to |
| `dryRun` | boolean | no | When `true`, returns what *would* be forgotten without changing anything. Defaults to `false` |
| `threshold` | number | no | Similarity floor (01) for candidate memories. Lower casts a wider net. Defaults to `0.5` |
| `maxForget` | number | no | Safety cap on how many memories may be forgotten in one call (1500). Defaults to `100` |
| `reason` | string | no | Reason recorded as `forgetReason` on each forgotten memory |
### Response
```json
{
"dryRun": false,
"count": 3,
"forgetBatchId": "VcuQoGRz4hA4ak5Xu6DRUN",
"summary": "Forgot 3 memories about \"Project Titan\".",
"forgotten": [
{ "id": "mem_1", "memory": "Project Titan ships in Q3", "score": 0.82 }
]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `dryRun` | boolean | Whether this was a preview or a real forget |
| `count` | number | Number of memories selected (dryRun) or forgotten (apply) |
| `forgetBatchId` | string \| null | ID tagged on every memory forgotten in this call; `null` on dryRun |
| `summary` | string | One-line summary of the operation (e.g. `Forgot 3 memories about "Project Titan".`) |
| `candidates` | array | On `dryRun`: the memories that **would** be forgotten (`{ id, memory, score }`) |
| `forgotten` | array | On apply: the memories that **were** forgotten (`{ id, memory, score }`) |
<Note>
Identity is server-owned: the LLM only ever references opaque handles for the memories a search returned, so it can never forget a memory outside the results it reviewed, and every operation is scoped to the `containerTag` you pass.
</Note>
---
@ -201,6 +336,7 @@ Update a memory by creating a new version. The original is preserved with `isLat
## Next Steps
- [Review Inferred Memories](/memory-review) — Approve or decline low-confidence memories
- [Document Operations](/document-operations) — Manage documents (SDK supported)
- [Search](/search) — Query your memories
- [Ingesting Content](/add-memories) — Add new content

288
apps/docs/memory-review.mdx Normal file
View file

@ -0,0 +1,288 @@
---
title: "Review Inferred Memories"
sidebarTitle: "Memory Review"
description: "List and act on low-confidence inferred memories — approve, decline, or undo"
icon: "list-checks"
---
Supermemory's graph automatically **derives** new facts from patterns across your
existing memories (see [Graph Memory](/concepts/graph-memory)). These derived facts
are guesses — the engine wasn't told them directly — so they are flagged as
**inferred** (`isInference: true`) and **down-weighted in search** until confirmed.
These two endpoints let you build a review experience on top of that queue: list the
inferred memories awaiting review, then **approve**, **decline**, or **undo** a
decision on each one.
<Info>
These endpoints are scoped to a single [container tag](/concepts/container-tags)
(space), under `/v3/container-tags/{containerTag}`.
</Info>
## How review affects ranking
While a memory is unreviewed and inferred it is down-weighted in search, so the
engine's guesses rank below facts you stated explicitly. Reviewing it resolves that
either way:
| Action | Result | Effect on search |
|--------|--------|------------------|
| **Approve** | `isInference` cleared | Ranks like a stated fact — no longer down-weighted |
| **Decline** | `isForgotten` set | Removed from search entirely — a rejected guess is forgotten |
| **Undo** | back to unreviewed | Returns to the queue; inferred and down-weighted again |
A reviewed memory is stamped with `reviewStatus` in its metadata so it drops out of the
review queue (declined memories also leave search, since they're forgotten). **Undo**
clears that stamp — and un-forgets a declined memory — bringing it back.
---
## List Inferred Memories
Return the inferred memories for a container tag that are still awaiting review (the
review queue). Reviewed memories are excluded.
```
GET /v3/container-tags/{containerTag}/inferred
```
<Tabs>
<Tab title="fetch">
```typescript
const res = await fetch(
"https://api.supermemory.ai/v3/container-tags/user_123/inferred",
{ headers: { "Authorization": `Bearer ${API_KEY}` } }
);
const { memories, total } = await res.json();
```
</Tab>
<Tab title="cURL">
```bash
curl "https://api.supermemory.ai/v3/container-tags/user_123/inferred" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY"
```
</Tab>
</Tabs>
### Path parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `containerTag` | string | The container tag / space to read the review queue for |
### Response
```json
{
"memories": [
{
"id": "mem_abc123",
"memory": "Alex likely works on Stripe's core payments product",
"parentCount": 3,
"createdAt": "2025-01-15T10:30:00.000Z",
"updatedAt": "2025-01-15T10:30:00.000Z",
"metadata": { "source": "derive" }
}
],
"total": 1
}
```
| Field | Type | Description |
|-------|------|-------------|
| `memories[].id` | string | Memory entry ID — pass to the review endpoint |
| `memories[].memory` | string | The inferred memory text |
| `memories[].parentCount` | number | How many source memories this was derived from. Higher = stronger signal |
| `memories[].createdAt` | string | ISO 8601 timestamp |
| `memories[].updatedAt` | string | ISO 8601 timestamp |
| `memories[].metadata` | object \| null | Arbitrary metadata stored on the memory |
| `total` | number | Count of unreviewed inferred memories returned |
<Note>
The queue returns up to **50** memories, ordered by `parentCount` descending (most
strongly supported first), then by `createdAt` descending. It excludes anything that
is forgotten, expired, or already reviewed. An unknown or empty container tag returns
`{ "memories": [], "total": 0 }`.
</Note>
---
## Review an Inferred Memory
Record a decision on a single inferred memory.
```
POST /v3/container-tags/{containerTag}/inferred/{memoryId}/review
```
<Tabs>
<Tab title="fetch">
```typescript
const res = await fetch(
"https://api.supermemory.ai/v3/container-tags/user_123/inferred/mem_abc123/review",
{
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ action: "approve" })
}
);
const result = await res.json();
// { id: "mem_abc123", isInference: false, isForgotten: false, reviewStatus: "approved" }
```
</Tab>
<Tab title="cURL">
```bash
curl -X POST \
"https://api.supermemory.ai/v3/container-tags/user_123/inferred/mem_abc123/review" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"action": "approve"}'
```
</Tab>
</Tabs>
### Path parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `containerTag` | string | The container tag / space the memory belongs to |
| `memoryId` | string | The memory entry ID from the list endpoint |
### Body parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `action` | string | yes | One of `approve`, `decline`, or `undo` |
<Warning>
The reject action is named **`decline`**. There is no `reject` value.
</Warning>
**Action semantics:**
- **`approve`** — Promote the memory: clears `isInference`, so it ranks like a stated
fact instead of a down-weighted guess. Stamps `reviewStatus: "approved"`.
- **`decline`** — Reject the suggestion: the memory is **forgotten**
(`isForgotten: true`) and stamped `reviewStatus: "declined"`, so it leaves both
search and the review queue.
- **`undo`** — Revert a prior `approve`/`decline` back to the unreviewed inferred
state: restores `isInference: true`, un-forgets the memory (`isForgotten: false`),
and clears the review stamp, so it returns to the queue.
### Response
```json
{
"id": "mem_abc123",
"isInference": false,
"isForgotten": false,
"reviewStatus": "approved"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `id` | string | The reviewed memory ID |
| `isInference` | boolean | `false` after approve; `true` after decline or undo |
| `isForgotten` | boolean | `true` after decline (the memory is forgotten); `false` otherwise |
| `reviewStatus` | `"approved"` \| `"declined"` \| `null` | The new status; `null` after an undo |
### Errors
| Status | When |
|--------|------|
| `401` | Missing or invalid authentication |
| `404` | The container tag or memory was not found in your organization |
| `409` | The memory isn't reviewable for this action — it's not an inferred memory, or there's no prior review to undo |
---
## Building a review experience
The endpoints are designed for an optimistic, one-at-a-time review UI (swipe to keep /
decline, with undo). A typical client fetches the queue once, then pops each card off
locally as the user decides — `undo` re-adds it.
<Accordion title="React Query hooks (TypeScript)">
```typescript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
const BASE = "https://api.supermemory.ai/v3";
const key = (tag: string) => ["inferred-memories", tag] as const;
export type InferredMemory = {
id: string;
memory: string;
parentCount: number;
createdAt: string;
updatedAt: string;
metadata: Record<string, unknown> | null;
};
export type ReviewAction = "approve" | "decline" | "undo";
export function useInferredMemories(containerTag: string) {
return useQuery({
queryKey: key(containerTag),
queryFn: async (): Promise<InferredMemory[]> => {
const res = await fetch(`${BASE}/container-tags/${containerTag}/inferred`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
if (!res.ok) throw new Error("Failed to load review queue");
const data = await res.json();
return data.memories ?? [];
},
staleTime: 60_000,
});
}
export function useReviewInferredMemory(containerTag: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (vars: { memoryId: string; action: ReviewAction }) => {
const res = await fetch(
`${BASE}/container-tags/${containerTag}/inferred/${vars.memoryId}/review`,
{
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ action: vars.action }),
},
);
if (!res.ok) throw new Error("Review failed");
return res.json();
},
onSuccess: (_data, { memoryId, action }) => {
// approve/decline remove the card; undo brings it back, so refetch.
if (action === "undo") {
queryClient.invalidateQueries({ queryKey: key(containerTag) });
return;
}
queryClient.setQueryData<InferredMemory[]>(key(containerTag), (prev) =>
prev?.filter((m) => m.id !== memoryId),
);
},
});
}
```
</Accordion>
<Note>
There is no separate "skip" action. A swipe-to-skip is purely client-side — don't send
a request and the memory simply stays in the queue for a later session.
</Note>
---
## Next Steps
- [Graph Memory](/concepts/graph-memory) — How inferred (`derive`) memories are created
- [Memory Operations](/memory-operations) — Create, forget, and update memories
- [Search](/search) — How inferred memories are ranked in results

View file

@ -121,6 +121,9 @@ Get profile and search results in one call by adding the `q` parameter:
| `containerTag` | string | Yes | User/project identifier |
| `q` | string | No | Search query (includes search results in response) |
| `threshold` | 0-1 | No | Filter search results by relevance score |
| `filters` | object | No | Metadata filters applied to profile and search results |
| `include` | string[] | No | Sections to return — any of `"static"`, `"dynamic"`, `"buckets"`. Omit to return all |
| `buckets` | string[] | No | Restrict the `buckets` section to specific keys. Omit for all configured buckets |
---
@ -180,6 +183,142 @@ ${result.searchResults?.results.map(m => m.memory).join('\n') || 'None'}
---
## Profile Buckets
Buckets are **custom topical categories** for a profile — an axis that sits alongside
`static` and `dynamic`. Where static/dynamic split facts by how long-lived they are,
buckets group them by subject (e.g. `preferences`, `goals`, `work`). As content is
ingested, a classifier assigns each memory to the buckets it matches, so you can pull
just the slice of context a given surface needs.
Every org starts with a built-in `preferences` bucket. You can define your own at the
organization or space level in your console settings; space-level buckets are
**add-only** — a container tag inherits all org buckets and may add more, but cannot
disable them.
### Requesting buckets
Pass `include: ["buckets"]` to return bucket-organized memories, and optionally
`buckets` to limit the response to specific keys. `include` also lets you skip
sections you don't need — `["buckets"]` alone omits `static` and `dynamic`.
<Tabs>
<Tab title="fetch">
```typescript
const res = await fetch("https://api.supermemory.ai/v4/profile", {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
containerTag: "user_123",
include: ["buckets"],
buckets: ["preferences", "goals"] // optional — omit for all buckets
})
});
const { profile } = await res.json();
console.log(profile.buckets.preferences);
console.log(profile.buckets.goals);
```
</Tab>
<Tab title="cURL">
```bash
curl -X POST "https://api.supermemory.ai/v4/profile" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"containerTag": "user_123",
"include": ["buckets"],
"buckets": ["preferences", "goals"]
}'
```
</Tab>
</Tabs>
**Response:**
```json
{
"profile": {
"buckets": {
"preferences": [
"[Summary] Prefers concise, technical answers and dark-mode tooling",
"[Recent] Switched their editor to Zed"
],
"goals": [
"[Recent] Wants to ship the billing revamp this quarter"
]
}
}
}
```
<Note>
**`[Recent]` and `[Summary]` labels.** To keep profiles dense, an entity's older
memories are periodically aggregated into a short synthesis. Entries prefixed
`[Summary]` are that aggregated context; entries prefixed `[Recent]` were ingested
since the last aggregation and aren't summarized yet. The `dynamic` section uses the
same `[Recent]` prefix (plus a `[YYYY-MM-DD]` date). Strip the prefixes if you only
want raw text, or keep them to signal recency to your model.
</Note>
### List bucket definitions
To see which buckets are configured for a container tag (org buckets merged with any
space-level additions), call `/v4/profile/buckets`:
<Tabs>
<Tab title="fetch">
```typescript
const res = await fetch("https://api.supermemory.ai/v4/profile/buckets", {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ containerTag: "user_123" })
});
const { buckets } = await res.json();
// [{ key: "preferences", description: "..." }, ...]
```
</Tab>
<Tab title="cURL">
```bash
curl -X POST "https://api.supermemory.ai/v4/profile/buckets" \
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"containerTag": "user_123"}'
```
</Tab>
</Tabs>
**Response:**
```json
{
"buckets": [
{
"key": "preferences",
"description": "Explicit first-person preferences the person directly stated."
}
]
}
```
| Field | Type | Description |
|-------|------|-------------|
| `buckets[].key` | string | Stable slug, also stored on each memory. Lowercase alphanumeric with `-`/`_`, 164 chars |
| `buckets[].description` | string | What belongs in the bucket — guides the ingestion classifier |
<Tip>
Bucket descriptions steer classification. A precise description ("Explicit
first-person preferences only — exclude inferred traits") yields cleaner buckets than
a vague one. `static` and `dynamic` are reserved and can't be used as bucket keys.
</Tip>
---
## Framework Examples
<Accordion title="Express.js Middleware">
@ -249,8 +388,9 @@ ${result.searchResults?.results.map(m => m.memory).join('\n') || 'None'}
```typescript
interface ProfileResponse {
profile: {
static: string[]; // Long-term facts
dynamic: string[]; // Recent context
static?: string[]; // Long-term facts
dynamic?: string[]; // Recent context
buckets?: Record<string, string[]>; // Topical buckets, keyed by bucket key
};
searchResults?: { // Only if q parameter provided
results: SearchResult[];

View file

@ -47,7 +47,7 @@ Replace `claude` with: `cursor`, `opencode`, or `vscode`
After adding the MCP, paste this in your agent session:
<Accordion title="Copy prompt below." icon='copy'>
```
````
You are integrating Supermemory into my application. Supermemory provides user memory, semantic search, and automatic knowledge extraction for AI applications.
Note: You can always reference the documentation by using the **SearchSupermemoryDocs MCP** or running a web search tool for content on **supermemory.ai/docs**.
@ -386,7 +386,7 @@ NOW:
3. Include installation, settings config, and full integration
DOCS: https://supermemory.ai/docs
```
````
</Accordion>

3480
apps/mcp/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,8 @@
import type { ContainerTagAccess } from "../../shared/types"
const FETCH_TIMEOUT_MS = 30_000
export type { ContainerTagAccess }
export interface AuthUser {
@ -30,6 +32,7 @@ export async function validateApiKey(
const response = await fetch(`${apiUrl}/v3/session`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
})
if (!response.ok) {
@ -82,6 +85,7 @@ export async function validateOAuthToken(
const response = await fetch(`${apiUrl}/v3/mcp/session-with-key`, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
})
if (!response.ok) {
@ -124,6 +128,7 @@ export async function validateOAuthToken(
const rbacResponse = await fetch(`${apiUrl}/v3/session`, {
method: "GET",
headers: { Authorization: `Bearer ${data.apiKey}` },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
})
if (!rbacResponse.ok) {
console.error("RBAC fetch returned non-OK:", rbacResponse.status)

View file

@ -8,6 +8,7 @@ import type {
const MAX_CHARS = 200000
const DEFAULT_PROJECT_ID = "sm_project_default"
const FETCH_TIMEOUT_MS = 30_000
export type {
ContainerTag,
@ -82,6 +83,7 @@ export class SupermemoryClient {
this.client = new Supermemory({
apiKey: bearerToken,
baseURL: apiUrl,
timeout: FETCH_TIMEOUT_MS,
})
this.containerTag = containerTag || DEFAULT_PROJECT_ID
}
@ -242,12 +244,14 @@ export class SupermemoryClient {
async listContainerTags(): Promise<ContainerTag[]> {
try {
const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS)
const response = await fetch(`${this.apiUrl}/v3/container-tags/list`, {
method: "GET",
headers: {
Authorization: `Bearer ${this.bearerToken}`,
"Content-Type": "application/json",
},
signal,
})
if (!response.ok) {
@ -270,8 +274,10 @@ export class SupermemoryClient {
containerTags?: string[],
page = 1,
limit = 200,
options?: { signal?: AbortSignal },
): Promise<DocumentsApiResponse> {
try {
const signal = options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS)
const response = await fetch(`${this.apiUrl}/v3/documents/documents`, {
method: "POST",
headers: {
@ -285,6 +291,7 @@ export class SupermemoryClient {
order: "desc",
containerTags,
}),
signal,
})
if (!response.ok) {
throw Object.assign(new Error("Failed to fetch documents"), {
@ -338,6 +345,15 @@ export class SupermemoryClient {
}
private handleError(error: unknown): never {
// Handle request timeout (AbortSignal.timeout or explicit abort)
if (
error instanceof Error &&
(error.name === "AbortError" || error.name === "TimeoutError")
) {
throw new Error("Request to Supermemory API timed out")
}
// Handle network/fetch errors
if (error instanceof TypeError) {
if (
error.message.includes("fetch") ||

View file

@ -0,0 +1,13 @@
import { notFound } from "next/navigation"
import { AppExperience } from "@/components/app-experience"
import { isIntegrationCard } from "@/lib/integration-routes"
export default async function IntegrationCardPage({
params,
}: {
params: Promise<{ card: string }>
}) {
const { card } = await params
if (!isIntegrationCard(card)) notFound()
return <AppExperience />
}

View file

@ -0,0 +1,5 @@
import { AppExperience } from "@/components/app-experience"
export default function IntegrationsPage() {
return <AppExperience />
}

View file

@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"
import { toast } from "sonner"
import { useAuth } from "@lib/auth-context"
import { authClient } from "@lib/auth"
import { analytics } from "@/lib/analytics"
import { BrainShell } from "@/components/onboarding-brain/shell"
import {
StepAbout,
@ -15,6 +16,7 @@ import {
type SourcesValues,
} from "@/components/onboarding-brain/step-sources"
import { StepIngest } from "@/components/onboarding-brain/step-ingest"
import { useFeatureFlagEnabled } from "posthog-js/react"
import {
StepTeam,
type TeamValues,
@ -34,12 +36,29 @@ import {
const STORAGE_KEY = "supermemory-brain-onboarding-v1"
const countsAsConnectedSource = (state: unknown) =>
state === "connected" || state === "waitlist"
const getErrorMessage = (error: unknown, fallback: string) => {
if (error instanceof Error && error.message) return error.message
if (typeof error === "string" && error.trim()) return error
if (typeof error === "object" && error !== null && "message" in error) {
const message = (error as { message?: unknown }).message
if (typeof message === "string" && message.trim()) return message
}
return fallback
}
export default function BrainOnboardingPage() {
const router = useRouter()
const params = useSearchParams()
const { user, org, organizations, setActiveOrg, refetchOrganizations } =
useAuth()
// `?new=1` forces creating an additional org even when the user already has one.
const forceCreate = params?.get("new") === "1"
const nameParam = params?.get("name")?.trim() || ""
const stepFromUrl = (params?.get("step") as BrainStep | null) ?? "about"
const initialStep: BrainStep = BRAIN_STEPS.includes(stepFromUrl)
? stepFromUrl
@ -60,11 +79,13 @@ export default function BrainOnboardingPage() {
[user?.email],
)
// Team (Company Brain) onboarding is gated behind a private-beta flag.
const allowTeam = useFeatureFlagEnabled("company-brain-beta") ?? false
const [mode, setMode] = useState<BrainMode>(detectedMode)
const [about, setAbout] = useState<AboutValues>({
name: user?.name ?? "",
about: "",
workspaceName: suggestedWorkspaceName,
workspaceName: nameParam || suggestedWorkspaceName,
workspaceDomain: domain ?? "",
})
const [sources, setSources] = useState<SourcesValues>({
@ -78,6 +99,7 @@ export default function BrainOnboardingPage() {
})
useEffect(() => {
if (forceCreate) return
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
@ -92,7 +114,7 @@ export default function BrainOnboardingPage() {
if (cached.sources) setSources((s) => ({ ...s, ...cached.sources }))
if (cached.team) setTeam((t) => ({ ...t, ...cached.team }))
} catch {}
}, [])
}, [forceCreate])
useEffect(() => {
try {
@ -103,8 +125,40 @@ export default function BrainOnboardingPage() {
} catch {}
}, [mode, about, sources, team])
const navTrigger = useRef<"user" | "auto">("auto")
const startedRef = useRef(false)
useEffect(() => {
if (startedRef.current) return
startedRef.current = true
analytics.onboardingStarted({
mode: detectedMode,
entry_step: initialStep,
})
analytics.onboardingStepViewed({
step: initialStep,
index: BRAIN_STEPS.indexOf(initialStep),
trigger: "auto",
})
}, [detectedMode, initialStep])
const firstStepRender = useRef(true)
useEffect(() => {
// Skip the mount run — the gated effect above fires the initial view.
if (firstStepRender.current) {
firstStepRender.current = false
return
}
analytics.onboardingStepViewed({
step,
index: BRAIN_STEPS.indexOf(step),
trigger: navTrigger.current,
})
navTrigger.current = "auto"
}, [step])
const setStepAndUrl = useCallback(
(next: BrainStep) => {
navTrigger.current = "user"
setStep(next)
const url = new URL(window.location.href)
url.searchParams.set("step", next)
@ -127,37 +181,67 @@ export default function BrainOnboardingPage() {
return plan === "scale" || plan === "scale_yearly"
}, [org])
// Personal onboarding has no team step — drop it from the flow + stepper.
const steps = useMemo<BrainStep[]>(
() =>
mode === "team" ? BRAIN_STEPS : BRAIN_STEPS.filter((s) => s !== "team"),
[mode],
)
const finish = useCallback(async () => {
analytics.onboardingCompleted({
mode,
steps_completed: BRAIN_STEPS.length,
sources_connected: Object.values(sources.connected).filter(
countsAsConnectedSource,
).length,
invites_sent: team.invites.filter((i) => i.email.trim()).length,
})
try {
localStorage.removeItem(STORAGE_KEY)
} catch {}
// Extra org from settings: hard-reload so org-scoped caches don't show the previous org's data.
if (forceCreate) {
window.location.href = "/?onboarded=1"
return
}
router.push("/?onboarded=1")
}, [router])
}, [router, mode, sources, team, forceCreate])
const goNext = useCallback(() => {
const idx = BRAIN_STEPS.indexOf(step)
const next = BRAIN_STEPS[idx + 1]
const idx = steps.indexOf(step)
analytics.onboardingStepCompleted({ step, index: idx })
const next = steps[idx + 1]
if (!next) {
finish()
return
}
setStepAndUrl(next)
}, [step, setStepAndUrl, finish])
}, [step, steps, setStepAndUrl, finish])
// If the current step isn't valid for the mode (e.g. switched to personal),
// fall back to the last valid step.
useEffect(() => {
if (!steps.includes(step)) {
setStepAndUrl(steps[steps.length - 1] ?? "about")
}
}, [steps, step, setStepAndUrl])
const [creatingOrg, setCreatingOrg] = useState(false)
const creatingOrgRef = useRef(false)
const ensureOrg = useCallback(async () => {
if (organizations && organizations.length > 0) return
if (!forceCreate && organizations && organizations.length > 0) return
const name = (about.workspaceName || suggestedWorkspaceName).trim()
const slug = generateOrgSlug(name)
const effectiveMode = allowTeam ? mode : "personal"
const metadata: BrainMetadata & { signupSource: string } = {
signupSource: "consumer",
brainOnboardingVersion: "v1",
brainMode: mode,
brainMode: effectiveMode,
brainWorkspaceName: name,
brainWorkspaceDomain:
mode === "team" ? about.workspaceDomain || domain : null,
effectiveMode === "team" ? about.workspaceDomain || domain : null,
brainContainerTag: containerTag,
...(about.about.trim() ? { brainAbout: about.about.trim() } : {}),
}
@ -166,7 +250,12 @@ export default function BrainOnboardingPage() {
slug,
metadata,
})
await setActiveOrg(result.data?.slug ?? slug)
if (result.error || !result.data?.slug) {
throw new Error(
getErrorMessage(result.error, "Organization was not created."),
)
}
await setActiveOrg(result.data.slug)
if (about.name.trim()) {
await authClient.updateUser({
name: about.name.trim(),
@ -175,15 +264,30 @@ export default function BrainOnboardingPage() {
})
}
await refetchOrganizations()
analytics.onboardingWorkspaceCreated({
mode,
has_about: Boolean(about.about.trim()),
has_domain: Boolean(mode === "team" && (about.workspaceDomain || domain)),
})
// Drop new=1 so a reload or back+Continue reuses this org instead of creating a duplicate.
if (forceCreate) {
const url = new URL(window.location.href)
url.searchParams.delete("new")
url.searchParams.delete("name")
router.replace(url.pathname + url.search, { scroll: false })
}
}, [
organizations,
about,
suggestedWorkspaceName,
mode,
allowTeam,
domain,
containerTag,
setActiveOrg,
refetchOrganizations,
forceCreate,
router,
])
const handleAboutContinue = useCallback(async () => {
@ -194,13 +298,22 @@ export default function BrainOnboardingPage() {
await ensureOrg()
goNext()
} catch (e) {
const message = getErrorMessage(e, "Organization was not created.")
console.error("Failed to create organization:", e)
toast.error("Couldn't create your workspace. Please try again.")
analytics.onboardingWorkspaceCreateFailed({
error: message,
})
toast.error("Organization was not created", {
description: "Please try again from Settings.",
})
if (forceCreate && (organizations?.length ?? 0) > 0) {
router.replace("/")
}
} finally {
creatingOrgRef.current = false
setCreatingOrg(false)
}
}, [ensureOrg, goNext])
}, [ensureOrg, goNext, forceCreate, organizations, router])
const [sendingInvites, setSendingInvites] = useState(false)
const sendingInvitesRef = useRef(false)
@ -209,6 +322,7 @@ export default function BrainOnboardingPage() {
if (sendingInvitesRef.current) return
const pending = team.invites.filter((i) => i.email.trim())
if (pending.length === 0) {
analytics.onboardingTeamSkipped()
goNext()
return
}
@ -231,6 +345,10 @@ export default function BrainOnboardingPage() {
r.status === "rejected" ||
(r.status === "fulfilled" && Boolean(r.value?.error)),
).length
analytics.onboardingInvitesSent({
sent: pending.length - failed,
failed,
})
if (failed > 0) {
toast.error(
`${failed} of ${pending.length} invite${pending.length === 1 ? "" : "s"} couldn't be sent.`,
@ -255,12 +373,17 @@ export default function BrainOnboardingPage() {
return (
<BrainShell
step={step}
steps={steps}
domain={mode === "team" ? about.workspaceDomain || domain : null}
>
{step === "about" && (
<StepAbout
mode={mode}
onModeChange={setMode}
onModeChange={(m) => {
analytics.onboardingModeSelected({ mode: m })
setMode(m)
}}
allowTeam={allowTeam}
domain={domain}
suggestedWorkspaceName={suggestedWorkspaceName}
defaultName={user?.name ?? ""}
@ -281,7 +404,13 @@ export default function BrainOnboardingPage() {
onContinue={goNext}
/>
)}
{step === "ingest" && <StepIngest mcpUrl={mcpUrl} onContinue={goNext} />}
{step === "ingest" && (
<StepIngest
mode={allowTeam ? mode : "personal"}
mcpUrl={mcpUrl}
onContinue={goNext}
/>
)}
{step === "team" && (
<StepTeam
mode={mode}
@ -290,7 +419,10 @@ export default function BrainOnboardingPage() {
values={team}
onChange={setTeam}
onContinue={handleTeamContinue}
onSkip={goNext}
onSkip={() => {
analytics.onboardingTeamSkipped()
goNext()
}}
submitting={sendingInvites}
onUpgrade={() => router.push("/settings/billing")}
/>

View file

@ -1,850 +1,5 @@
"use client"
import { AppExperience } from "@/components/app-experience"
import {
useState,
useCallback,
useEffect,
useMemo,
useRef,
useSyncExternalStore,
} from "react"
import { AnimatePresence, motion } from "motion/react"
import { useQueryState } from "nuqs"
import { Header, PublicHeader } from "@/components/header"
import { MobileBottomNav } from "@/components/bottom-nav"
import { ChatSidebar, HomeChatComposer } from "@/components/chat"
import type { ChatAttachmentDraft } from "@/components/chat/attachments"
import { DashboardView } from "@/components/dashboard-view"
import { MemoriesGrid } from "@/components/memories-grid"
import { GraphLayoutView } from "@/components/graph-layout-view"
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 { OnboardingConfetti } from "@/components/onboarding-brain/onboarding-confetti"
import { AddDocumentModal } from "@/components/add-document"
import { DocumentModal } from "@/components/document-modal"
import { DocumentsCommandPalette } from "@/components/documents-command-palette"
import { FullscreenNoteModal } from "@/components/fullscreen-note-modal"
import type { HighlightItem } from "@/components/highlights-card"
import { DigestsView } from "@/components/digests-view"
import { HotkeysProvider } from "react-hotkeys-hook"
import { useHotkeys } from "react-hotkeys-hook"
import { useIsMobile } from "@hooks/use-mobile"
import { useAuth } from "@lib/auth-context"
import { useProject } from "@/stores"
import { useContainerTags } from "@/hooks/use-container-tags"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import {
useQuickNoteDraftReset,
useQuickNoteDraft,
} from "@/stores/quick-note-draft"
import { analytics } from "@/lib/analytics"
import type { ModelId, ReasoningEffort } 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 {
addDocumentParam,
searchParam,
qParam,
docParam,
fullscreenParam,
threadParam,
type IntegrationParamValue,
} from "@/lib/search-params"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
import { getToolDocumentSpace } from "@/lib/plugin-space"
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">
<p className="text-muted-foreground">
Something went wrong.{" "}
<button
type="button"
className="underline cursor-pointer"
onClick={() => window.location.reload()}
>
Reload
</button>
</p>
</div>
)
}
export default function NewPage() {
const isMobile = useIsMobile()
const { user, session, isSessionPending } = useAuth()
const { selectedProject, selectedProjects, setSelectedProject } = 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(() => {
const url = new URL(window.location.href)
if (!url.searchParams.get("extension-auth-success")) return
const sessionToken = session?.token
const userData = { email: user?.email, name: user?.name, userId: user?.id }
if (sessionToken && userData.email) {
window.postMessage(
{ token: encodeURIComponent(sessionToken), userData },
window.location.origin,
)
url.searchParams.delete("extension-auth-success")
window.history.replaceState({}, "", url.toString())
}
}, [user, session])
// URL-driven modal states
const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam)
const [isSearchOpen, setIsSearchOpen] = useQueryState("search", searchParam)
const [searchPrefill, setSearchPrefill] = useQueryState("q", qParam)
const [docId, setDocId] = useQueryState("doc", docParam)
const [isFullscreen, setIsFullscreen] = useQueryState(
"fullscreen",
fullscreenParam,
)
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 [queuedChatReasoningEffort, setQueuedChatReasoningEffort] =
useState<ReasoningEffort | null>(null)
const [queuedChatProject, setQueuedChatProject] = useState<string | null>(
null,
)
const [queuedChatAttachments, setQueuedChatAttachments] = useState<
ChatAttachmentDraft[] | null
>(null)
const [queuedHighlightContent, setQueuedHighlightContent] = useState<
string | null
>(null)
const [queuedMessageSource, setQueuedMessageSource] = useState<
"highlight" | "home"
>("highlight")
const [selectedDocument, setSelectedDocument] =
useState<DocumentWithMemories | null>(null)
// Clear document when docId is removed (e.g. back button)
useEffect(() => {
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
const tryResolve = () => {
const queries = queryClient.getQueriesData<{
pages: DocumentsResponse[]
}>({ queryKey: ["documents-with-memories"] })
for (const [, data] of queries) {
if (!data?.pages) continue
for (const page of data.pages) {
const doc = page.documents?.find((d) => d.id === docId)
if (doc) {
setSelectedDocument(doc)
return true
}
}
}
return false
}
if (tryResolve()) return
const unsubscribe = queryClient.getQueryCache().subscribe(() => {
if (tryResolve()) unsubscribe()
})
return unsubscribe
}, [docId, selectedDocument, queryClient])
const resetDraft = useQuickNoteDraftReset(selectedProject)
const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "")
const quickNoteDraftRef = useRef(quickNoteDraft)
quickNoteDraftRef.current = quickNoteDraft
const { noteMutation, bulkDeleteMutation } = useDocumentMutations({
onClose: () => {
resetDraft()
setIsFullscreen(false)
},
})
const [selectedDocumentIds, setSelectedDocumentIds] = useState<Set<string>>(
new Set(),
)
const [isSelectionMode, setIsSelectionMode] = useState(false)
const handleToggleSelection = useCallback((documentId: string) => {
setSelectedDocumentIds((prev) => {
const next = new Set(prev)
if (next.has(documentId)) {
next.delete(documentId)
} else {
next.add(documentId)
}
return next
})
}, [])
const handleClearSelection = useCallback(() => {
setSelectedDocumentIds(new Set())
setIsSelectionMode(false)
}, [])
const handleEnterSelectionMode = useCallback(() => {
setIsSelectionMode(true)
}, [])
const handleSelectAllVisible = useCallback((visibleIds: string[]) => {
setSelectedDocumentIds((prev) => {
const next = new Set(prev)
for (const id of visibleIds) {
next.add(id)
}
return next
})
}, [])
const handleBulkDelete = useCallback(() => {
const ids = Array.from(selectedDocumentIds)
if (ids.length === 0) return
bulkDeleteMutation.mutate(
{ documentIds: ids },
{
onSuccess: () => {
setSelectedDocumentIds(new Set())
setIsSelectionMode(false)
if (selectedDocument && ids.includes(selectedDocument.id ?? "")) {
setDocId(null)
}
},
},
)
}, [selectedDocumentIds, bulkDeleteMutation, selectedDocument, setDocId])
type SpaceHighlightsResponse = {
highlights: HighlightItem[]
questions: string[]
generatedAt: string
}
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, 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}`
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()
}
}
}
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
spaceId,
highlightsCount: 3,
questionsCount: 4,
includeHighlights: true,
includeQuestions: true,
forceRefresh,
}),
},
)
if (!response.ok) {
throw new Error("Failed to fetch space highlights")
}
const data = await response.json()
// 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
},
staleTime: HIGHLIGHTS_MAX_AGE,
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:v2:${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")
})
useHotkeys("mod+k", (e) => {
e.preventDefault()
analytics.searchOpened({ source: "hotkey" })
setIsSearchOpen(true)
})
const handleOpenDocument = useCallback(
(document: DocumentWithMemories) => {
if (document.id) {
analytics.documentModalOpened({ document_id: document.id })
setSelectedDocument(document)
setDocId(document.id)
}
},
[setDocId],
)
const handleOpenToolDocument = useCallback(
(document: DocumentWithMemories, pluginClientId: string) => {
const documentSpace = getToolDocumentSpace(document, pluginClientId)
if (documentSpace) {
setSelectedProject(documentSpace)
}
handleOpenDocument(document)
void setViewMode("list")
},
[handleOpenDocument, setSelectedProject, setViewMode],
)
// 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 = quickNoteDraftRef.current.trim().length > 0
noteMutation.mutate(
{ content, project: selectedProject },
{
onSuccess: () => {
if (hadPreviousContent) {
analytics.quickNoteEdited()
} else {
analytics.quickNoteCreated()
}
},
},
)
}
},
[selectedProject, noteMutation],
)
const handleFullScreenSave = useCallback(
(content: string) => {
if (content.trim()) {
const hadInitialContent = fullscreenInitialContent.trim().length > 0
noteMutation.mutate(
{ content, project: selectedProject },
{
onSuccess: () => {
if (hadInitialContent) {
analytics.quickNoteEdited()
} else {
analytics.quickNoteCreated()
}
},
},
)
}
},
[selectedProject, noteMutation, fullscreenInitialContent],
)
const handleMaximize = useCallback(
(content: string) => {
analytics.fullscreenNoteModalOpened()
setFullscreenInitialContent(content)
setIsFullscreen(true)
},
[setIsFullscreen],
)
const handleHighlightsChat = useCallback(
(highlightContent: string, userReply: string) => {
setQueuedHighlightContent(highlightContent)
setQueuedChatSeed(userReply)
setQueuedChatModel(null)
setQueuedChatReasoningEffort(null)
setQueuedChatProject(null)
setQueuedChatAttachments(null)
setQueuedMessageSource("highlight")
void setViewMode("chat")
},
[setViewMode],
)
const handleHomeChatStart = useCallback(
(
message: string,
model: ModelId,
projectId: string,
reasoningEffort: ReasoningEffort,
attachments?: ChatAttachmentDraft[],
) => {
setQueuedHighlightContent(null)
setQueuedChatSeed(message)
setQueuedChatModel(model)
setQueuedChatReasoningEffort(reasoningEffort)
setQueuedChatProject(projectId)
setQueuedChatAttachments(attachments ?? null)
setQueuedMessageSource("home")
void setViewMode("chat")
},
[setViewMode],
)
const consumeQueuedChat = useCallback(() => {
setQueuedChatSeed(null)
setQueuedChatModel(null)
setQueuedChatReasoningEffort(null)
setQueuedChatProject(null)
setQueuedChatAttachments(null)
setQueuedHighlightContent(null)
setQueuedMessageSource("highlight")
}, [])
const handleHighlightsShowRelated = useCallback(
(query: string) => {
analytics.searchOpened({ source: "highlight_related" })
setSearchPrefill(query)
setIsSearchOpen(true)
},
[setSearchPrefill, setIsSearchOpen],
)
const handleOpenIntegrations = useCallback(
(integration?: IntegrationParamValue) => {
if (integration === "notion" || integration === "google-drive") {
void setAddDoc("connect")
return
}
void setViewMode(integration ?? "integrations")
},
[setViewMode, setAddDoc],
)
const handleOpenPlugins = useCallback(() => {
void setViewMode("plugins")
}, [setViewMode])
const handleAddMemory = useCallback(
(tab: "note" | "link") => {
analytics.addDocumentModalOpened()
setAddDoc(tab)
},
[setAddDoc],
)
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" ||
viewMode === "digests"
const isDashboardShell =
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
const isGraphMode = viewMode === "graph"
const showBottomNav = isMobile && !!session && !isChatView
const isPublicIntegrations =
!session && !isSessionPending && viewMode === "integrations"
return (
<HotkeysProvider>
<OnboardingConfetti />
<div
className={cn(
"relative flex min-h-dvh flex-col bg-[#05080D]",
(isGraphMode || isChatView || viewMode === "digests") &&
"h-dvh overflow-hidden",
showBottomNav &&
!isGraphMode &&
"pb-[calc(4rem+env(safe-area-inset-bottom))]",
)}
>
{showNovaBackdrop && (
<div className="pointer-events-none fixed inset-0 z-0">
<AnimatedGradientBackground
animateFromBottom={false}
topPosition={gradientTopPosition}
/>
<div className="absolute inset-0 bg-[#05080D]/50" aria-hidden />
<div
id="graph-dotted-grid"
className="absolute inset-0 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>
)}
{isPublicIntegrations ? (
<PublicHeader variant="integrations" />
) : !session && viewMode === "mcp" ? (
<PublicHeader />
) : (
<Header
onAddMemory={() => {
analytics.addDocumentModalOpened()
setAddDoc("note")
}}
onOpenSearch={() => {
analytics.searchOpened({ source: "header" })
setIsSearchOpen(true)
}}
/>
)}
<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 || viewMode === "digests") &&
"overflow-hidden",
)}
>
<div
className={cn(
"relative z-10 flex min-h-0 flex-1 flex-col md:flex-row",
)}
>
<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}
queuedHighlightContent={queuedHighlightContent}
onConsumeQueuedMessage={consumeQueuedChat}
queuedMessageSource={queuedMessageSource}
queuedAttachments={queuedChatAttachments}
initialSelectedModel={queuedChatModel}
initialReasoningEffort={queuedChatReasoningEffort}
initialChatProject={queuedChatProject}
/>
</div>
) : viewMode === "integrations" ? (
<div className="min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0">
<IntegrationsView
publicMode={isPublicIntegrations}
onOpenDocument={handleOpenDocument}
/>
</div>
) : viewMode === "mcp" ? (
<MCPDetailView
onBack={() => void setViewMode("integrations")}
/>
) : 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 === "digests" ? (
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto lg:overflow-hidden">
<DigestsView />
</div>
) : viewMode === "graph" ? (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<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}
onOpenToolDocument={handleOpenToolDocument}
onHighlightsChat={handleHighlightsChat}
onHighlightsShowRelated={handleHighlightsShowRelated}
onResetHighlights={handleResetHighlights}
onOpenDigests={() => void setViewMode("digests")}
memoryOfDay={memoryOfDay}
/>
)}
</ErrorBoundary>
</div>
</motion.main>
</AnimatePresence>
{isDashboardShell && showBottomNav && (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-20 h-64 bg-gradient-to-t from-[#05080D] via-[#05080D]/95 to-transparent" />
)}
{isDashboardShell && (
<div
className={cn(
"pointer-events-none fixed inset-x-0 z-30",
showBottomNav
? "bottom-[calc(4rem+env(safe-area-inset-bottom))]"
: "bottom-0 bg-gradient-to-t from-black via-black/40 to-transparent pt-12",
)}
>
<div className="pointer-events-auto">
<HomeChatComposer onStartChat={handleHomeChatStart} />
</div>
</div>
)}
{showBottomNav && (
<MobileBottomNav
onAddMemory={() => {
analytics.addDocumentModalOpened()
setAddDoc("note")
}}
onOpenSearch={() => {
analytics.searchOpened({ source: "header" })
setIsSearchOpen(true)
}}
/>
)}
<AddDocumentModal
isOpen={addDoc !== null}
onClose={() => setAddDoc(null)}
/>
<DocumentsCommandPalette
open={isSearchOpen}
onOpenChange={(open) => {
setIsSearchOpen(open)
if (!open) setSearchPrefill("")
}}
projectId={selectedProject}
onOpenDocument={handleOpenDocument}
onAddMemory={() => {
analytics.addDocumentModalOpened()
setAddDoc("note")
}}
onOpenIntegrations={() => setViewMode("integrations")}
initialSearch={searchPrefill}
/>
<DocumentModal
document={selectedDocument}
isOpen={docId !== null}
onClose={() => setDocId(null)}
/>
<FullscreenNoteModal
isOpen={isFullscreen}
onClose={() => setIsFullscreen(false)}
initialContent={fullscreenInitialContent}
onSave={handleFullScreenSave}
isSaving={noteMutation.isPending}
/>
</div>
</HotkeysProvider>
)
export default function Page() {
return <AppExperience />
}

View file

@ -1,17 +1,5 @@
"use client"
import { useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { redirect } from "next/navigation"
export default function SettingsIntegrationsPage() {
const router = useRouter()
const searchParams = useSearchParams()
useEffect(() => {
const params = new URLSearchParams(searchParams.toString())
params.set("view", "integrations")
router.replace(`/?${params.toString()}`)
}, [router, searchParams])
return null
redirect("/integrations")
}

View file

@ -15,7 +15,7 @@ export default function SettingsRedirect() {
const hash = typeof window !== "undefined" ? window.location.hash : ""
const tab = parseHashToTab(hash)
router.replace(
tab === "integrations" ? "/?view=integrations" : `/?settings=${tab}`,
tab === "integrations" ? "/integrations" : `/?settings=${tab}`,
)
}, [router])

View file

@ -0,0 +1,388 @@
"use client"
import { authClient, useSession } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import { Loader, Users, XCircle } from "lucide-react"
import { useParams, useRouter } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { dmSans125ClassName } from "@/lib/fonts"
type InvitationData = {
id: string
email: string
role: string
status: string
expiresAt: string
organizationName: string
organizationSlug: string
organizationId: string
inviterEmail?: string
}
type InviteState =
| "loading"
| "no_session"
| "ready"
| "not_found"
| "expired"
| "already_accepted"
| "wrong_account"
const pageWrapperClass =
"flex items-center justify-center min-h-screen bg-background p-4"
const cardClass = cn(
"bg-[#14161A] rounded-[14px] p-6 w-full max-w-[400px]",
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
)
function FullPageSpinner() {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="size-6 border-2 border-[#4BA0FA] border-t-transparent rounded-full animate-spin" />
</div>
)
}
function PrimaryButton({
children,
onClick,
disabled,
}: {
children: React.ReactNode
onClick: () => void
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"relative w-full h-11 rounded-[10px] flex items-center justify-center",
"text-[#FAFAFA] font-medium text-[14px] tracking-[-0.14px]",
"cursor-pointer transition-opacity hover:opacity-90",
"disabled:opacity-60 disabled:cursor-not-allowed",
dmSans125ClassName(),
)}
style={{
background:
"linear-gradient(182.37deg, #0ff0d2 -91.53%, #5bd3fb -67.8%, #1e0ff0 95.17%)",
boxShadow:
"1px 1px 2px 0px #1A88FF inset, 0 2px 10px 0 rgba(5, 1, 0, 0.20)",
}}
>
{children}
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_2px_1px_#1A88FF]" />
</button>
)
}
function SecondaryButton({
children,
onClick,
disabled,
}: {
children: React.ReactNode
onClick: () => void
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"w-full flex items-center justify-center gap-2 rounded-full h-10 px-4",
"bg-[#0D121A] border border-[#1E293B] text-[#FAFAFA]",
"text-[13px] font-medium cursor-pointer transition-colors hover:bg-[#1E293B]",
"disabled:opacity-60 disabled:cursor-not-allowed",
dmSans125ClassName(),
)}
>
{children}
</button>
)
}
function IconTile({ children }: { children: React.ReactNode }) {
return (
<div className="flex size-10 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
{children}
</div>
)
}
function Title({ children }: { children: React.ReactNode }) {
return (
<h2
className={dmSans125ClassName("font-semibold text-[18px] text-[#FAFAFA]")}
>
{children}
</h2>
)
}
function Subtitle({ children }: { children: React.ReactNode }) {
return (
<p className={dmSans125ClassName("text-[13px] text-[#737373] mt-1")}>
{children}
</p>
)
}
function StatusCard({
icon,
title,
description,
actionLabel,
onAction,
}: {
icon: React.ReactNode
title: string
description: string
actionLabel: string
onAction: () => void
}) {
return (
<div className={pageWrapperClass}>
<div className={cardClass}>
<div className="flex flex-col items-center gap-5">
<IconTile>{icon}</IconTile>
<div className="text-center">
<Title>{title}</Title>
<Subtitle>{description}</Subtitle>
</div>
<SecondaryButton onClick={onAction}>{actionLabel}</SecondaryButton>
</div>
</div>
</div>
)
}
export default function InvitePage() {
const params = useParams<{ invitationId: string }>()
const invitationId = params.invitationId
const { data: session, isPending: sessionPending } = useSession()
const { setActiveOrg, refetchOrganizations } = useAuth()
const router = useRouter()
const [state, setState] = useState<InviteState>("loading")
const [invitation, setInvitation] = useState<InvitationData | null>(null)
const [accepting, setAccepting] = useState(false)
const [declining, setDeclining] = useState(false)
useEffect(() => {
if (sessionPending) return
if (!session) {
setState("no_session")
return
}
let cancelled = false
;(async () => {
const { data, error } = await authClient.organization.getInvitation({
query: { id: invitationId },
})
if (cancelled) return
if (error) {
setState(error.status === 403 ? "wrong_account" : "not_found")
return
}
if (!data) {
setState("not_found")
return
}
const inv = data as unknown as InvitationData
if (inv.status === "accepted") setState("already_accepted")
else if (inv.status === "canceled" || inv.status === "rejected")
setState("not_found")
else if (new Date(inv.expiresAt) < new Date()) setState("expired")
else {
setInvitation(inv)
setState("ready")
}
})()
return () => {
cancelled = true
}
}, [session, sessionPending, invitationId])
const handleAccept = useCallback(async () => {
setAccepting(true)
try {
const { error } = await authClient.organization.acceptInvitation({
invitationId,
})
if (error) {
toast.error(error.message ?? "Failed to accept invitation")
return
}
if (invitation?.organizationSlug) {
await setActiveOrg(invitation.organizationSlug)
}
await refetchOrganizations()
toast.success(
`You've joined ${invitation?.organizationName ?? "the team"}`,
)
router.push("/")
} finally {
setAccepting(false)
}
}, [invitationId, invitation, setActiveOrg, refetchOrganizations, router])
const handleDecline = useCallback(async () => {
setDeclining(true)
try {
const { error } = await authClient.organization.rejectInvitation({
invitationId,
})
if (error) {
toast.error(error.message ?? "Failed to decline invitation")
return
}
toast.success("Invitation declined")
router.push("/")
} finally {
setDeclining(false)
}
}, [invitationId, router])
if (state === "loading") return <FullPageSpinner />
if (state === "no_session") {
const loginHref = `/login?redirect=${encodeURIComponent(
typeof window !== "undefined" ? window.location.href : "",
)}`
return (
<div className={pageWrapperClass}>
<div className={cardClass}>
<div className="flex flex-col items-center gap-5">
<IconTile>
<Users className="size-5 text-[#4BA0FA]" />
</IconTile>
<div className="text-center">
<Title>You're not logged in</Title>
<Subtitle>Log in to view and accept this invitation.</Subtitle>
</div>
<PrimaryButton onClick={() => router.push(loginHref)}>
Log in
</PrimaryButton>
</div>
</div>
</div>
)
}
if (state === "wrong_account") {
return (
<StatusCard
icon={<XCircle className="size-5 text-red-400" />}
title="This invitation isn't for you"
description={`It was sent to a different email${
session?.user?.email ? ` than ${session.user.email}` : ""
}.`}
actionLabel="Go to dashboard"
onAction={() => router.push("/")}
/>
)
}
if (
state === "not_found" ||
state === "expired" ||
state === "already_accepted"
) {
const copy = {
not_found: {
title: "Invitation not found",
body: "This invitation doesn't exist or has been revoked.",
},
expired: {
title: "Invitation expired",
body: "Ask your team admin to send a new one.",
},
already_accepted: {
title: "Already joined",
body: "You've already accepted this invitation.",
},
}[state]
return (
<StatusCard
icon={<Users className="size-5 text-[#4BA0FA]" />}
title={copy.title}
description={copy.body}
actionLabel="Go to dashboard"
onAction={() => router.push("/")}
/>
)
}
return (
<div className={pageWrapperClass}>
<div className={cardClass}>
<div className="flex flex-col items-center gap-5">
<IconTile>
<Users className="size-5 text-[#4BA0FA]" />
</IconTile>
<div className="text-center">
<Title>{invitation?.organizationName}</Title>
<Subtitle>
You've been invited to join{" "}
<strong className="text-[#A3A3A3]">
{invitation?.organizationName}
</strong>{" "}
as {invitation?.role}.
</Subtitle>
{invitation?.inviterEmail && (
<p
className={dmSans125ClassName(
"text-[12px] text-[#737373] mt-2",
)}
>
Invited by {invitation.inviterEmail}
</p>
)}
{session?.user?.email && (
<p
className={dmSans125ClassName(
"text-[12px] text-[#737373] mt-1",
)}
>
Signed in as {session.user.email}
</p>
)}
</div>
<div className="flex w-full flex-col gap-2.5">
<PrimaryButton
onClick={handleAccept}
disabled={accepting || declining}
>
{accepting ? (
<>
<Loader className="size-4 animate-spin mr-2" />
Accepting
</>
) : (
"Accept invitation"
)}
</PrimaryButton>
<SecondaryButton
onClick={handleDecline}
disabled={declining || accepting}
>
{declining ? (
<>
<Loader className="size-4 animate-spin" />
Declining
</>
) : (
"Decline"
)}
</SecondaryButton>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,879 @@
"use client"
import {
useState,
useCallback,
useEffect,
useMemo,
useRef,
useSyncExternalStore,
} from "react"
import { AnimatePresence, motion } from "motion/react"
import { useQueryState } from "nuqs"
import { Header, PublicHeader } from "@/components/header"
import { MobileBottomNav } from "@/components/bottom-nav"
import { ChatSidebar, HomeChatComposer } from "@/components/chat"
import type { ChatAttachmentDraft } from "@/components/chat/attachments"
import { DashboardView } from "@/components/dashboard-view"
import { BrainHomeView } from "@/components/brain-home/brain-home-view"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { MemoriesGrid } from "@/components/memories-grid"
import { GraphLayoutView } from "@/components/graph-layout-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 { OnboardingConfetti } from "@/components/onboarding-brain/onboarding-confetti"
import { AddDocumentModal } from "@/components/add-document"
import { DocumentModal } from "@/components/document-modal"
import { DocumentsCommandPalette } from "@/components/documents-command-palette"
import { FullscreenNoteModal } from "@/components/fullscreen-note-modal"
import type { HighlightItem } from "@/components/highlights-card"
import { DigestsView } from "@/components/digests-view"
import { HotkeysProvider } from "react-hotkeys-hook"
import { useHotkeys } from "react-hotkeys-hook"
import { useIsMobile } from "@hooks/use-mobile"
import { useAuth } from "@lib/auth-context"
import { useProject } from "@/stores"
import { useContainerTags } from "@/hooks/use-container-tags"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import {
useQuickNoteDraftReset,
useQuickNoteDraft,
} from "@/stores/quick-note-draft"
import { analytics } from "@/lib/analytics"
import type { ModelId, ReasoningEffort } 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, useLegacyViewRedirect } from "@/lib/view-mode-context"
import type { MemoryOfDay } from "@/components/dashboard-view"
import { ErrorBoundary } from "@/components/error-boundary"
import { cn } from "@lib/utils"
import {
addDocumentParam,
searchParam,
qParam,
docParam,
fullscreenParam,
threadParam,
type IntegrationParamValue,
} from "@/lib/search-params"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
import { getToolDocumentSpace } from "@/lib/plugin-space"
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">
<p className="text-muted-foreground">
Something went wrong.{" "}
<button
type="button"
className="underline cursor-pointer"
onClick={() => window.location.reload()}
>
Reload
</button>
</p>
</div>
)
}
export function AppExperience() {
const isMobile = useIsMobile()
const { user, session, isSessionPending, org } = useAuth()
const { selectedProject, selectedProjects, setSelectedProject } = 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()
useLegacyViewRedirect()
const isCompanyBrain = useHasCompanyBrain()
// Slack OAuth redirects back here with ?slack=connected — toast then clean up.
useEffect(() => {
const sp = new URLSearchParams(window.location.search)
if (sp.get("slack") !== "connected") return
const team = sp.get("team")
toast.success(
team
? `Supermemory added to ${team} on Slack`
: "Supermemory added to your Slack",
)
sp.delete("slack")
sp.delete("team")
const qs = sp.toString()
window.history.replaceState(
null,
"",
window.location.pathname + (qs ? `?${qs}` : ""),
)
}, [])
const queryClient = useQueryClient()
const [highlightsForceAt, setHighlightsForceAt] = useState(0)
// Chrome extension auth: send session token via postMessage so the content script can store it
useEffect(() => {
const url = new URL(window.location.href)
if (!url.searchParams.get("extension-auth-success")) return
const sessionToken = session?.token
const userData = { email: user?.email, name: user?.name, userId: user?.id }
if (sessionToken && userData.email) {
window.postMessage(
{ token: encodeURIComponent(sessionToken), userData },
window.location.origin,
)
url.searchParams.delete("extension-auth-success")
window.history.replaceState({}, "", url.toString())
}
}, [user, session])
// URL-driven modal states
const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam)
const [isSearchOpen, setIsSearchOpen] = useQueryState("search", searchParam)
const [searchPrefill, setSearchPrefill] = useQueryState("q", qParam)
const [docId, setDocId] = useQueryState("doc", docParam)
const [isFullscreen, setIsFullscreen] = useQueryState(
"fullscreen",
fullscreenParam,
)
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 [queuedChatReasoningEffort, setQueuedChatReasoningEffort] =
useState<ReasoningEffort | null>(null)
const [queuedChatProject, setQueuedChatProject] = useState<string | null>(
null,
)
const [queuedChatAttachments, setQueuedChatAttachments] = useState<
ChatAttachmentDraft[] | null
>(null)
const [queuedHighlightContent, setQueuedHighlightContent] = useState<
string | null
>(null)
const [queuedMessageSource, setQueuedMessageSource] = useState<
"highlight" | "home"
>("highlight")
const [selectedDocument, setSelectedDocument] =
useState<DocumentWithMemories | null>(null)
// Clear document when docId is removed (e.g. back button)
useEffect(() => {
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
const tryResolve = () => {
const queries = queryClient.getQueriesData<{
pages: DocumentsResponse[]
}>({ queryKey: ["documents-with-memories"] })
for (const [, data] of queries) {
if (!data?.pages) continue
for (const page of data.pages) {
const doc = page.documents?.find((d) => d.id === docId)
if (doc) {
setSelectedDocument(doc)
return true
}
}
}
return false
}
if (tryResolve()) return
const unsubscribe = queryClient.getQueryCache().subscribe(() => {
if (tryResolve()) unsubscribe()
})
return unsubscribe
}, [docId, selectedDocument, queryClient])
const resetDraft = useQuickNoteDraftReset(selectedProject)
const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "")
const quickNoteDraftRef = useRef(quickNoteDraft)
quickNoteDraftRef.current = quickNoteDraft
const { noteMutation, bulkDeleteMutation } = useDocumentMutations({
onClose: () => {
resetDraft()
setIsFullscreen(false)
},
})
const [selectedDocumentIds, setSelectedDocumentIds] = useState<Set<string>>(
new Set(),
)
const [isSelectionMode, setIsSelectionMode] = useState(false)
const handleToggleSelection = useCallback((documentId: string) => {
setSelectedDocumentIds((prev) => {
const next = new Set(prev)
if (next.has(documentId)) {
next.delete(documentId)
} else {
next.add(documentId)
}
return next
})
}, [])
const handleClearSelection = useCallback(() => {
setSelectedDocumentIds(new Set())
setIsSelectionMode(false)
}, [])
const handleEnterSelectionMode = useCallback(() => {
setIsSelectionMode(true)
}, [])
const handleSelectAllVisible = useCallback((visibleIds: string[]) => {
setSelectedDocumentIds((prev) => {
const next = new Set(prev)
for (const id of visibleIds) {
next.add(id)
}
return next
})
}, [])
const handleBulkDelete = useCallback(() => {
const ids = Array.from(selectedDocumentIds)
if (ids.length === 0) return
bulkDeleteMutation.mutate(
{ documentIds: ids },
{
onSuccess: () => {
setSelectedDocumentIds(new Set())
setIsSelectionMode(false)
if (selectedDocument && ids.includes(selectedDocument.id ?? "")) {
setDocId(null)
}
},
},
)
}, [selectedDocumentIds, bulkDeleteMutation, selectedDocument, setDocId])
type SpaceHighlightsResponse = {
highlights: HighlightItem[]
questions: string[]
generatedAt: string
}
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, 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}`
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()
}
}
}
const response = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
spaceId,
highlightsCount: 3,
questionsCount: 4,
includeHighlights: true,
includeQuestions: true,
forceRefresh,
}),
},
)
if (!response.ok) {
throw new Error("Failed to fetch space highlights")
}
const data = await response.json()
// 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
},
staleTime: HIGHLIGHTS_MAX_AGE,
refetchOnWindowFocus: false,
})
const { data: memoryOfDay = null } = useQuery<MemoryOfDay | null>({
queryKey: [
"memory-of-day",
user?.id,
org?.id,
new Date().toISOString().slice(0, 10),
],
queryFn: async (): Promise<MemoryOfDay | null> => {
const cacheKey = `memory-of-day:v2:${user?.id}:${org?.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 && !!org,
})
useHotkeys("c", () => {
analytics.addDocumentModalOpened()
setAddDoc("note")
})
useHotkeys("mod+k", (e) => {
e.preventDefault()
analytics.searchOpened({ source: "hotkey" })
setIsSearchOpen(true)
})
const handleOpenDocument = useCallback(
(document: DocumentWithMemories) => {
if (document.id) {
analytics.documentModalOpened({ document_id: document.id })
setSelectedDocument(document)
setDocId(document.id)
}
},
[setDocId],
)
const handleOpenToolDocument = useCallback(
(document: DocumentWithMemories, pluginClientId: string) => {
const documentSpace = getToolDocumentSpace(document, pluginClientId)
if (documentSpace) {
setSelectedProject(documentSpace)
}
handleOpenDocument(document)
void setViewMode("list")
},
[handleOpenDocument, setSelectedProject, setViewMode],
)
// 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 = quickNoteDraftRef.current.trim().length > 0
noteMutation.mutate(
{ content, project: selectedProject },
{
onSuccess: () => {
if (hadPreviousContent) {
analytics.quickNoteEdited()
} else {
analytics.quickNoteCreated()
}
},
},
)
}
},
[selectedProject, noteMutation],
)
const handleFullScreenSave = useCallback(
(content: string) => {
if (content.trim()) {
const hadInitialContent = fullscreenInitialContent.trim().length > 0
noteMutation.mutate(
{ content, project: selectedProject },
{
onSuccess: () => {
if (hadInitialContent) {
analytics.quickNoteEdited()
} else {
analytics.quickNoteCreated()
}
},
},
)
}
},
[selectedProject, noteMutation, fullscreenInitialContent],
)
const handleMaximize = useCallback(
(content: string) => {
analytics.fullscreenNoteModalOpened()
setFullscreenInitialContent(content)
setIsFullscreen(true)
},
[setIsFullscreen],
)
const handleHighlightsChat = useCallback(
(highlightContent: string, userReply: string) => {
setQueuedHighlightContent(highlightContent)
setQueuedChatSeed(userReply)
setQueuedChatModel(null)
setQueuedChatReasoningEffort(null)
setQueuedChatProject(null)
setQueuedChatAttachments(null)
setQueuedMessageSource("highlight")
void setViewMode("chat")
},
[setViewMode],
)
const handleHomeChatStart = useCallback(
(
message: string,
model: ModelId,
projectId: string,
reasoningEffort: ReasoningEffort,
attachments?: ChatAttachmentDraft[],
) => {
setQueuedHighlightContent(null)
setQueuedChatSeed(message)
setQueuedChatModel(model)
setQueuedChatReasoningEffort(reasoningEffort)
setQueuedChatProject(projectId)
setQueuedChatAttachments(attachments ?? null)
setQueuedMessageSource("home")
void setViewMode("chat")
},
[setViewMode],
)
const consumeQueuedChat = useCallback(() => {
setQueuedChatSeed(null)
setQueuedChatModel(null)
setQueuedChatReasoningEffort(null)
setQueuedChatProject(null)
setQueuedChatAttachments(null)
setQueuedHighlightContent(null)
setQueuedMessageSource("highlight")
}, [])
const handleHighlightsShowRelated = useCallback(
(query: string) => {
analytics.searchOpened({ source: "highlight_related" })
setSearchPrefill(query)
setIsSearchOpen(true)
},
[setSearchPrefill, setIsSearchOpen],
)
const handleOpenIntegrations = useCallback(
(integration?: IntegrationParamValue) => {
if (integration === "notion" || integration === "google-drive") {
void setAddDoc("connect")
return
}
void setViewMode(integration ?? "integrations")
},
[setViewMode, setAddDoc],
)
const handleOpenPlugins = useCallback(() => {
void setViewMode("plugins")
}, [setViewMode])
const handleAddMemory = useCallback(
(tab: "note" | "link") => {
analytics.addDocumentModalOpened()
setAddDoc(tab)
},
[setAddDoc],
)
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" ||
viewMode === "digests"
const isDashboardShell =
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
const isGraphMode = viewMode === "graph"
const showBottomNav = isMobile && !!session && !isChatView
const isPublicIntegrations =
!session && !isSessionPending && viewMode === "integrations"
return (
<HotkeysProvider>
<OnboardingConfetti />
<div
className={cn(
"relative flex min-h-dvh flex-col bg-[#05080D]",
(isGraphMode || isChatView || viewMode === "digests") &&
"h-dvh overflow-hidden",
showBottomNav &&
!isGraphMode &&
"pb-[calc(4rem+env(safe-area-inset-bottom))]",
)}
>
{showNovaBackdrop && (
<div className="pointer-events-none fixed inset-0 z-0">
<AnimatedGradientBackground
animateFromBottom={false}
topPosition={gradientTopPosition}
/>
<div className="absolute inset-0 bg-[#05080D]/50" aria-hidden />
<div
id="graph-dotted-grid"
className="absolute inset-0 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>
)}
{isPublicIntegrations ? (
<PublicHeader variant="integrations" />
) : !session && viewMode === "mcp" ? (
<PublicHeader />
) : (
<Header
onAddMemory={() => {
analytics.addDocumentModalOpened()
setAddDoc("note")
}}
onOpenSearch={() => {
analytics.searchOpened({ source: "header" })
setIsSearchOpen(true)
}}
/>
)}
<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 || viewMode === "digests") &&
"overflow-hidden",
)}
>
<div
className={cn(
"relative z-10 flex min-h-0 flex-1 flex-col md:flex-row",
)}
>
<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}
queuedHighlightContent={queuedHighlightContent}
onConsumeQueuedMessage={consumeQueuedChat}
queuedMessageSource={queuedMessageSource}
queuedAttachments={queuedChatAttachments}
initialSelectedModel={queuedChatModel}
initialReasoningEffort={queuedChatReasoningEffort}
initialChatProject={queuedChatProject}
/>
</div>
) : viewMode === "integrations" ? (
<div className="min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0">
<IntegrationsView
publicMode={isPublicIntegrations}
onOpenDocument={handleOpenDocument}
/>
</div>
) : viewMode === "mcp" ? (
<MCPDetailView
onBack={() => void setViewMode("integrations")}
/>
) : 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 === "digests" ? (
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto lg:overflow-hidden">
<DigestsView />
</div>
) : viewMode === "graph" ? (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<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>
) : isCompanyBrain ? (
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto p-4 pt-2! pb-[180px] md:p-6">
<BrainHomeView />
</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}
onOpenToolDocument={handleOpenToolDocument}
onHighlightsChat={handleHighlightsChat}
onHighlightsShowRelated={handleHighlightsShowRelated}
onResetHighlights={handleResetHighlights}
onOpenDigests={() => void setViewMode("digests")}
memoryOfDay={memoryOfDay}
/>
)}
</ErrorBoundary>
</div>
</motion.main>
</AnimatePresence>
{isDashboardShell && showBottomNav && (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-20 h-64 bg-gradient-to-t from-[#05080D] via-[#05080D]/95 to-transparent" />
)}
{isDashboardShell && (
<div
className={cn(
"pointer-events-none fixed inset-x-0 z-30",
showBottomNav
? "bottom-[calc(4rem+env(safe-area-inset-bottom))]"
: "bottom-0 bg-gradient-to-t from-black via-black/40 to-transparent pt-12",
)}
>
<div className="pointer-events-auto">
<HomeChatComposer onStartChat={handleHomeChatStart} />
</div>
</div>
)}
{showBottomNav && (
<MobileBottomNav
onAddMemory={() => {
analytics.addDocumentModalOpened()
setAddDoc("note")
}}
onOpenSearch={() => {
analytics.searchOpened({ source: "header" })
setIsSearchOpen(true)
}}
/>
)}
<AddDocumentModal
isOpen={addDoc !== null}
onClose={() => setAddDoc(null)}
/>
<DocumentsCommandPalette
open={isSearchOpen}
onOpenChange={(open) => {
setIsSearchOpen(open)
if (!open) setSearchPrefill("")
}}
projectId={selectedProject}
onOpenDocument={handleOpenDocument}
onAddMemory={() => {
analytics.addDocumentModalOpened()
setAddDoc("note")
}}
onOpenIntegrations={() => setViewMode("integrations")}
initialSearch={searchPrefill}
/>
<DocumentModal
document={selectedDocument}
isOpen={docId !== null}
onClose={() => setDocId(null)}
/>
<FullscreenNoteModal
isOpen={isFullscreen}
onClose={() => setIsFullscreen(false)}
initialContent={fullscreenInitialContent}
onSave={handleFullScreenSave}
isSaving={noteMutation.isPending}
/>
</div>
</HotkeysProvider>
)
}

View file

@ -0,0 +1,360 @@
"use client"
import { $fetch } from "@lib/api"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import { useQuery } from "@tanstack/react-query"
import { ArrowRight, Check, FileText, Loader2 } from "lucide-react"
import Link from "next/link"
import { dmSans125ClassName } from "@/lib/fonts"
import { ConnectionsBoard } from "./connections-board"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const cardStyle = {
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}
type RecentDoc = {
id?: string
title?: string | null
createdAt?: string | Date | null
}
function useBrainOverview() {
const { user, org } = useAuth()
const enabled = !!user && !!org?.id
const docs = useQuery({
queryKey: ["brain-recents", org?.id],
queryFn: async () => {
const res = await $fetch("@post/documents/documents", {
body: {
page: 1,
limit: 6,
sort: "createdAt",
order: "desc",
containerTags: [],
},
disableValidation: true,
})
if (res.error) throw new Error(res.error?.message)
return res.data as unknown as {
documents?: RecentDoc[]
pagination?: { totalItems?: number }
}
},
staleTime: 60_000,
enabled,
})
const connectors = useQuery({
queryKey: ["brain-home", "connectors"],
queryFn: async () => {
const res = await $fetch("@post/connections/list", {
body: { containerTags: [] },
})
if (res.error) return [] as Array<{ provider?: string }>
return (res.data ?? []) as Array<{ provider?: string }>
},
staleTime: 30_000,
enabled,
})
const brain = useQuery({
queryKey: ["brain-connections"],
queryFn: async () => {
const [c, s] = await Promise.all([
fetch(`${BACKEND}/brain/connections`, { credentials: "include" }),
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
])
const toolkits = c.ok
? ((await c.json()) as { toolkits: { org: boolean; user: boolean }[] })
.toolkits
: []
const slack = s.ok
? ((await s.json()) as { connected: boolean }).connected
: false
return {
activeCount: toolkits.filter((t) => t.org || t.user).length,
slack,
}
},
staleTime: 30_000,
enabled,
})
const mcp = useQuery({
queryKey: ["mcp-status"],
queryFn: async () => {
const res = await $fetch("@get/mcp/has-login")
if (res.error) return false
return Boolean((res.data as { previousLogin?: boolean })?.previousLogin)
},
staleTime: 60_000,
enabled,
})
const memoriesCount = docs.data?.pagination?.totalItems ?? 0
const connectedCount =
(brain.data?.activeCount ?? 0) +
(brain.data?.slack ? 1 : 0) +
(connectors.data?.length ?? 0)
return {
loading: docs.isPending,
recentDocs: docs.data?.documents ?? [],
memoriesCount,
connectedCount,
hasSource: connectedCount > 0,
hasAgent: mcp.data ?? false,
hasMemory: memoriesCount > 0,
}
}
export function BrainHomeView() {
const o = useBrainOverview()
const stepsDone = [o.hasSource, o.hasAgent, o.hasMemory].filter(
Boolean,
).length
return (
<div className="mx-auto max-w-[1080px] space-y-6">
<StatsRow
memories={o.memoriesCount}
connected={o.connectedCount}
setupDone={stepsDone}
/>
<ConnectionsBoard />
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
<RecentMemories docs={o.recentDocs} loading={o.loading} />
<GettingStarted
hasSource={o.hasSource}
hasAgent={o.hasAgent}
hasMemory={o.hasMemory}
/>
</div>
</div>
)
}
function StatsRow({
memories,
connected,
setupDone,
}: {
memories: number
connected: number
setupDone: number
}) {
const tiles = [
{ label: "Memories", value: memories.toLocaleString() },
{ label: "Connected sources", value: String(connected) },
{ label: "Setup", value: `${setupDone}/3` },
]
return (
<section
className="grid grid-cols-3 divide-x divide-white/[0.04] rounded-[16px] bg-[#1B1F24]"
style={cardStyle}
>
{tiles.map((t) => (
<div key={t.label} className="px-5 py-4">
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#737373]">
{t.label}
</p>
<p
className={cn(
"mt-1.5 text-[22px] font-semibold leading-none tabular-nums text-[#fafafa]",
dmSans125ClassName(),
)}
>
{t.value}
</p>
</div>
))}
</section>
)
}
function RecentMemories({
docs,
loading,
}: {
docs: RecentDoc[]
loading: boolean
}) {
return (
<section
className="min-w-0 rounded-[18px] bg-[#1B1F24] p-5"
style={cardStyle}
>
<p
className={cn(
"mb-3 text-[15px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Recent memories
</p>
{loading ? (
<div className="flex items-center gap-2 py-6 text-[13px] font-medium text-[#737373]">
<Loader2 className="size-4 animate-spin" />
Loading
</div>
) : docs.length === 0 ? (
<div className="flex items-center gap-3 rounded-[12px] bg-[#14161A] px-4 py-5">
<div className="flex size-9 shrink-0 items-center justify-center rounded-[10px] bg-[#0F1217] text-[#525D6E]">
<FileText className="size-4" />
</div>
<div className="min-w-0">
<p className="text-[13px] font-medium text-[#fafafa]">
No memories yet
</p>
<p className="mt-0.5 text-[12px] font-medium leading-[1.5] text-[#737373]">
Connect a source or ask your brain below what you save shows up
here.
</p>
</div>
</div>
) : (
<ul className="divide-y divide-white/[0.04]">
{docs.map((doc, i) => (
<li
key={doc.id ?? i}
className="flex items-center gap-3 px-1 py-2.5"
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-[8px] bg-[#0F1217] text-[#737373]">
<FileText className="size-3.5" />
</div>
<p className="min-w-0 flex-1 truncate text-[13px] font-medium text-[#fafafa]">
{doc.title?.trim() || "Untitled memory"}
</p>
<span className="shrink-0 text-[11px] font-medium text-[#737373]">
{formatWhen(doc.createdAt)}
</span>
</li>
))}
</ul>
)}
</section>
)
}
function GettingStarted({
hasSource,
hasAgent,
hasMemory,
}: {
hasSource: boolean
hasAgent: boolean
hasMemory: boolean
}) {
const steps = [
{
done: hasSource,
title: "Connect a source",
hint: "GitHub, Linear, Drive or Slack.",
href: "/settings/integrations",
},
{
done: hasAgent,
title: "Install a coding agent",
hint: "Claude Code, Codex or Cursor.",
href: "/settings/integrations",
},
{
done: hasMemory,
title: "Add your first memory",
hint: "Save a doc, or ask your brain below.",
},
]
return (
<section
className="relative h-fit overflow-hidden rounded-[18px] bg-[#1B1F24] p-5"
style={cardStyle}
>
<div
aria-hidden
className="absolute -top-px right-8 left-8 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.45), transparent)",
}}
/>
<p
className={cn(
"text-[15px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Getting started
</p>
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
A few steps to make your brain useful.
</p>
<ul className="mt-4 space-y-2.5">
{steps.map((step) => (
<li key={step.title} className="flex items-start gap-3">
<span
aria-hidden
className={cn(
"mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border",
step.done
? "border-[#4BA0FA] bg-[#4BA0FA]"
: "border-[rgba(82,89,102,0.4)]",
)}
>
{step.done && <Check className="size-3 text-white" />}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p
className={cn(
"text-[13px] font-medium",
step.done
? "text-[#737373] line-through"
: "text-[#fafafa]",
)}
>
{step.title}
</p>
{!step.done && step.href && (
<Link
href={step.href}
className="inline-flex shrink-0 items-center gap-0.5 text-[12px] font-medium text-[#4BA0FA] transition-opacity hover:opacity-80"
>
Set up
<ArrowRight className="size-3" />
</Link>
)}
</div>
{!step.done && (
<p className="mt-0.5 text-[12px] font-medium leading-[1.4] text-[#737373]">
{step.hint}
</p>
)}
</div>
</li>
))}
</ul>
</section>
)
}
function formatWhen(value?: string | Date | null): string {
if (!value) return ""
const d = new Date(value)
if (Number.isNaN(d.getTime())) return ""
const min = Math.round((Date.now() - d.getTime()) / 60000)
if (min < 1) return "just now"
if (min < 60) return `${min}m`
const hr = Math.round(min / 60)
if (hr < 24) return `${hr}h`
const day = Math.round(hr / 24)
if (day < 7) return `${day}d`
return d.toLocaleDateString()
}

View file

@ -0,0 +1,393 @@
"use client"
import { $fetch } from "@lib/api"
import { cn } from "@lib/utils"
import { useQuery } from "@tanstack/react-query"
import { GoogleDrive, Notion } from "@ui/assets/icons"
import { Cloud, ExternalLink, Loader2 } from "lucide-react"
import Link from "next/link"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { dmSans125ClassName } from "@/lib/fonts"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const cardStyle = {
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}
const tileStyle = {
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
}
type ConnRow = { toolkit: string; org: boolean; user: boolean }
export function ConnectionsBoard() {
const [brainRows, setBrainRows] = useState<ConnRow[] | null>(null)
const [slack, setSlack] = useState<{
connected: boolean
teamName: string | null
} | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const loadBrain = useCallback(async () => {
try {
const [c, s] = await Promise.all([
fetch(`${BACKEND}/brain/connections`, { credentials: "include" }),
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
])
if (c.ok)
setBrainRows(((await c.json()) as { toolkits: ConnRow[] }).toolkits)
if (s.ok) setSlack(await s.json())
} catch {}
}, [])
useEffect(() => {
void loadBrain()
const onFocus = () => void loadBrain()
window.addEventListener("focus", onFocus)
return () => window.removeEventListener("focus", onFocus)
}, [loadBrain])
const { data: connectors } = useQuery({
queryKey: ["brain-home", "connectors"],
queryFn: async () => {
const res = await $fetch("@post/connections/list", {
body: { containerTags: [] },
})
if (res.error) return [] as Array<{ provider?: string }>
return (res.data ?? []) as Array<{ provider?: string }>
},
staleTime: 30_000,
})
const connectorConnected = (provider: string) =>
Boolean(connectors?.some((c) => c.provider === provider))
const connectBrain = async (toolkit: string) => {
setBusy(`brain:${toolkit}`)
try {
const res = await fetch(
`${BACKEND}/brain/connections/${toolkit}/link?scope=user`,
{ method: "POST", credentials: "include" },
)
if (!res.ok) {
toast.error("Couldn't start the connection.")
return
}
const data = (await res.json()) as { url?: string }
if (data.url) window.open(data.url, "_blank", "noopener")
else toast.error("Couldn't start the connection.")
} catch {
toast.error("Couldn't start the connection.")
} finally {
setBusy(null)
}
}
const connectConnector = async (
provider: "google-drive" | "notion" | "onedrive",
) => {
setBusy(`conn:${provider}`)
try {
const res = await $fetch("@post/connections/:provider", {
params: { provider },
body: { redirectUrl: window.location.href, containerTags: [] },
})
const data = res.data as { authLink?: string } | undefined
if (data?.authLink) window.location.href = data.authLink
else toast.error("Couldn't start the connection.")
} catch {
toast.error("Couldn't start the connection.")
} finally {
setBusy(null)
}
}
const brainConnected = (toolkit: string) =>
Boolean(brainRows?.find((r) => r.toolkit === toolkit)?.org) ||
Boolean(brainRows?.find((r) => r.toolkit === toolkit)?.user)
return (
<div className="space-y-4">
{slack && !slack.connected && <SlackBanner />}
<div className="grid gap-4 lg:grid-cols-2">
<Group
title="Tool integrations"
subtitle="Apps your agents can act on."
>
<AppCard
icon={<GithubMark className="size-5 text-[#fafafa]" />}
name="GitHub"
subtitle="Repos, pull requests and issues."
connected={brainConnected("github")}
busy={busy === "brain:github"}
onConnect={() => connectBrain("github")}
/>
<AppCard
icon={<LinearMark className="size-5 text-[#5E6AD2]" />}
name="Linear"
subtitle="Issues, projects and cycles."
connected={brainConnected("linear")}
busy={busy === "brain:linear"}
onConnect={() => connectBrain("linear")}
/>
</Group>
<Group
title="Connectors"
subtitle="Sync documents into your brain."
cta={
<Link
href="/settings/integrations"
className="inline-flex items-center gap-1 text-[12px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
>
All connectors
<ExternalLink className="size-3" aria-hidden />
</Link>
}
>
<AppCard
icon={<GoogleDrive className="size-5" />}
name="Google Drive"
subtitle="Docs, sheets and slides."
connected={connectorConnected("google-drive")}
busy={busy === "conn:google-drive"}
onConnect={() => connectConnector("google-drive")}
/>
<AppCard
icon={<Notion className="size-5" />}
name="Notion"
subtitle="Pages, databases and blocks."
connected={connectorConnected("notion")}
busy={busy === "conn:notion"}
onConnect={() => connectConnector("notion")}
/>
<AppCard
icon={<Cloud className="size-5 text-[#0F6CBD]" />}
name="OneDrive"
subtitle="Files from Microsoft 365."
connected={connectorConnected("onedrive")}
busy={busy === "conn:onedrive"}
onConnect={() => connectConnector("onedrive")}
/>
</Group>
</div>
</div>
)
}
function SlackBanner() {
return (
<section
className="relative overflow-hidden rounded-[18px] bg-[#1B1F24] p-5"
style={cardStyle}
>
<div
aria-hidden
className="absolute -top-px right-8 left-8 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.45), transparent)",
}}
/>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3.5">
<div
className="flex size-12 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
style={tileStyle}
>
<SlackMark className="size-7" />
</div>
<div className="min-w-0">
<p
className={cn(
"text-[16px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Company Brain in Slack
</p>
<p className="mt-0.5 text-[13px] font-medium leading-[1.5] text-[#737373]">
Install Supermemory so your team can{" "}
<span className="text-[#A1A1AA]">@supermemory</span> in any
channel.
</p>
</div>
</div>
<a
href={`${BACKEND}/brain/slack/oauth/install`}
className="inline-flex shrink-0 items-center gap-2 self-start rounded-lg bg-white px-4 py-2.5 text-[14px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02] sm:self-auto"
>
<SlackMark className="size-[18px]" />
Add to Slack
</a>
</div>
</section>
)
}
function Group({
title,
subtitle,
accent,
cta,
children,
}: {
title: string
subtitle: string
accent?: boolean
cta?: React.ReactNode
children: React.ReactNode
}) {
return (
<section
className="relative flex min-w-0 flex-col gap-2.5 overflow-hidden rounded-[18px] bg-[#1B1F24] p-5"
style={cardStyle}
>
{accent && (
<div
aria-hidden
className="absolute -top-px right-8 left-8 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.45), transparent)",
}}
/>
)}
<div className="mb-1 flex items-start justify-between gap-3">
<div>
<p
className={cn(
"text-[15px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
{title}
</p>
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
{subtitle}
</p>
</div>
{cta}
</div>
{children}
</section>
)
}
function AppCard({
icon,
name,
subtitle,
connected,
busy,
onConnect,
}: {
icon: React.ReactNode
name: string
subtitle: string
connected: boolean
busy: boolean
onConnect: () => void
}) {
return (
<div className="flex items-center gap-3 rounded-[12px] bg-[#14161A] p-3">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-[10px] border border-[rgba(82,89,102,0.2)] bg-[#080B0F]"
style={tileStyle}
>
{icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-[14px] font-semibold leading-tight text-[#fafafa]">
{name}
</p>
<p className="mt-0.5 truncate text-[12px] font-medium text-[#737373]">
{subtitle}
</p>
</div>
{connected ? (
<span className="flex shrink-0 items-center gap-1.5 text-[12px] font-medium text-[#fafafa]">
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
Connected
</span>
) : (
<button
type="button"
onClick={onConnect}
disabled={busy}
className={cn(
dmSans125ClassName(),
"flex shrink-0 items-center gap-1.5 rounded-full bg-[#0D121A] px-3.5 py-2 text-[13px] font-medium text-[#fafafa] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80 disabled:opacity-50",
)}
>
{busy && <Loader2 className="size-3.5 animate-spin" />}
Connect
</button>
)}
</div>
)
}
function GithubMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
)
}
function LinearMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<title>Linear</title>
<path d="M3.084 12.866a8.916 8.916 0 0 0 8.05 8.05.27.27 0 0 0 .222-.46l-7.812-7.812a.27.27 0 0 0-.46.222Zm-.044-1.955a.27.27 0 0 0 .078.21l9.76 9.76c.06.06.142.087.21.078a8.87 8.87 0 0 0 1.273-.218.27.27 0 0 0 .127-.453L3.712 9.51a.27.27 0 0 0-.453.127 8.87 8.87 0 0 0-.218 1.273Zm.69-2.706a.27.27 0 0 0 .06.29l11.715 11.716a.27.27 0 0 0 .29.06 8.96 8.96 0 0 0 .837-.384.27.27 0 0 0 .066-.439L4.553 7.302a.27.27 0 0 0-.44.066 8.96 8.96 0 0 0-.383.837Zm1.11-1.798a.27.27 0 0 1-.017-.366A8.948 8.948 0 0 1 18.07 18.69a.27.27 0 0 1-.366-.017L4.94 6.407Z" />
</svg>
)
}
function SlackMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
<title>Slack</title>
<path
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
fill="#E01E5A"
/>
<path
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
fill="#E01E5A"
/>
<path
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
fill="#36C5F0"
/>
<path
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
fill="#36C5F0"
/>
<path
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
fill="#2EB67D"
/>
<path
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
fill="#2EB67D"
/>
<path
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
fill="#ECB22E"
/>
<path
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
fill="#ECB22E"
/>
</svg>
)
}

View file

@ -48,7 +48,10 @@ import {
import { SpaceSelector } from "@/components/space-selector"
import { SuperLoader } from "../superloader"
import { UserMessage } from "./message/user-message"
import { AgentMessage } from "./message/agent-message"
import {
AgentMessage,
isChatToolDisplayPartType,
} from "./message/agent-message"
import { ChatGraphContextRail } from "./chat-graph-context-rail"
import { ChainOfThought } from "./input/chain-of-thought"
import { useIsMobile } from "@hooks/use-mobile"
@ -1147,14 +1150,14 @@ export function ChatSidebar({
}) => ({
id: m.id,
role: m.role,
// Strip tool parts (they break convertToModelMessages with tool_use/tool_result
// mismatches); keep text/reasoning + source parts so citations survive reload.
// Keep chat tool outputs that are meaningful to render after thread reload.
parts: (m.parts || []).filter(
(p) =>
p.type === "text" ||
p.type === "reasoning" ||
p.type === "source-url" ||
p.type === "source-document",
p.type === "source-document" ||
isChatToolDisplayPartType(p.type),
),
metadata: m.metadata,
createdAt: new Date(m.createdAt),

View file

@ -2,6 +2,7 @@
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"
import type { UIMessage } from "@ai-sdk/react"
import { useQuery } from "@tanstack/react-query"
import { Streamdown } from "streamdown"
import {
BookOpenIcon,
@ -19,18 +20,37 @@ import {
} from "lucide-react"
import { cn } from "@lib/utils"
import { isWebSearchToolName } from "@/lib/chat-web-search-tools"
import {
buildCitationIndex,
fetchDocumentsByIds,
getDocumentSourceUrl,
isMemoryToolOutputReady,
mapDocumentsByKnownIds,
type CitationTarget,
type DocumentWithMemories,
extractMemoryToolOutputs,
} from "@/lib/chat-memory-tools"
import {
parseSourceAnnotatedMarkdown,
stripSourceMarkup,
} from "@/lib/source-annotations"
import { modelNames, type ModelId } from "@/lib/models"
import { RelatedMemories } from "./related-memories"
import { MessageActions } from "./message-actions"
const TOOL_META: Record<string, { label: string; icon: typeof SearchIcon }> = {
bash: { label: "Memory", icon: TerminalIcon },
recallContext: { label: "Recall Memories", icon: BookOpenIcon },
discoverSpaces: { label: "Discover Spaces", icon: SearchIcon },
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 },
forgetMemory: { label: "Forget Memory", icon: XCircleIcon },
updateMemory: { label: "Update Memory", icon: BookOpenIcon },
forgetDocument: { label: "Forget Document", icon: XCircleIcon },
scheduleTask: { label: "Schedule Task", icon: ClockIcon },
listSchedules: { label: "List Schedules", icon: ListIcon },
cancelSchedule: { label: "Cancel Schedule", icon: XCircleIcon },
@ -38,7 +58,7 @@ const TOOL_META: Record<string, { label: string; icon: typeof SearchIcon }> = {
type ToolCallDisplayPart = {
type: string
state: string
state?: string
input?: unknown
output?: unknown
toolCallId?: string
@ -64,6 +84,20 @@ function faviconUrl(host: string): string {
return `https://www.google.com/s2/favicons?sz=64&domain=${host}`
}
function safeExternalUrl(url: string | null | undefined): string | null {
if (!url) return null
if (url.startsWith("/") && !url.startsWith("//")) return url
if (url.startsWith("#") && !url.startsWith("#sm-source:")) return url
try {
const parsed = new URL(url)
return parsed.protocol === "http:" || parsed.protocol === "https:"
? url
: null
} catch {
return null
}
}
function isWebSearchPart(part: { type: string; toolName?: string }): boolean {
if (part.type === "dynamic-tool") {
return isWebSearchToolName(part.toolName ?? "")
@ -74,6 +108,25 @@ function isWebSearchPart(part: { type: string; toolName?: string }): boolean {
return false
}
function isMemoryRetrievalToolName(toolName: string): boolean {
return (
toolName === "searchMemories" ||
toolName === "recallContext" ||
toolName === "discoverSpaces"
)
}
export function isChatToolDisplayPartType(type: string): boolean {
return (
type === "tool-searchMemories" ||
type === "tool-recallContext" ||
type === "tool-discoverSpaces" ||
type === "tool-forgetMemory" ||
type === "tool-updateMemory" ||
type === "tool-forgetDocument"
)
}
function CitationLink({
href,
label,
@ -83,7 +136,8 @@ function CitationLink({
label: string
source?: SourceUrlPart
}) {
const url = source?.url ?? href
const url = safeExternalUrl(source?.url ?? href) ?? ""
if (!url) return <>{label}</>
const host = sourceHost(url)
const rawTitle = source?.title?.trim()
const hasTitle =
@ -136,9 +190,138 @@ function CitationLink({
)
}
function makeMarkdownComponents(sources: SourceUrlPart[]) {
function sourceTitle(
target: CitationTarget,
document?: DocumentWithMemories,
): string {
return (
document?.title?.trim() ||
target.title?.trim() ||
document?.customId ||
target.customId ||
target.documentId ||
target.sourceId
)
}
function sourceSummary(
target: CitationTarget,
document?: DocumentWithMemories,
): string | null {
const summary =
document?.summary ||
target.summary ||
(document as { content?: string } | undefined)?.content ||
null
return summary ? summary.trim() : null
}
function sourceKind(
target: CitationTarget,
document?: DocumentWithMemories,
): string {
return (document?.type || target.type || "memory").replaceAll("_", " ")
}
function SourceCitationLink({
sourceId,
children,
citationIndex,
documentByKnownId,
}: {
sourceId: string
children: ReactNode
citationIndex: Map<string, CitationTarget>
documentByKnownId: Map<string, DocumentWithMemories>
}) {
const target = citationIndex.get(sourceId)
if (!target) return <>{children}</>
const document =
(target.documentId
? documentByKnownId.get(target.documentId)
: undefined) ??
(target.customId ? documentByKnownId.get(target.customId) : undefined)
const url = safeExternalUrl(
document ? getDocumentSourceUrl(document) : target.url,
)
const title = sourceTitle(target, document)
const summary = sourceSummary(target, document)
return (
<span className="group/source relative inline rounded-[3px] border-b border-dotted border-white/20 bg-white/[0.025] px-px text-white/90 transition-colors hover:border-white/35 hover:bg-white/[0.045] focus-within:border-white/35 focus-within:bg-white/[0.045]">
{url ? (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-inherit no-underline outline-none focus-visible:ring-1 focus-visible:ring-white/25"
>
{children}
</a>
) : (
<button
type="button"
className="cursor-help border-0 bg-transparent p-0 text-inherit"
>
{children}
</button>
)}
<span className="ml-1 inline-flex h-3.5 min-w-3.5 translate-y-[-1px] items-center justify-center rounded-full border border-white/10 bg-white/[0.04] px-1 text-[9px] font-medium leading-none text-white/45 transition-colors group-hover/source:text-white/65 group-focus-within/source:text-white/65">
{sourceId}
</span>
<span className="pointer-events-none absolute bottom-full left-1/2 z-[1000] hidden w-72 -translate-x-1/2 pb-2 group-hover/source:block group-focus-within/source:block">
<span className="pointer-events-auto block rounded-xl border border-white/10 bg-[#0B0F16]/95 p-3 text-left shadow-[0_16px_44px_rgba(0,0,0,0.48)] backdrop-blur-xl">
<span className="mb-1 flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-white/85">
{title}
</span>
<span className="shrink-0 rounded-full bg-white/5 px-2 py-0.5 text-[10px] capitalize text-white/40">
{sourceKind(target, document)}
</span>
</span>
{summary ? (
<span className="line-clamp-3 text-xs leading-snug text-white/55">
{summary}
</span>
) : null}
{url ? (
<span className="mt-2 block text-xs font-medium text-blue-300">
Open source
</span>
) : null}
</span>
</span>
</span>
)
}
function makeMarkdownComponents(
sources: SourceUrlPart[],
citationIndex: Map<string, CitationTarget>,
documentByKnownId: Map<string, DocumentWithMemories>,
) {
return {
a: ({ href, children }: { href?: string; children?: ReactNode }) => {
if (href?.startsWith("#sm-source:")) {
const sourceId = (() => {
try {
return decodeURIComponent(href.slice("#sm-source:".length))
} catch {
return null
}
})()
if (!sourceId) return <>{children}</>
return (
<SourceCitationLink
sourceId={sourceId}
citationIndex={citationIndex}
documentByKnownId={documentByKnownId}
>
{children}
</SourceCitationLink>
)
}
const label =
typeof children === "string"
? children
@ -146,14 +329,16 @@ function makeMarkdownComponents(sources: SourceUrlPart[]) {
? children.join("")
: ""
const match = label.match(/^\[?(\d+)\]?$/)
if (match && href) {
const safeHref = safeExternalUrl(href)
if (match && safeHref) {
const n = Number(match[1])
const source = sources.find((s) => s.url === href) ?? sources[n - 1]
return <CitationLink href={href} label={label} source={source} />
const source = sources.find((s) => s.url === safeHref) ?? sources[n - 1]
return <CitationLink href={safeHref} label={label} source={source} />
}
if (!safeHref) return <>{children}</>
return (
<a
href={href}
href={safeHref}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
@ -397,6 +582,9 @@ function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
const isDone = part.state === "output-available"
const isError = part.state === "error" || part.state === "output-error"
const errorText = part.errorText
if (isMemoryRetrievalToolName(toolName) && isMemoryToolOutputReady(part)) {
return null
}
return (
<div className="rounded-lg border border-[#1E2128] bg-[#0D121A] text-xs my-1 overflow-hidden">
@ -516,7 +704,38 @@ export function AgentMessage({
.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ")
const webSources = (() => {
const copyText = stripSourceMarkup(messageText)
const memoryOutputs = useMemo(
() => extractMemoryToolOutputs(message),
[message],
)
const citationIndex = useMemo(
() => buildCitationIndex(memoryOutputs),
[memoryOutputs],
)
const allowedSourceIds = useMemo(
() => new Set(citationIndex.keys()),
[citationIndex],
)
const sourceDocumentIds = useMemo(() => {
const ids = new Set<string>()
for (const target of citationIndex.values()) {
if (target.documentId) ids.add(target.documentId)
if (target.customId) ids.add(target.customId)
}
return [...ids].sort()
}, [citationIndex])
const { data: sourceDocuments = [] } = useQuery({
queryKey: ["chat-source-documents", sourceDocumentIds],
queryFn: () => fetchDocumentsByIds(sourceDocumentIds),
enabled: sourceDocumentIds.length > 0,
staleTime: 5 * 60 * 1000,
})
const documentByKnownId = useMemo(
() => mapDocumentsByKnownIds(sourceDocuments),
[sourceDocuments],
)
const webSources = useMemo(() => {
const seen = new Set<string>()
const out: SourceUrlPart[] = []
for (const part of message.parts) {
@ -527,15 +746,13 @@ export function AgentMessage({
out.push(source)
}
return out
})()
}, [message.parts])
const hasAssistantText = message.parts.some(
(p) => p.type === "text" && (p as { text?: string }).text?.trim(),
)
const sourceKey = webSources.map((s) => s.url).join("|")
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed by stable source urls
const markdownComponents = useMemo(
() => makeMarkdownComponents(webSources),
[sourceKey],
() => makeMarkdownComponents(webSources, citationIndex, documentByKnownId),
[webSources, citationIndex, documentByKnownId],
)
const responseModelLabel = responseModel
? `${modelNames[responseModel].name} ${modelNames[responseModel].version}`
@ -603,7 +820,10 @@ export function AgentMessage({
className="text-sm text-white/90 chat-markdown-content"
>
<Streamdown components={markdownComponents}>
{runText}
{
parseSourceAnnotatedMarkdown(runText, allowedSourceIds)
.markdown
}
</Streamdown>
</div>
)
@ -613,7 +833,7 @@ export function AgentMessage({
type: "dynamic-tool"
toolName: string
toolCallId: string
state: string
state?: string
input?: unknown
output?: unknown
errorText?: string
@ -651,7 +871,7 @@ export function AgentMessage({
<div className="flex min-h-7 items-center gap-2">
<MessageActions
messageId={message.id}
messageText={messageText}
messageText={copyText}
isLastMessage={isLastAgentMessage}
isHovered={isHovered}
copiedMessageId={copiedMessageId}

View file

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

View file

@ -20,12 +20,14 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const searchParams = useSearchParams()
const { session, organizations, isRestoring } = useAuth()
const { session, organizations, isRestoring, isSessionPending } = useAuth()
const isPublicAppPage =
pathname === "/" &&
["integrations", "mcp"].includes(searchParams.get("view") ?? "")
const isGuestPublicAppPage = isPublicAppPage && !session
pathname === "/integrations" ||
pathname === "/integrations/mcp" ||
(pathname === "/" &&
["integrations", "mcp"].includes(searchParams.get("view") ?? ""))
const isGuestPublicAppPage = isPublicAppPage && !session && !isSessionPending
const isOnboarding = pathname.startsWith("/onboarding")
useEffect(() => {

View file

@ -514,7 +514,7 @@ 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="/?view=integrations"
href="/integrations"
className="flex items-center gap-2 transition-opacity hover:opacity-90"
>
<Logo className="h-6 md:h-7" />
@ -523,7 +523,7 @@ export function PublicHeader({
</p>
</Link>
<Link href="/login?redirect=%2F%3Fview%3Dintegrations">
<Link href="/login?redirect=%2Fintegrations">
<button
type="button"
className={cn(

View file

@ -93,6 +93,19 @@ interface ConnectedKey {
createdAt?: string | null
}
interface ConnectedMcpKey {
keyId: string
keyStart: string | null
lastRequest?: string | null
createdAt?: string | null
}
function isMcpAuthMetadata(metadata: { sm_source?: string; sm_kind?: string }) {
return (
metadata.sm_source === "mcp" || metadata.sm_kind === "mcp_oauth_exchange"
)
}
function toIsoDate(value: string | Date | null | undefined): string | null {
if (!value) return null
const d = value instanceof Date ? value : new Date(value)
@ -142,6 +155,34 @@ function parsePluginAuthKeys(
return { active, setup }
}
function parseMcpAuthKeys(
apiKeys: ListedApiKey[],
keyPrefix: (key: ListedApiKey) => string | null,
): ConnectedMcpKey[] {
const keys: ConnectedMcpKey[] = []
for (const key of apiKeys) {
if (key.enabled === false) continue
if (!key.metadata) continue
try {
const metadata =
typeof key.metadata === "string"
? (JSON.parse(key.metadata) as {
sm_source?: string
sm_kind?: string
})
: (key.metadata as { sm_source?: string; sm_kind?: string })
if (!isMcpAuthMetadata(metadata)) continue
keys.push({
keyId: key.id,
keyStart: keyPrefix(key),
lastRequest: toIsoDate(key.lastRequest),
createdAt: toIsoDate(key.createdAt),
})
} catch {}
}
return keys
}
type ListedApiKey = {
id: string
name?: string | null
@ -1036,6 +1077,31 @@ function ActiveButton({
)
}
function McpConnectedPill({
connectedAt,
lastActive,
}: {
connectedAt?: string | null
lastActive?: string | null
}) {
return (
<span
className={cn(
dmSans125ClassName(),
"flex shrink-0 items-center gap-1.5 text-[12px] font-medium text-[#00AC3F] sm:text-[13px]",
)}
>
<span className="size-[7px] rounded-full bg-[#00AC3F]" />
Connected
{(lastActive ?? connectedAt) && (
<span className="text-[11px] font-normal text-[#737373]">
· {formatRelativeTime(lastActive ?? connectedAt)}
</span>
)}
</span>
)
}
function FinishSetupButton({ onClick }: { onClick: () => void }) {
return (
<PillButton onClick={onClick}>
@ -1137,7 +1203,18 @@ interface ConnectorEntry {
onReconnect: () => void
}
type RailEntry = PluginEntry | ConnectorEntry
interface McpEntry {
kind: "mcp"
id: string
name: string
icon: ReactNode
connectionCount: number
createdAt: string | null
lastActive: string | null
onManage: () => void
}
type RailEntry = PluginEntry | ConnectorEntry | McpEntry
function railConnectionMeta(connection: Connection) {
const m = connection.metadata as Record<string, unknown> | undefined
@ -1423,6 +1500,60 @@ function ConnectorRailRow({ entry }: { entry: ConnectorEntry }) {
)
}
function McpRailRow({ entry }: { entry: McpEntry }) {
const [expanded, setExpanded] = useState(false)
const lastTime = entry.lastActive ?? entry.createdAt
const suffix = [
entry.connectionCount > 1 ? `${entry.connectionCount} connections` : null,
lastTime ? formatRelativeTime(lastTime) : null,
]
.filter(Boolean)
.join(" · ")
return (
<RailRow
icon={entry.icon}
name={entry.name}
expanded={expanded}
onToggle={() => setExpanded((v) => !v)}
statusLine={
<div className="flex min-w-0 items-center gap-1.5">
<ActiveStatusDot />
{suffix && (
<span
className={cn(
dmSans125ClassName(),
"min-w-0 truncate text-[11px] text-[#737373]",
)}
>
· {suffix}
</span>
)}
</div>
}
>
{entry.createdAt && (
<RailDetail
label="Connected"
value={formatRelativeTime(entry.createdAt)}
/>
)}
{entry.lastActive && (
<RailDetail
label="Last active"
value={formatRelativeTime(entry.lastActive)}
/>
)}
<RailDetail
label="MCP keys"
value={`${entry.connectionCount} connected`}
/>
<div className="mt-1 flex flex-wrap gap-1.5">
<RailAction label="Manage" onClick={entry.onManage} />
</div>
</RailRow>
)
}
const SKELETON_KEYS = ["s1", "s2", "s3", "s4", "s5"]
function RailSkeleton({ rows }: { rows: number }) {
@ -1521,6 +1652,8 @@ function ActiveConnectionsRail({
{entries.map((entry) =>
entry.kind === "plugin" ? (
<PluginRailRow key={entry.id} entry={entry} />
) : entry.kind === "mcp" ? (
<McpRailRow key={entry.id} entry={entry} />
) : (
<ConnectorRailRow key={entry.id} entry={entry} />
),
@ -1879,6 +2012,8 @@ function MobileActivityPanel({
{entries.map((entry) =>
entry.kind === "plugin" ? (
<PluginRailRow key={entry.id} entry={entry} />
) : entry.kind === "mcp" ? (
<McpRailRow key={entry.id} entry={entry} />
) : (
<ConnectorRailRow key={entry.id} entry={entry} />
),
@ -2321,9 +2456,11 @@ function CategoryFilterToggle({
function SectionRail({
label,
children,
headerSlot,
}: {
label: string
children: ReactNode
headerSlot?: ReactNode
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const [canScrollLeft, setCanScrollLeft] = useState(false)
@ -2374,6 +2511,7 @@ function SectionRail({
{label}
</h3>
<div className="hidden items-center gap-1.5 sm:flex">
{headerSlot}
<button
type="button"
aria-label="Show previous"
@ -2429,7 +2567,8 @@ export function IntegrationsView({
open: boolean
key: string
pluginId: string | null
}>({ open: false, key: "", pluginId: null })
loading: boolean
}>({ open: false, key: "", pluginId: null, loading: false })
const [connectedPluginId, setConnectedPluginId] = useState<string | null>(
null,
)
@ -2524,6 +2663,11 @@ export function IntegrationsView({
[apiKeys, keyPrefix],
)
const activeMcpKeys = useMemo(
() => parseMcpAuthKeys(apiKeys, keyPrefix),
[apiKeys, keyPrefix],
)
const activePluginById = useMemo(() => {
const map = new Map<string, ConnectedKey>()
for (const key of activePlugins) {
@ -2541,6 +2685,20 @@ export function IntegrationsView({
return map
}, [activePlugins])
const activeMcpKey = useMemo(() => {
let latest: ConnectedMcpKey | null = null
for (const key of activeMcpKeys) {
if (!latest) {
latest = key
continue
}
const a = toMs(key.lastRequest ?? key.createdAt)
const b = toMs(latest.lastRequest ?? latest.createdAt)
if (a >= b) latest = key
}
return latest
}, [activeMcpKeys])
const activeCountByPlugin = useMemo(() => {
const map = new Map<string, number>()
for (const key of activePlugins) {
@ -2600,6 +2758,12 @@ export function IntegrationsView({
},
onMutate: (pluginId) => setConnectingPlugin(pluginId),
onError: (err) => {
// Tear down a pre-opened (loading) modal so a failed mint doesn't hang on a spinner.
setNewKey((s) =>
s.loading
? { open: false, key: "", pluginId: null, loading: false }
: s,
)
toast.error("Failed to connect plugin", {
description: err instanceof Error ? err.message : "Unknown error",
})
@ -2609,7 +2773,7 @@ export function IntegrationsView({
queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] })
},
onSuccess: (data, pluginId) => {
setNewKey({ open: true, key: data.key, pluginId })
setNewKey({ open: true, key: data.key, pluginId, loading: false })
},
})
@ -2657,23 +2821,26 @@ export function IntegrationsView({
}
}
const handleUpgrade = async (planId?: unknown) => {
const checkoutPlanId = planId === "api_max" ? "api_max" : "api_pro"
try {
const result = await autumn.attach({
planId: checkoutPlanId,
successUrl: `${window.location.origin}/?view=integrations`,
})
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")
return
const handleUpgrade = useCallback(
async (planId?: unknown) => {
const checkoutPlanId = planId === "api_max" ? "api_max" : "api_pro"
try {
const result = await autumn.attach({
planId: checkoutPlanId,
successUrl: `${window.location.origin}/integrations`,
})
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.")
}
autumn.refetch?.()
} catch (error) {
console.error(error)
toast.error("Failed to start checkout. Please try again.")
}
}
},
[autumn],
)
const redirectToLogin = useCallback(() => {
const loginUrl = new URL("/login", window.location.origin)
@ -2701,6 +2868,85 @@ export function IntegrationsView({
setMcpModalOpen(true)
}
// Deeplink: /integrations?connect=<plugin-id|provider> auto-opens that card's connect flow.
const [connectTarget, setConnectTarget] = useQueryState(
"connect",
parseAsString,
)
// Tracks the last target we acted on; reset when the param clears so a fresh deeplink re-fires.
const connectHandledRef = useRef<string | null>(null)
useEffect(() => {
if (!connectTarget) {
connectHandledRef.current = null
return
}
if (connectHandledRef.current === connectTarget) return
const target = connectTarget
const isPlugin = !!PLUGIN_CATALOG[target]
const freeTier = isPlugin && isFreeTierPlugin(target)
// Paid plugins and granola need the plan query before deciding upgrade-vs-connect.
const needsPlan = (isPlugin && !freeTier) || target === "granola"
if (needsPlan && isAutumnLoading) return
// Defer to a macrotask and cancel on cleanup so React Strict Mode's mount→unmount→remount
// fires this exactly once (on the surviving mount) instead of opening/minting twice.
let cancelled = false
const timer = setTimeout(() => {
if (cancelled) return
connectHandledRef.current = target
if (publicMode) {
redirectToLogin()
return
}
if (isPlugin) {
if (!freeTier && !hasProProduct) {
void setConnectTarget(null)
handleUpgrade("api_pro")
} else {
// Open instantly; the key fills in on mint. The ?connect param stays the source
// of truth until the modal closes.
setNewKey({ open: true, key: "", pluginId: target, loading: true })
createPluginKeyMutation.mutate(target)
}
return
}
if (target === "granola") {
if (!hasProProduct) {
void setConnectTarget(null)
handleUpgrade("api_pro")
} else {
setGranolaModalOpen(true)
}
return
}
if (["notion", "google-drive", "onedrive"].includes(target)) {
// The add-document modal is driven by its own ?add param, so clearing ?connect is safe.
void setConnectTarget(null)
void setAddDoc("connect")
}
}, 0)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [
connectTarget,
isAutumnLoading,
hasProProduct,
publicMode,
redirectToLogin,
setConnectTarget,
setAddDoc,
createPluginKeyMutation,
handleUpgrade,
])
const closeMcpModal = () => {
setMcpModalOpen(false)
void setMcpClient(null)
@ -2792,6 +3038,24 @@ export function IntegrationsView({
},
})
}
if (activeMcpKey) {
rows.push({
ts: toMs(activeMcpKey.lastRequest ?? activeMcpKey.createdAt),
entry: {
kind: "mcp",
id: "mcp",
name: "Supermemory MCP",
icon: <MCPIcon className="size-6" />,
connectionCount: activeMcpKeys.length,
createdAt: activeMcpKey.createdAt ?? null,
lastActive: activeMcpKey.lastRequest ?? null,
onManage: () => {
void setMcpClient("mcp-url")
setMcpModalOpen(true)
},
},
})
}
for (const provider of [
"google-drive",
"notion",
@ -2829,11 +3093,14 @@ export function IntegrationsView({
rows.sort((a, b) => b.ts - a.ts)
return rows.map((r) => r.entry)
}, [
activeMcpKey,
activeMcpKeys.length,
activePluginById,
activeCountByPlugin,
connectionsByProvider,
allProjects,
setAddDoc,
setMcpClient,
addConnectionMutation,
])
@ -2886,6 +3153,7 @@ export function IntegrationsView({
const claudeCodeConnected = activePluginById.has("claude_code")
const claudeCodeNeedsPro =
!isAutumnLoading && !hasProProduct && !isFreeTierPlugin("claude_code")
const mcpConnected = !!activeMcpKey
const featuredPicks: FeaturedPick[] = [
{
@ -2939,7 +3207,7 @@ export function IntegrationsView({
/>
),
docsUrl: "https://supermemory.ai/docs/supermemory-mcp/introduction",
ctaLabel: "Connect",
ctaLabel: mcpConnected ? "Connected" : "Connect",
onCta: () => {
if (publicMode) {
redirectToLogin()
@ -3234,14 +3502,21 @@ export function IntegrationsView({
}
case "mcp-client":
return (
<PillButton
<button
type="button"
aria-label={`Connect ${item.name}`}
title="Connect"
onClick={() => {
trackCard(item)
openMcpClient(item.clientKey)
}}
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[#A1A1AA] transition-colors hover:text-[#FAFAFA] sm:size-9",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
)}
>
Connect
</PillButton>
<Plus className="size-4" />
</button>
)
case "import":
return (
@ -3409,7 +3684,7 @@ export function IntegrationsView({
]
return (
<div className="flex-1 p-4 md:p-6 pt-2">
<div className="flex-1">
{shortcutsConnect.dialog}
<div
className={cn(
@ -3485,7 +3760,18 @@ export function IntegrationsView({
)
if (items.length === 0) return null
return (
<SectionRail key={cat} label={CATEGORY_LABEL[cat]}>
<SectionRail
key={cat}
label={CATEGORY_LABEL[cat]}
headerSlot={
cat === "ai-clients" && activeMcpKey ? (
<McpConnectedPill
connectedAt={activeMcpKey.createdAt}
lastActive={activeMcpKey.lastRequest}
/>
) : null
}
>
{items.map((item) => (
<div
key={item.id}
@ -3524,13 +3810,15 @@ export function IntegrationsView({
<Dialog
open={newKey.open}
onOpenChange={(open) =>
onOpenChange={(open) => {
setNewKey((s) => ({
open,
key: open ? s.key : "",
pluginId: open ? s.pluginId : null,
loading: open ? s.loading : false,
}))
}
if (!open) void setConnectTarget(null)
}}
>
<DialogContent
showCloseButton={false}
@ -3562,7 +3850,9 @@ export function IntegrationsView({
Set up {dialogPlugin?.name ?? "your plugin"}
</p>
<p className="mt-0.5 truncate text-[12px] text-[#A1A1AA]">
Copy your key and run these steps to finish.
{newKey.loading
? "Generating your key…"
: "Copy your key and run these steps to finish."}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
@ -3599,15 +3889,28 @@ export function IntegrationsView({
INSET,
)}
>
<InstallSteps steps={setupSteps} apiKey={newKey.key} />
{newKey.loading ? (
<div className="flex items-center justify-center gap-2 py-10 text-[13px] text-[#A1A1AA]">
<Loader className="size-4 animate-spin" />
Generating your key
</div>
) : (
<InstallSteps steps={setupSteps} apiKey={newKey.key} />
)}
</div>
</div>
<div className="flex shrink-0 items-center justify-end">
<button
type="button"
onClick={() =>
setNewKey({ open: false, key: "", pluginId: null })
}
onClick={() => {
setNewKey({
open: false,
key: "",
pluginId: null,
loading: false,
})
void setConnectTarget(null)
}}
className={cn(
dmSans125ClassName(),
"flex h-9 items-center gap-1.5 rounded-full bg-[#0D121A] px-5 text-[13px] font-medium text-[#FAFAFA] transition-opacity hover:opacity-80",
@ -3958,7 +4261,10 @@ export function IntegrationsView({
<GranolaConnectModal
open={hasProProduct && granolaModalOpen}
onOpenChange={(open) => setGranolaModalOpen(open && hasProProduct)}
onOpenChange={(open) => {
setGranolaModalOpen(open && hasProProduct)
if (!open) void setConnectTarget(null)
}}
/>
</div>
)

View file

@ -613,7 +613,7 @@ export function PluginsDetail() {
try {
const result = await autumn.attach({
planId: "api_pro",
successUrl: `${window.location.origin}/?view=integrations`,
successUrl: `${window.location.origin}/integrations`,
})
if (result?.paymentUrl) {
window.open(result.paymentUrl, "_self")

View file

@ -1,367 +1,8 @@
"use client"
import Image from "next/image"
import { useEffect, useState } from "react"
import { motion } from "motion/react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { ChromeIcon, RaycastIcon } from "@/components/integration-icons"
import {
ClaudeDesktopIcon,
GoogleDrive,
MCPIcon,
Notion,
} from "@ui/assets/icons"
import { Logo } from "@ui/assets/Logo"
import NovaOrb from "@/components/nova/nova-orb"
type ToolNode = {
id: string
name: string
x: number
y: number
icon?: React.ComponentType<{ className?: string }>
iconSrc?: string
}
type ContextConnection = {
from: ToolNode
to: ToolNode
}
type ContextPhase = "idle" | "capture" | "hold" | "recall"
const CENTER = { x: 50, y: 50 }
const IN_MS = 1100
const HOLD_MS = 700
const OUT_MS = 1100
const TOTAL_MS = IN_MS + HOLD_MS + OUT_MS
const TOOL_NODES: ToolNode[] = [
{ id: "chrome", name: "Chrome", x: 14, y: 20, icon: ChromeIcon },
{ id: "notion", name: "Notion", x: 84, y: 16, icon: Notion },
{ id: "drive", name: "Google Drive", x: 10, y: 52, icon: GoogleDrive },
{ id: "claude", name: "Claude", x: 90, y: 44, icon: ClaudeDesktopIcon },
{ id: "raycast", name: "Raycast", x: 76, y: 76, icon: RaycastIcon },
{ id: "mcp", name: "MCP", x: 22, y: 84, icon: MCPIcon },
{
id: "claude-code",
name: "Claude Code",
x: 20,
y: 36,
iconSrc: "/images/plugins/claude-code.svg",
},
{
id: "codex",
name: "Codex",
x: 92,
y: 28,
iconSrc: "/images/plugins/codex.png",
},
{
id: "opencode",
name: "OpenCode",
x: 58,
y: 10,
iconSrc: "/images/plugins/opencode.svg",
},
{
id: "hermes",
name: "Hermes",
x: 36,
y: 90,
iconSrc: "/images/plugins/hermes.svg",
},
{
id: "openclaw",
name: "OpenClaw",
x: 68,
y: 68,
iconSrc: "/images/plugins/openclaw.svg",
},
]
const CONTEXT_FLOWS: [string, string][] = [
["chrome", "claude"],
["notion", "raycast"],
["drive", "claude-code"],
["opencode", "codex"],
["claude-code", "mcp"],
["hermes", "openclaw"],
["claude", "mcp"],
["notion", "claude"],
]
function nodeById(id: string) {
return TOOL_NODES.find((node) => node.id === id)
}
function pickContextFlow(): ContextConnection | null {
const flow = CONTEXT_FLOWS[Math.floor(Math.random() * CONTEXT_FLOWS.length)]
if (!flow) return null
const [fromId, toId] = flow
const from = nodeById(fromId)
const to = nodeById(toId)
if (!from || !to) return null
return { from, to }
}
function ToolNodeIcon({
node,
role,
}: {
node: ToolNode
role?: "source" | "destination"
}) {
const Icon = node.icon
return (
<div
className="login-tool-node-wrap"
style={{ left: `${node.x}%`, top: `${node.y}%` }}
>
<div
className={cn(
"login-tool-node",
role === "source" && "login-tool-node-source",
role === "destination" && "login-tool-node-destination",
)}
title={node.name}
>
{Icon ? (
<Icon className="login-tool-node-icon shrink-0" />
) : node.iconSrc ? (
<Image
src={node.iconSrc}
alt=""
width={22}
height={22}
className="login-tool-node-icon shrink-0"
/>
) : null}
</div>
<span
className={cn(
"login-tool-node-label",
dmSansClassName(),
role && "login-tool-node-label-visible",
)}
aria-hidden={!role}
>
{node.name}
</span>
</div>
)
}
function MemoryChip() {
return (
<div className="login-context-chip">
<div className="login-context-chip-lines" aria-hidden>
<span />
<span />
<span />
</div>
</div>
)
}
function AnimatedContextFlow({
connection,
phase,
}: {
connection: ContextConnection
phase: ContextPhase
}) {
const { from, to } = connection
const dIn = `M ${from.x} ${from.y} L ${CENTER.x} ${CENTER.y}`
const dOut = `M ${CENTER.x} ${CENTER.y} L ${to.x} ${to.y}`
const chipLeft =
phase === "recall"
? [`${CENTER.x}%`, `${to.x}%`]
: phase === "hold"
? `${CENTER.x}%`
: [`${from.x}%`, `${CENTER.x}%`]
const chipTop =
phase === "recall"
? [`${CENTER.y}%`, `${to.y}%`]
: phase === "hold"
? `${CENTER.y}%`
: [`${from.y}%`, `${CENTER.y}%`]
return (
<div className="pointer-events-none absolute inset-0 z-[1]">
<svg
className="absolute inset-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
>
<path
d={dIn}
fill="none"
stroke="rgb(75 160 250 / 0.12)"
strokeWidth="2.5"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
<path
d={dOut}
fill="none"
stroke="rgb(75 160 250 / 0.12)"
strokeWidth="2.5"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{(phase === "capture" || phase === "hold") && (
<motion.path
key={`in-${from.id}`}
d={dIn}
fill="none"
stroke="rgb(140 205 255 / 0.75)"
strokeWidth="1.75"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: IN_MS / 1000, ease: [0.33, 0, 0.2, 1] }}
/>
)}
{phase === "recall" && (
<motion.path
key={`out-${to.id}`}
d={dOut}
fill="none"
stroke="rgb(160 220 255 / 0.9)"
strokeWidth="1.75"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: OUT_MS / 1000, ease: [0.33, 0, 0.2, 1] }}
/>
)}
</svg>
{phase !== "idle" && (
<motion.div
className="absolute z-[2] -translate-x-1/2 -translate-y-1/2"
initial={false}
animate={{
left: chipLeft,
top: chipTop,
opacity: phase === "hold" ? 1 : [1, 1],
scale: phase === "hold" ? 1.05 : 1,
}}
transition={{
left: {
duration: phase === "recall" ? OUT_MS / 1000 : IN_MS / 1000,
ease: [0.35, 0, 0.15, 1],
},
top: {
duration: phase === "recall" ? OUT_MS / 1000 : IN_MS / 1000,
ease: [0.35, 0, 0.15, 1],
},
scale: { duration: 0.3 },
}}
>
<MemoryChip />
</motion.div>
)}
</div>
)
}
function ToolsContextNetwork() {
const [connection, setConnection] = useState<ContextConnection | null>(null)
const [pulseId, setPulseId] = useState(0)
const [phase, setPhase] = useState<ContextPhase>("idle")
useEffect(() => {
let cancelled = false
let pulseTimeout: ReturnType<typeof setTimeout>
const runPulse = () => {
if (cancelled) return
const next = pickContextFlow()
if (!next) return
setConnection(next)
setPulseId((id) => id + 1)
pulseTimeout = setTimeout(runPulse, TOTAL_MS + 900 + Math.random() * 500)
}
runPulse()
return () => {
cancelled = true
clearTimeout(pulseTimeout)
}
}, [])
useEffect(() => {
if (!connection) return
setPhase("capture")
const holdTimer = setTimeout(() => setPhase("hold"), IN_MS)
const recallTimer = setTimeout(() => setPhase("recall"), IN_MS + HOLD_MS)
const idleTimer = setTimeout(() => setPhase("idle"), TOTAL_MS)
return () => {
clearTimeout(holdTimer)
clearTimeout(recallTimer)
clearTimeout(idleTimer)
}
}, [connection])
const sourceRole = (nodeId: string) =>
connection &&
connection.from.id === nodeId &&
(phase === "capture" || phase === "hold")
? ("source" as const)
: undefined
const destRole = (nodeId: string) =>
connection && connection.to.id === nodeId && phase === "recall"
? ("destination" as const)
: undefined
return (
<div
className="login-tools-network relative h-full min-h-[240px] w-full lg:min-h-0"
aria-hidden
>
{connection && phase !== "idle" ? (
<AnimatedContextFlow
key={pulseId}
connection={connection}
phase={phase}
/>
) : null}
{TOOL_NODES.map((node) => (
<ToolNodeIcon
key={node.id}
node={node}
role={sourceRole(node.id) ?? destRole(node.id)}
/>
))}
<div className="absolute left-1/2 top-1/2 z-[3] -translate-x-1/2 -translate-y-1/2">
<motion.div
className="relative flex size-24 items-center justify-center sm:size-28 lg:size-36"
animate={{ scale: phase === "hold" ? [1, 1.08, 1] : 1 }}
transition={{ duration: 0.55, ease: "easeOut" }}
>
<NovaOrb size={112} className="blur-[2px]!" />
<div className="absolute inset-0 flex items-center justify-center">
<Logo className="size-6 opacity-80 sm:size-7 lg:size-8" />
</div>
</motion.div>
</div>
</div>
)
}
import OrbitMemory from "@/components/orbit-memory"
function LoginPanelBackground() {
return (
@ -373,6 +14,15 @@ function LoginPanelBackground() {
<div className="login-panel-orb pointer-events-none" aria-hidden />
<div className="login-panel-orb-image" aria-hidden />
<div className="login-panel-orb-image-alt" aria-hidden />
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-[1] opacity-[0.05]"
style={{
backgroundImage:
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20width%3D%27200%27%20height%3D%27200%27%3E%3Cfilter%20id%3D%27n%27%3E%3CfeTurbulence%20type%3D%27fractalNoise%27%20baseFrequency%3D%270.9%27%20numOctaves%3D%272%27%20stitchTiles%3D%27stitch%27%2F%3E%3CfeColorMatrix%20type%3D%27saturate%27%20values%3D%270%27%2F%3E%3C%2Ffilter%3E%3Crect%20width%3D%27100%25%27%20height%3D%27100%25%27%20filter%3D%27url%28%23n%29%27%2F%3E%3C%2Fsvg%3E")',
backgroundSize: "200px 200px",
}}
/>
</>
)
}
@ -382,8 +32,16 @@ export function LoginToolsPanel() {
<aside className="relative hidden min-h-0 flex-col overflow-hidden border-white/[0.06] lg:col-start-1 lg:row-start-1 lg:flex lg:h-full lg:border-r">
<LoginPanelBackground />
<div className="login-tools-panel-inner relative z-10 flex flex-col justify-center px-4 py-8 sm:px-8 lg:px-10">
<ToolsContextNetwork />
<div className="login-tools-panel-inner relative z-10 flex items-center justify-center px-4 py-8 sm:px-8 lg:px-10">
<div className="h-full" style={{ aspectRatio: "780 / 1024" }}>
{/* overflow:visible lets the core glow bleed and fade into the
panel instead of being hard-clipped at the orbit's box edge;
the panel's own overflow-hidden clips it softly at the edges */}
<OrbitMemory
style={{ background: "transparent", overflow: "visible" }}
grain={false}
/>
</div>
</div>
<p

View file

@ -1,310 +0,0 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { usePathname } from "next/navigation"
import { Phone, Users, X as XIcon } from "lucide-react"
import { useLobbyside } from "@lobbyside/react"
import { useAuth } from "@lib/auth-context"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import { analytics } from "@/lib/analytics"
const STORAGE_KEY = "sm_next_app_research_cta_dismissed_v1"
const BOOK_CALL_HREF = "https://cal.com/supermemory/growth"
const LOBBYSIDE_WIDGET_ID = "e385c52f-4dd3-4fb2-81eb-da3a78059014"
function ResearchCtaHeroGraphic({
avatarUrl,
hostName,
}: {
avatarUrl?: string
hostName?: string
}) {
return (
<div
id="next-app-research-cta-hero"
className={cn(
"relative flex min-h-[4.5rem] w-full shrink-0 items-center justify-center overflow-hidden rounded-xl py-5",
"border border-white/[0.1] bg-gradient-to-b from-[#141c28] to-[#0D121A]",
)}
aria-hidden
>
<div
className="pointer-events-none absolute inset-0 opacity-[0.45]"
style={{
background:
"radial-gradient(ellipse 85% 90% at 50% 30%, rgba(59, 130, 246, 0.2), transparent 65%)",
}}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div
className="h-[120%] w-px bg-gradient-to-b from-transparent via-[#5B8DEF]/35 to-transparent"
style={{ transform: "rotate(52deg)" }}
/>
<div
className="absolute h-[120%] w-px bg-gradient-to-b from-transparent via-[#9B7AFF]/30 to-transparent"
style={{ transform: "rotate(-52deg)" }}
/>
</div>
<div className="relative z-10 flex flex-row items-center justify-center gap-10 px-3">
<div className="flex size-9 items-center justify-center">
<Phone className="size-[24px] text-[#7EB0FF]" strokeWidth={1.65} />
</div>
<span
className={cn(
dmSans125ClassName(),
"select-none text-[17px] font-light leading-none text-[#6B9FFF]/75",
)}
>
×
</span>
{avatarUrl ? (
<span className="relative inline-flex">
<img
src={avatarUrl}
alt={hostName ?? ""}
className="size-9 rounded-full object-cover ring-1 ring-[#B49CFB]/40"
/>
<span
aria-hidden
className="absolute bottom-0 right-0 size-[10px] rounded-full bg-[#22c55e] ring-2 ring-[#0D121A]"
/>
</span>
) : (
<div className="flex size-9 items-center justify-center">
<Users className="size-[24px] text-[#B49CFB]" strokeWidth={1.65} />
</div>
)}
</div>
</div>
)
}
export function NextAppResearchCta() {
const pathname = usePathname()
const [mounted, setMounted] = useState(false)
const [dismissed, setDismissed] = useState(false)
const widget = useLobbyside(LOBBYSIDE_WIDGET_ID)
const { user, org } = useAuth()
useEffect(() => {
setMounted(true)
setDismissed(localStorage.getItem(STORAGE_KEY) === "1")
}, [])
const handleDismiss = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
localStorage.setItem(STORAGE_KEY, "1")
setDismissed(true)
analytics.nextAppResearchCtaDismissed()
}, [])
const handleJoinCall = useCallback(async () => {
if (widget.status !== "online" || widget.isQueueFull) return
analytics.nextAppResearchCtaLobbysideCallClicked()
// Open the tab synchronously so Safari/iOS keep the user-activation
// gesture. We redirect it once joinCall() resolves, or fall back to
// the book-a-call URL if the host goes offline / queue fills / the
// request errors between render and click.
const pendingTab = window.open("", "_blank")
const navigate = (url: string) => {
if (pendingTab && !pendingTab.closed) {
pendingTab.location.href = url
} else {
window.open(url, "_blank", "noopener,noreferrer")
}
}
try {
const visitor: Record<string, string> = {}
if (user?.email) visitor.email = user.email
if (user?.name) visitor.name = user.name
if (org?.name) visitor.company = org.name
const github = (user as { github?: unknown } | null)?.github
if (typeof github === "string" && github) visitor.github = github
const joinArgs = Object.keys(visitor).length > 0 ? { visitor } : undefined
const { entryUrl } = await widget.joinCall(joinArgs)
navigate(entryUrl)
} catch (err) {
console.error("[Lobbyside] joinCall failed", err)
navigate(BOOK_CALL_HREF)
}
}, [widget, user, org])
const handleCardKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLElement>) => {
if (e.target !== e.currentTarget) return
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
handleJoinCall()
}
},
[handleJoinCall],
)
const handleBookClick = useCallback(() => {
analytics.nextAppResearchCtaBookCallClicked()
}, [])
if (
!mounted ||
dismissed ||
pathname.startsWith("/onboarding") ||
widget.status === "loading"
) {
return null
}
const cardBaseClasses = cn(
"fixed z-[45] bottom-4 left-4 min-w-[280px] max-w-[min(calc(100vw-2rem),22.5rem)]",
"rounded-xl border border-white/[0.08] bg-[#0D121A]/95 backdrop-blur-md",
"shadow-[0_8px_32px_rgba(0,0,0,0.35)] p-3.5",
)
if (widget.status !== "online" || widget.isQueueFull) {
return (
<section
id="next-app-research-cta"
className={cardBaseClasses}
aria-label="Research participant invitation"
>
<div className="flex flex-col gap-3">
<ResearchCtaHeroGraphic />
<div className="min-w-0 w-full">
<div className="flex items-start gap-1">
<p
className={cn(
dmSans125ClassName(),
"flex-1 min-w-0 font-medium text-[12px] text-[#FAFAFA] tracking-[-0.12px]",
)}
>
Be part of the next supermemory app
</p>
<button
type="button"
onClick={handleDismiss}
className={cn(
"shrink-0 rounded-md p-1 -mr-1 -mt-0.5",
"text-muted-foreground hover:text-foreground transition-colors",
"cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label="Dismiss"
>
<XIcon className="size-4" />
</button>
</div>
<p
className={cn(
dmSans125ClassName(),
"mt-1 text-[12px] text-[#737373] tracking-[-0.12px]",
)}
>
Share what you want next. Wed love a quick call.
</p>
<div className="mt-2.5 flex justify-end">
<a
href={BOOK_CALL_HREF}
onClick={handleBookClick}
target="_blank"
rel="noopener noreferrer"
className={cn(
dmSans125ClassName(),
"inline-flex text-[13px] font-medium text-[#A3A3A3]",
"tracking-[-0.13px] underline underline-offset-4 decoration-white/20",
"hover:text-[#FAFAFA] hover:decoration-white/40 transition-colors",
)}
>
Book a call
</a>
</div>
</div>
</div>
</section>
)
}
return (
<button
type="button"
id="next-app-research-cta"
tabIndex={0}
onClick={handleJoinCall}
onKeyDown={handleCardKeyDown}
className={cn(
cardBaseClasses,
"cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label={`${widget.buttonText} with ${widget.hostName}`}
>
<div className="flex flex-col gap-3">
<ResearchCtaHeroGraphic
avatarUrl={widget.avatarUrl}
hostName={widget.hostName}
/>
<div className="min-w-0 w-full">
<div className="flex items-start gap-1">
<p
className={cn(
dmSans125ClassName(),
"flex-1 min-w-0 font-medium text-[12px] text-[#FAFAFA] tracking-[-0.12px]",
)}
>
{widget.ctaText}
</p>
<button
type="button"
onClick={handleDismiss}
className={cn(
"shrink-0 rounded-md p-1 -mr-1 -mt-0.5",
"text-muted-foreground hover:text-foreground transition-colors",
"cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label="Dismiss"
>
<XIcon className="size-4" />
</button>
</div>
<div className="mt-2.5 flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<p
className={cn(
dmSans125ClassName(),
"truncate text-[12px] font-medium text-[#FAFAFA] tracking-[-0.12px]",
)}
>
{widget.hostName}
</p>
{widget.hostTitle ? (
<p
className={cn(
dmSans125ClassName(),
"truncate text-[11px] text-[#737373] tracking-[-0.11px]",
)}
>
{widget.hostTitle}
</p>
) : null}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleJoinCall()
}}
className={cn(
dmSans125ClassName(),
"shrink-0 inline-flex text-[13px] font-medium text-[#A3A3A3]",
"tracking-[-0.13px] underline underline-offset-4 decoration-white/20",
"hover:text-[#FAFAFA] hover:decoration-white/40 transition-colors",
"cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm",
)}
>
{widget.buttonText}
</button>
</div>
</div>
</div>
</button>
)
}

View file

@ -9,11 +9,12 @@ import { BRAIN_STEPS, BRAIN_STEP_LABELS, type BrainStep } from "./types"
interface ShellProps {
step: BrainStep
domain?: string | null
steps?: BrainStep[]
children: React.ReactNode
}
export function BrainShell({ step, children }: ShellProps) {
const visibleSteps: BrainStep[] = BRAIN_STEPS
export function BrainShell({ step, steps, children }: ShellProps) {
const visibleSteps: BrainStep[] = steps ?? BRAIN_STEPS
return (
<div

View file

@ -11,6 +11,7 @@ import {
Building2,
LayoutGrid,
Loader2,
Mail,
Plug,
Terminal,
User2,
@ -31,6 +32,7 @@ export interface AboutValues {
interface Props {
mode: BrainMode
onModeChange: (m: BrainMode) => void
allowTeam: boolean
domain: string | null
suggestedWorkspaceName: string
defaultName: string
@ -58,6 +60,7 @@ const inputClass =
export function StepAbout({
mode,
onModeChange,
allowTeam,
domain,
suggestedWorkspaceName,
defaultName,
@ -80,8 +83,11 @@ export function StepAbout({
if (Object.keys(patch).length > 0) onChange({ ...values, ...patch })
}, [defaultName, suggestedWorkspaceName, domain])
const teamGated = mode === "team" && !allowTeam
const canContinue =
values.name.trim().length > 0 && values.workspaceName.trim().length > 0
!teamGated &&
values.name.trim().length > 0 &&
values.workspaceName.trim().length > 0
return (
<div className="space-y-5">
@ -146,7 +152,9 @@ export function StepAbout({
<ModeToggle mode={mode} onChange={onModeChange} />
<div className="mt-6">
{mode === "team" ? (
{teamGated ? (
<TeamBetaGate onUsePersonal={() => onModeChange("personal")} />
) : mode === "team" ? (
<TeamWorkspaceCard
domain={values.workspaceDomain || domain || ""}
onDomainChange={(d) =>
@ -358,6 +366,57 @@ function PersonalWorkspaceCard({
)
}
function TeamBetaGate({ onUsePersonal }: { onUsePersonal: () => void }) {
return (
<div
className="relative overflow-hidden rounded-[14px] bg-[#1B1F24] p-5 md:p-6"
style={cardSurfaceStyle}
>
<div
aria-hidden
className="absolute -top-px left-0 right-0 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
}}
/>
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#4BA0FA]">
Private beta
</p>
<p
className={cn(
"mt-1.5 text-[15px] font-semibold leading-snug text-[#fafafa]",
dmSans125ClassName(),
)}
>
Team workspaces are invite-only
</p>
<p className="mt-1.5 text-[13px] leading-relaxed text-[#737373]">
We're onboarding teams to Company Brain one at a time. Email us for
access or start with a personal workspace and invite your team later.
</p>
<div className="mt-4 flex w-full flex-col items-center gap-2.5">
<a
href="mailto:support@supermemory.com?subject=Company%20Brain%20beta%20access"
className="group inline-flex items-center gap-2 rounded-[10px] border border-[rgba(82,89,102,0.2)] bg-[#14161A] px-3.5 py-2 text-[13px] font-medium text-[#fafafa] transition-colors hover:border-[rgba(75,160,250,0.4)]"
>
<Mail className="size-3.5 text-[#4BA0FA]" />
support@supermemory.com
<ArrowRight className="size-3.5 text-[#525D6E] transition-colors group-hover:text-[#fafafa]" />
</a>
<button
type="button"
onClick={onUsePersonal}
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
>
Start with a personal workspace
<ArrowRight className="size-3.5" />
</button>
</div>
</div>
)
}
function ModeToggle({
mode,
onChange,

View file

@ -1,20 +1,89 @@
"use client"
import { useState, useEffect } from "react"
import { useEffect, useMemo, useState } from "react"
import type { ReactNode } from "react"
import Image from "next/image"
import { useQueryState, parseAsString } from "nuqs"
import Link from "next/link"
import { Button } from "@ui/components/button"
import { MCPIcon } from "@ui/assets/icons"
import { ArrowRight, Check, Copy, EyeOff, Eye } from "lucide-react"
import {
ArrowRight,
Check,
Copy,
ExternalLink,
Loader2,
Plug,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { dmSans125ClassName } from "@/lib/fonts"
import { toast } from "sonner"
import { MCPSteps } from "@/components/mcp-modal/mcp-detail-view"
import { PLUGIN_CATALOG } from "@/lib/plugin-catalog"
import { analytics } from "@/lib/analytics"
import type { BrainMode } from "./types"
interface Props {
mcpUrl: string
onContinue: () => void
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const TEST_PROMPT = "What do we know about [topic]?"
type FlowToolId = "slack" | "mcp" | "codex" | "claude-code"
type FlowToolKind = "slack" | "mcp" | "plugin"
type FlowTool = {
id: FlowToolId
label: string
blurb: string
kind: FlowToolKind
pluginId?: string
recommended?: boolean
}
const TOOL_OPTIONS: Record<BrainMode, [FlowTool, ...FlowTool[]]> = {
team: [
{
id: "slack",
label: "Slack",
blurb: "Ask questions in-channel.",
kind: "slack",
recommended: true,
},
{
id: "claude-code",
label: "Claude Code",
blurb: "Shared context in your terminal.",
kind: "plugin",
pluginId: "claude_code",
},
{
id: "codex",
label: "Codex",
blurb: "OpenAI's coding agent.",
kind: "plugin",
pluginId: "codex",
},
],
personal: [
{
id: "mcp",
label: "MCP",
blurb: "Use the universal URL in any client.",
kind: "mcp",
recommended: true,
},
{
id: "codex",
label: "Codex",
blurb: "OpenAI's coding agent.",
kind: "plugin",
pluginId: "codex",
},
{
id: "claude-code",
label: "Claude Code",
blurb: "Context in your terminal.",
kind: "plugin",
pluginId: "claude_code",
},
],
}
const modalCardStyle = {
@ -27,175 +96,440 @@ const inputBevelStyle = {
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
}
type AgentCategory = "coding" | "productivity"
type Agent = {
key: string
name: string
tagline: string
category: AgentCategory
pluginId?: string
interface Props {
mode: BrainMode
mcpUrl: string
onContinue: () => void
}
const AGENTS: Agent[] = [
{
key: "cursor",
name: "Cursor",
tagline: "Persistent context across coding sessions.",
category: "coding",
},
{
key: "claude-code",
name: "Claude Code",
tagline: "Memory and decisions across CLI sessions.",
category: "coding",
pluginId: "claude_code",
},
{
key: "vscode",
name: "VS Code",
tagline: "Inline context while you write.",
category: "coding",
},
{
key: "cline",
name: "Cline",
tagline: "Agentic dev tasks with your memory.",
category: "coding",
},
{
key: "codex",
name: "Codex",
tagline: "OpenAI Codex with persistent memory.",
category: "coding",
pluginId: "codex",
},
{
key: "gemini-cli",
name: "Gemini CLI",
tagline: "Gemini in your terminal, brain-aware.",
category: "coding",
},
{
key: "claude",
name: "Claude Desktop",
tagline: "Memory across every Claude conversation.",
category: "productivity",
},
{
key: "chatgpt",
name: "ChatGPT",
tagline: "Custom GPT backed by your brain.",
category: "productivity",
},
]
const CATEGORY_ORDER: { id: AgentCategory; label: string }[] = [
{ id: "coding", label: "Coding" },
{ id: "productivity", label: "Productivity" },
]
function agentIcon(agent: Agent) {
if (agent.pluginId) {
const plugin = PLUGIN_CATALOG[agent.pluginId]
if (plugin) return plugin.icon
}
const file = agent.key === "claude-code" ? "claude" : agent.key
return `/mcp-supported-tools/${file}.png`
}
export function StepIngest({ mcpUrl, onContinue }: Props) {
const [activeCategory, setActiveCategory] = useState<AgentCategory>("coding")
const [selectedKey, setSelectedKey] = useState<string>("cursor")
const [, setMcpClient] = useQueryState("mcpClient", parseAsString)
const selectedAgent = AGENTS.find((a) => a.key === selectedKey) ?? AGENTS[0]
export function StepIngest({ mode, mcpUrl, onContinue }: Props) {
const tools = TOOL_OPTIONS[mode]
const [selected, setSelected] = useState<FlowToolId>(tools[0].id)
useEffect(() => {
if (selectedAgent && !selectedAgent.pluginId) {
setMcpClient(selectedAgent.key)
} else {
setMcpClient(null)
}
}, [selectedAgent, setMcpClient])
setSelected(tools[0].id)
}, [tools])
const selectAgent = (agent: Agent) => {
setSelectedKey(agent.key)
const activeTool = useMemo(
() => tools.find((t) => t.id === selected) ?? tools[0],
[tools, selected],
)
const handleContinue = () => {
analytics.onboardingIngestCompleted()
onContinue()
}
const filtered = AGENTS.filter((a) => a.category === activeCategory)
const handleSkip = () => {
analytics.onboardingIngestSkipped()
onContinue()
}
return (
<div className="space-y-5">
<div className="px-1">
<p
className={cn(
"font-semibold text-[#fafafa] text-[22px]",
dmSans125ClassName(),
)}
>
Use your brain anywhere
</p>
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
Now plug it into the tools you already use to write code, chat, think.
</p>
</div>
<div className="mx-auto w-full max-w-[900px] pb-10">
<section className="relative py-4">
<div className="mb-6 px-1">
<p
className={cn(
"text-[22px] font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Use your brain where you work
</p>
<p className="mt-1.5 text-[15px] font-medium leading-[1.4] text-[#737373]">
{mode === "team"
? "Pick where your team asks questions, then set it up."
: "Pick the tool you open every day — about 60 seconds."}
</p>
</div>
<McpHero url={mcpUrl} />
<div className="grid lg:grid-cols-[300px_1fr] gap-4 h-[560px]">
<aside
className="rounded-[16px] bg-[#1B1F24] p-3 flex flex-col gap-3 overflow-hidden h-full"
style={modalCardStyle}
>
<CategoryTabs value={activeCategory} onChange={setActiveCategory} />
<div className="flex-1 min-h-0 overflow-y-auto space-y-1 scrollbar-thin pr-1">
{filtered.map((agent) => (
<AgentRow
key={agent.key}
agent={agent}
active={selectedKey === agent.key}
onClick={() => selectAgent(agent)}
<div className="grid items-start gap-4 lg:grid-cols-[250px_minmax(0,1fr)]">
{/* Left rail: pick a tool */}
<div className="flex flex-col gap-2">
{tools.map((tool) => (
<FlowToolRow
key={tool.id}
tool={tool}
active={selected === tool.id}
onSelect={() => {
analytics.onboardingAgentSelected({ agent: tool.id })
setSelected(tool.id)
}}
/>
))}
<Link
href="/settings/integrations"
className="mt-1 inline-flex items-center gap-1.5 px-2 py-1.5 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
>
More tools
<span className="text-[#525D6E]">(full catalog)</span>
<ExternalLink className="size-3.5" aria-hidden />
</Link>
</div>
</aside>
<section
className="rounded-[16px] bg-[#1B1F24] p-5 md:p-6 overflow-hidden flex flex-col h-full"
style={modalCardStyle}
>
{selectedAgent?.pluginId ? (
<PluginSteps pluginId={selectedAgent.pluginId} />
) : (
<MCPSteps variant="embedded" />
)}
</section>
{/* Right pane: setup detail */}
<div
className="relative flex min-h-[300px] flex-col overflow-hidden rounded-[22px] bg-[#1B1F24] p-6 md:p-7"
style={modalCardStyle}
>
<div
aria-hidden
className="absolute -top-px right-10 left-10 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.4), transparent)",
}}
/>
<div className="flex flex-1 flex-col">
<div className="mb-5 flex items-center gap-3">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
style={inputBevelStyle}
>
<ToolIcon id={activeTool.id} />
</div>
<div>
<p className="text-[18px] font-semibold text-[#fafafa]">
Set up {activeTool.label}
</p>
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
{activeTool.blurb}
</p>
</div>
</div>
{activeTool.kind === "slack" ? (
<SlackSetupPanel />
) : activeTool.kind === "mcp" ? (
<McpGenericSetup mcpUrl={mcpUrl} />
) : activeTool.pluginId ? (
<PluginSetup
key={activeTool.pluginId}
pluginId={activeTool.pluginId}
/>
) : null}
</div>
<div className="mt-6 flex items-center justify-end gap-[22px] border-t border-white/[0.06] pt-5">
<button
type="button"
onClick={handleSkip}
className="text-[14px] font-medium text-[#737373] transition-colors hover:text-[#999]"
>
Skip for now
</button>
<Button
variant="insideOut"
onClick={handleContinue}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
Continue
<ArrowRight className="size-3.5" />
</Button>
</div>
</div>
</div>
</section>
</div>
)
}
function ToolIcon({ id, className }: { id: FlowToolId; className?: string }) {
if (id === "slack") return <SlackMark className={className ?? "size-5"} />
if (id === "mcp")
return <Plug className={cn("text-[#4BA0FA]", className ?? "size-5")} />
const file = id === "claude-code" ? "claude" : id
return (
<Image
src={`/mcp-supported-tools/${file}.png`}
alt=""
width={28}
height={28}
unoptimized
className={cn("object-contain", className ?? "size-5")}
/>
)
}
function FlowToolRow({
tool,
active,
onSelect,
}: {
tool: FlowTool
active: boolean
onSelect: () => void
}) {
return (
<button
type="button"
onClick={onSelect}
aria-pressed={active}
className={cn(
"group relative flex w-full items-center gap-3 overflow-hidden rounded-[14px] p-3 text-left transition-all duration-150",
active
? "bg-[#10151D] ring-2 ring-[#4BA0FA]/45"
: "bg-[#1B1F24] ring-1 ring-white/[0.05] hover:ring-white/[0.12]",
)}
style={modalCardStyle}
>
{active && (
<div
aria-hidden
className="absolute -top-px right-5 left-5 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.55), transparent)",
}}
/>
)}
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-[10px] border bg-[#14161A] transition-colors",
active ? "border-[#2261CA]/45" : "border-[rgba(82,89,102,0.2)]",
)}
style={inputBevelStyle}
>
<ToolIcon id={tool.id} />
</div>
<div className="flex flex-wrap items-center justify-end gap-[22px] px-1 pt-2">
<button
type="button"
onClick={onContinue}
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors"
>
Skip for now
</button>
<Button
variant="insideOut"
onClick={onContinue}
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
>
Continue
<ArrowRight className="size-3.5" />
</Button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-[14px] font-semibold leading-tight text-[#fafafa]">
{tool.label}
</p>
{tool.recommended && (
<span className="shrink-0 rounded-full bg-[#4BA0FA]/12 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[#4BA0FA]">
Recommended
</span>
)}
</div>
<p className="mt-0.5 truncate text-[12px] font-medium text-[#737373]">
{tool.blurb}
</p>
</div>
<span
aria-hidden
className={cn(
"flex size-[18px] shrink-0 items-center justify-center rounded-full border transition-colors",
active
? "border-[#4BA0FA] bg-[#4BA0FA]"
: "border-[rgba(82,89,102,0.4)] group-hover:border-[rgba(115,115,115,0.5)]",
)}
>
{active && <Check className="size-3 text-white" />}
</span>
</button>
)
}
function StepRow({
index,
title,
done,
children,
}: {
index: number
title: ReactNode
done?: boolean
children?: ReactNode
}) {
return (
<div className="flex gap-3">
<span
aria-hidden
className={cn(
"mt-0.5 flex size-[22px] shrink-0 items-center justify-center rounded-full text-[12px] font-semibold transition-colors",
done
? "bg-[#4BA0FA] text-white"
: "border border-[rgba(82,89,102,0.3)] bg-[#14161A] text-[#737373]",
)}
>
{done ? <Check className="size-3" /> : index}
</span>
<div className="min-w-0 flex-1 pt-0.5">
<div className="text-[13px] font-medium leading-[1.5] text-[#fafafa]">
{title}
</div>
{children ? <div className="mt-2.5">{children}</div> : null}
</div>
</div>
)
}
function McpHero({ url }: { url: string }) {
// Coding-agent plugins (Codex, Claude Code) auto-login via OAuth, so the
// "Save your API key" step is dropped — we render the remaining install steps.
function PluginSetup({ pluginId }: { pluginId: string }) {
const plugin = PLUGIN_CATALOG[pluginId]
const steps = (plugin?.installSteps ?? []).filter(
(s) => !s.secret && !s.code?.includes("sm_..."),
)
return (
<div className="space-y-4">
{steps.map((step, i) => (
<StepRow key={step.title} index={i + 1} title={step.title}>
{step.description ? (
<p className="mb-2 text-[12px] font-medium leading-[1.5] text-[#737373]">
{step.description}
</p>
) : null}
{step.code ? <CopyCodeBlock code={step.code} /> : null}
</StepRow>
))}
<StepRow index={steps.length + 1} title="Ask your brain to test it">
<CopyCodeBlock code={TEST_PROMPT} />
</StepRow>
</div>
)
}
function McpGenericSetup({ mcpUrl }: { mcpUrl: string }) {
return (
<div className="space-y-4">
<StepRow index={1} title="Copy your universal MCP URL">
<McpUrlRow url={mcpUrl} />
</StepRow>
<StepRow index={2} title="Paste it into any MCP client">
<Link
href="/settings/integrations"
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
>
Per-client setup guides
<ExternalLink className="size-3.5" aria-hidden />
</Link>
</StepRow>
<StepRow index={3} title="Ask your brain to test it">
<CopyCodeBlock code={TEST_PROMPT} />
</StepRow>
</div>
)
}
function SlackSetupPanel() {
const [status, setStatus] = useState<{
connected: boolean
teamName: string | null
} | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
;(async () => {
try {
const res = await fetch(`${BACKEND}/brain/slack/status`, {
credentials: "include",
})
if (active && res.ok) {
setStatus(
(await res.json()) as {
connected: boolean
teamName: string | null
},
)
}
} finally {
if (active) setLoading(false)
}
})()
return () => {
active = false
}
}, [])
const connected = status?.connected ?? false
return (
<div className="space-y-4">
<StepRow
index={1}
done={connected}
title={
connected
? `Connected to ${status?.teamName ?? "your workspace"}`
: "Add Supermemory to your Slack workspace"
}
>
{!connected &&
(loading ? (
<span className="inline-flex items-center gap-2 text-[12px] font-medium text-[#737373]">
<Loader2 className="size-3.5 animate-spin" />
Checking
</span>
) : (
<Button
variant="insideOut"
asChild
className="h-9 gap-2 rounded-full px-4 text-[13px] font-medium text-[#fafafa]"
>
<a href={`${BACKEND}/brain/slack/oauth/install`}>
<SlackMark className="size-4" />
Add to Slack
</a>
</Button>
))}
</StepRow>
<StepRow
index={2}
title={
<>
Mention <span className="text-[#4BA0FA]">@supermemory</span> in{" "}
<span className="font-mono text-[12px]">#general</span>
</>
}
/>
<StepRow index={3} title="Ask your brain to test it">
<CopyCodeBlock code="@supermemory what do we know about [topic]?" />
</StepRow>
</div>
)
}
function CopyCodeBlock({ code }: { code: string }) {
const [copied, setCopied] = useState(false)
const copy = async () => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
toast.success("Copied")
setTimeout(() => setCopied(false), 1500)
} catch {
toast.error("Could not copy")
}
}
return (
<div
className="flex min-w-0 flex-1 items-start gap-2 rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#0F1217] p-3"
style={inputBevelStyle}
>
<pre className="min-w-0 flex-1 overflow-x-auto whitespace-pre font-mono text-[11px] text-[#fafafa]">
{code}
</pre>
<button
type="button"
onClick={copy}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-[#737373] transition-colors hover:text-[#fafafa]"
aria-label="Copy"
>
{copied ? (
<Check className="size-3.5 text-[#4BA0FA]" />
) : (
<Copy className="size-3.5" />
)}
</button>
</div>
)
}
function McpUrlRow({ url }: { url: string }) {
const [copied, setCopied] = useState(false)
const copy = async () => {
try {
await navigator.clipboard.writeText(url)
@ -208,36 +542,22 @@ function McpHero({ url }: { url: string }) {
}
return (
<section
className="rounded-[16px] bg-[#1B1F24] px-5 py-3 flex items-center gap-3 relative overflow-hidden"
style={modalCardStyle}
>
<div className="flex flex-wrap items-center gap-3">
<div
aria-hidden
className="absolute -top-px left-0 right-0 h-px"
style={{
background:
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
}}
/>
<div
className="size-9 rounded-[10px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center shrink-0"
className="min-w-0 flex-1 rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#0F1217] px-4 py-3"
style={inputBevelStyle}
>
<MCPIcon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[10px] uppercase tracking-[0.08em] text-[#737373] font-semibold">
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[#525D6E]">
Universal MCP URL
</p>
<p className="text-[13px] text-[#fafafa] font-mono truncate mt-0.5">
<p className="mt-0.5 truncate font-mono text-[13px] text-[#fafafa]">
{url}
</p>
</div>
<Button
variant="insideOut"
onClick={copy}
className="rounded-full h-9 px-4 text-[12px] font-medium text-[#fafafa] shrink-0"
className="h-9 shrink-0 rounded-full px-4 text-[12px] font-medium text-[#fafafa]"
>
{copied ? (
<Check className="size-3.5" />
@ -246,235 +566,46 @@ function McpHero({ url }: { url: string }) {
)}
{copied ? "Copied" : "Copy URL"}
</Button>
</section>
)
}
function PluginSteps({ pluginId }: { pluginId: string }) {
const plugin = PLUGIN_CATALOG[pluginId]
if (!plugin) return null
const steps = plugin.installSteps ?? []
return (
<div className="h-full overflow-y-auto pr-1 scrollbar-thin">
<div className="flex items-center gap-3 mb-4">
<div className="size-10 rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)] flex items-center justify-center shrink-0 overflow-hidden">
<Image
src={plugin.icon}
alt={plugin.name}
width={28}
height={28}
unoptimized
className="size-6 object-contain"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-[16px] font-semibold text-[#fafafa] leading-tight">
Set up {plugin.name}
</p>
<p className="text-[12px] text-[#A1A1AA] font-medium mt-0.5">
{plugin.tagline}
</p>
</div>
{plugin.docsUrl && (
<a
href={plugin.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[12px] text-[#A1A1AA] hover:text-[#fafafa] transition-colors font-medium shrink-0"
>
Docs
</a>
)}
</div>
<div className="space-y-3">
{steps.map((step, i) => (
<PluginStep key={step.title} idx={i + 1} step={step} />
))}
</div>
<div className="mt-4 rounded-[10px] border border-[#4BA0FA]/20 bg-[#4BA0FA]/[0.04] p-3 flex items-start gap-2">
<div className="text-[11px] text-[#A1A1AA] leading-[1.5] font-medium">
Your <span className="text-[#fafafa]">API key</span> is minted in
Settings Integrations Plugins. Mint it once and paste into the
step above.
</div>
</div>
</div>
)
}
function PluginStep({
idx,
step,
}: {
idx: number
step: import("@/lib/plugin-catalog").InstallStep
}) {
const [revealed, setRevealed] = useState(false)
const [copied, setCopied] = useState(false)
const copy = async () => {
if (!step.code) return
try {
await navigator.clipboard.writeText(step.code)
setCopied(true)
toast.success("Copied")
setTimeout(() => setCopied(false), 1500)
} catch {
toast.error("Could not copy")
}
}
function SlackMark({ className }: { className?: string }) {
return (
<div className="flex gap-3">
<div className="flex flex-col items-center shrink-0 pt-1">
<div className="size-5 rounded-full bg-[#4BA0FA]/15 text-[#4BA0FA] flex items-center justify-center text-[10px] font-semibold">
{idx}
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-semibold text-[#fafafa]">
{step.title}
{step.optional && (
<span className="ml-2 text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
Optional
</span>
)}
</p>
{step.description && (
<p className="text-[12px] text-[#A1A1AA] mt-1 leading-[1.5] font-medium">
{step.description}
</p>
)}
{step.code && (
<div
className="mt-2 rounded-[10px] bg-[#0F1217] border border-[rgba(82,89,102,0.2)] p-3 flex items-start gap-2"
style={inputBevelStyle}
>
<pre
className={cn(
"flex-1 min-w-0 text-[11px] text-[#fafafa] font-mono overflow-x-auto whitespace-pre",
step.secret && !revealed && "blur-[3px] select-none",
)}
>
{step.code}
</pre>
<div className="flex items-center gap-1 shrink-0">
{step.secret && (
<button
type="button"
onClick={() => setRevealed((v) => !v)}
className="size-7 rounded-md text-[#737373] hover:text-[#fafafa] flex items-center justify-center transition-colors"
aria-label={revealed ? "Hide" : "Reveal"}
>
{revealed ? (
<EyeOff className="size-3.5" />
) : (
<Eye className="size-3.5" />
)}
</button>
)}
<button
type="button"
onClick={copy}
className="size-7 rounded-md text-[#737373] hover:text-[#fafafa] flex items-center justify-center transition-colors"
aria-label="Copy"
>
{copied ? (
<Check className="size-3.5 text-[#4BA0FA]" />
) : (
<Copy className="size-3.5" />
)}
</button>
</div>
</div>
)}
</div>
</div>
)
}
function CategoryTabs({
value,
onChange,
}: {
value: AgentCategory
onChange: (c: AgentCategory) => void
}) {
const counts: Record<AgentCategory, number> = {
coding: 0,
productivity: 0,
}
for (const a of AGENTS) counts[a.category] += 1
return (
<div className="scrollbar-none flex items-center gap-0.5 overflow-x-auto rounded-full bg-[#0D121A] p-0.5 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)] w-full">
{CATEGORY_ORDER.map((cat) => {
const isActive = value === cat.id
return (
<button
key={cat.id}
type="button"
onClick={() => onChange(cat.id)}
className={cn(
dmSansClassName(),
"flex flex-1 h-7 shrink-0 items-center justify-center gap-1.5 rounded-full px-3 text-[12px] font-medium leading-none transition-colors",
isActive
? "bg-white/[0.10] text-[#FAFAFA]"
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
)}
>
<span className="leading-none">{cat.label}</span>
<span
className={cn(
"text-[10px] font-semibold tabular-nums leading-none",
isActive ? "text-[#A1A1AA]" : "text-[#525D6E]",
)}
>
{counts[cat.id]}
</span>
</button>
)
})}
</div>
)
}
function AgentRow({
agent,
active,
onClick,
}: {
agent: Agent
active: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
dmSansClassName(),
"w-full text-left flex items-center gap-2.5 rounded-[10px] px-2.5 py-2 transition-colors",
active
? "bg-white/[0.08] text-[#fafafa]"
: "text-[#A1A1AA] hover:bg-white/[0.04] hover:text-[#fafafa]",
)}
>
<div className="size-8 rounded-[8px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)] flex items-center justify-center shrink-0 overflow-hidden">
<Image
src={agentIcon(agent)}
alt={agent.name}
width={24}
height={24}
unoptimized
className="size-5 object-contain"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-medium truncate">{agent.name}</p>
<p className="text-[11px] text-[#737373] truncate font-medium">
{agent.tagline}
</p>
</div>
</button>
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
<title>Slack</title>
<path
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
fill="#E01E5A"
/>
<path
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
fill="#E01E5A"
/>
<path
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
fill="#36C5F0"
/>
<path
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
fill="#36C5F0"
/>
<path
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
fill="#2EB67D"
/>
<path
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
fill="#2EB67D"
/>
<path
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
fill="#ECB22E"
/>
<path
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
fill="#ECB22E"
/>
</svg>
)
}

View file

@ -108,6 +108,7 @@ import {
} from "@lib/constants"
import { useCustomer } from "autumn-js/react"
import { toast } from "sonner"
import { analytics } from "@/lib/analytics"
import type { BrainMode } from "./types"
type SourceId =
@ -213,6 +214,9 @@ const PLAN_CARDS: PlanCardDefinition[] = [
const PLAN_CARD_SCROLL_STEP = 406
const countsAsConnected = (state: SourceState | undefined) =>
state === "connected" || state === "waitlist"
export interface SourcesValues {
connected: Partial<Record<SourceId, SourceState>>
driveScope: DriveScope
@ -309,6 +313,7 @@ export function StepSources({
provider: "google-drive" | "notion" | "onedrive",
id: SourceId,
) => {
analytics.onboardingIntegrationClicked({ integration: provider })
setState(id, "connecting")
try {
const metadata: Record<string, string> = {}
@ -338,10 +343,16 @@ export function StepSources({
}
const openExternal = (id: SourceId, url: string) => {
analytics.onboardingIntegrationClicked({ integration: id })
window.open(url, "_blank", "noopener,noreferrer")
setState(id, "connected")
}
const requestWaitlist = (id: SourceId) => {
analytics.onboardingIntegrationClicked({ integration: id })
setState(id, "waitlist")
}
const guard = (
plan: RequiredPlan | undefined,
title: string,
@ -359,9 +370,14 @@ export function StepSources({
}
const connectedCount = Object.values(values.connected).filter(
(s) => s === "connected" || s === "waitlist",
countsAsConnected,
).length
const handleContinue = () => {
analytics.onboardingSourcesCompleted({ connected_count: connectedCount })
onContinue()
}
return (
<div className="mx-auto w-full max-w-[1400px] pb-10">
<section className="relative min-h-[calc(100dvh-136px)] py-4">
@ -454,7 +470,7 @@ export function StepSources({
</button>
<SourceActions
connectedCount={connectedCount}
onContinue={onContinue}
onContinue={handleContinue}
className="mt-0 px-0"
/>
</div>
@ -467,8 +483,8 @@ export function StepSources({
onChange={onChange}
isLocked={isLocked}
guard={guard}
setState={setState}
openExternal={openExternal}
requestWaitlist={requestWaitlist}
connectRealProvider={connectRealProvider}
/>
</div>
@ -917,8 +933,8 @@ function MoreSourcesGrid({
onChange,
isLocked,
guard,
setState,
openExternal,
requestWaitlist,
connectRealProvider,
}: {
mode: BrainMode
@ -930,8 +946,8 @@ function MoreSourcesGrid({
title: string,
fn: () => void,
) => () => void
setState: (id: SourceId, state: SourceState) => void
openExternal: (id: SourceId, url: string) => void
requestWaitlist: (id: SourceId) => void
connectRealProvider: (
provider: "google-drive" | "notion" | "onedrive",
id: SourceId,
@ -1027,7 +1043,7 @@ function MoreSourcesGrid({
"Decisions and follow-ups surfaced",
"You control which labels sync",
]}
onConnect={guard("max", "Gmail", () => setState("gmail", "waitlist"))}
onConnect={guard("max", "Gmail", () => requestWaitlist("gmail"))}
/>
<SourceCard
title="GitHub"
@ -1042,7 +1058,7 @@ function MoreSourcesGrid({
"READMEs and docs indexed",
"Stays in sync with new activity",
]}
onConnect={guard("max", "GitHub", () => setState("github", "waitlist"))}
onConnect={guard("max", "GitHub", () => requestWaitlist("github"))}
/>
<SourceCard
title="Granola"
@ -1057,9 +1073,10 @@ function MoreSourcesGrid({
"Decisions and action items extracted",
"Synced after every meeting",
]}
onConnect={guard("max", "Granola", () =>
toast.info("Granola is coming soon."),
)}
onConnect={guard("max", "Granola", () => {
analytics.onboardingIntegrationClicked({ integration: "granola" })
toast.info("Granola is coming soon.")
})}
/>
</>
)

File diff suppressed because one or more lines are too long

View file

@ -5,6 +5,7 @@ import Image from "next/image"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog"
import { Drawer, DrawerContent, DrawerTitle } from "@repo/ui/components/drawer"
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
import { cn } from "@lib/utils"
import { useIsMobile } from "@hooks/use-mobile"
import * as DialogPrimitive from "@radix-ui/react-dialog"
@ -21,10 +22,11 @@ import {
Loader,
Pencil,
Check,
Lock,
} from "lucide-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import { DEFAULT_PROJECT_ID, SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import type { ContainerTagListType } from "@lib/types"
@ -50,6 +52,7 @@ import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import NovaOrb from "@/components/nova/nova-orb"
import { AutoSpaceIcon } from "@/components/nova/auto-space-icon"
import { SpaceGlyph } from "./space-glyph"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
interface SelectSpacesModalProps {
isOpen: boolean
@ -130,8 +133,15 @@ export function SelectSpacesModal({
)
const pluginMetaMap = usePluginSpaceMeta(pluginTags)
const hasCompanyBrain = useHasCompanyBrain()
const allSpaces = useMemo(() => {
const rest = projects
.filter((p) => p.containerTag !== DEFAULT_PROJECT_ID)
.sort(compareSpacesUserFirst)
// Company brain orgs use real Private + Team Brain spaces; skip the
// synthetic "My Space" default that would otherwise duplicate Private.
if (hasCompanyBrain) return rest
const defaultSpace = {
id: "default",
name: "My Space",
@ -142,11 +152,8 @@ export function SelectSpacesModal({
createdAt: "",
updatedAt: "",
} as ContainerTagListType
const rest = projects
.filter((p) => p.containerTag !== DEFAULT_PROJECT_ID)
.sort(compareSpacesUserFirst)
return [defaultSpace, ...rest]
}, [projects])
}, [projects, hasCompanyBrain])
const { categories, connectedCatalogIds } = useMemo<{
categories: Category[]
@ -588,6 +595,20 @@ export function SelectSpacesModal({
)
const isDefault = project.containerTag === DEFAULT_PROJECT_ID
const isOwnSpace = isOwnConversationSpace(project, user?.id)
const isCbSpace =
hasCompanyBrain && !plugin && !isOwnSpace && !!project.visibility
const isShared = project.visibility === "public"
const orgName = org?.name ?? "your team"
const orgMembers = org?.members ?? []
const memberCount = orgMembers.length
const isDefaultBrain = project.containerTag === SHARED_TEAM_BRAIN_TAG
const descriptor = isCbSpace
? isShared
? `${orgName} · ${memberCount} ${
memberCount === 1 ? "member" : "members"
}`
: "Only you"
: null
const canEdit = !isDefault && !plugin && !isOwnSpace
const canBulkDelete = enableDelete && !isDefault
const isEditing = editingProject?.containerTag === project.containerTag
@ -716,6 +737,54 @@ export function SelectSpacesModal({
)
) : isOwnSpace ? (
<NovaOrb size={20} className="shrink-0 blur-[0.55px]!" />
) : isCbSpace ? (
isShared ? (
<span className="shrink-0 flex items-center" aria-hidden>
{orgMembers.slice(0, 3).map((m, i) => (
<Avatar
key={m.id}
className={cn(
"size-6 ring-2 ring-[#14161A]",
i > 0 && "-ml-2",
)}
>
<AvatarImage
src={m.user?.image ?? ""}
alt=""
className="object-cover"
/>
<AvatarFallback className="bg-[#1E232B] text-white text-[10px] font-medium">
{(m.user?.name ?? m.user?.email ?? "U")
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
))}
{memberCount > 3 && (
<span className="-ml-2 flex size-6 items-center justify-center rounded-full bg-[#1E232B] text-[#A3A3A3] text-[9px] font-medium ring-2 ring-[#14161A]">
+{memberCount - 3}
</span>
)}
</span>
) : (
<span className="shrink-0 relative" aria-hidden>
<Avatar className="size-6">
<AvatarImage
src={user?.image ?? ""}
alt=""
className="object-cover"
/>
<AvatarFallback className="bg-[#1E232B] text-white text-[10px] font-medium">
{(user?.name ?? user?.email ?? "U")
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="absolute -right-1 -bottom-1 flex size-3.5 items-center justify-center rounded-full bg-[#14161A] text-[#A3A3A3]">
<Lock className="size-2" />
</span>
</span>
)
) : (
<SpaceGlyph
emoji={project.emoji}
@ -723,23 +792,35 @@ export function SelectSpacesModal({
className="shrink-0"
/>
)}
<span
className="min-w-0 flex-1 truncate text-[#fafafa] text-sm font-medium"
title={plugin ? project.containerTag : displayName}
>
{plugin ? (
<>
{plugin.label}
{pluginIdLabel && (
<span className="ml-1.5 text-[12px] text-[#737373]">
· {pluginIdLabel}
</span>
)}
</>
) : (
displayName
<span className="flex min-w-0 flex-1 flex-col">
<span
className="truncate text-[#fafafa] text-sm font-medium"
title={plugin ? project.containerTag : displayName}
>
{plugin ? (
<>
{plugin.label}
{pluginIdLabel && (
<span className="ml-1.5 text-[12px] text-[#737373]">
· {pluginIdLabel}
</span>
)}
</>
) : (
displayName
)}
</span>
{descriptor && (
<span className="truncate text-[11px] text-[#737373]">
{descriptor}
</span>
)}
</span>
{isCbSpace && isDefaultBrain && (
<span className="ml-2 shrink-0 rounded-[4px] bg-[#4BA0FA]/15 px-1.5 py-0.5 text-[10px] font-medium text-[#4BA0FA]">
Default
</span>
)}
</button>
)}
{canEdit && !isEditing && !isBulkDeleteMode && (
@ -787,6 +868,7 @@ export function SelectSpacesModal({
enableDelete,
handleEditKeyDown,
handleSelect,
hasCompanyBrain,
isBulkDeleteMode,
onDeleteRequest,
pluginMetaMap,
@ -795,6 +877,11 @@ export function SelectSpacesModal({
toggleBulkDeleteTag,
updateProjectMutation.isPending,
user?.id,
org?.name,
org?.members,
user?.email,
user?.image,
user?.name,
],
)
@ -960,7 +1047,36 @@ export function SelectSpacesModal({
</div>
</>
)}
{mainList.map(renderRow)}
{hasCompanyBrain && recentProjects.length === 0
? (() => {
const shared = mainList.filter(
(p) => p.visibility === "public",
)
const personal = mainList.filter(
(p) => p.visibility !== "public",
)
return (
<>
{shared.length > 0 && (
<>
<div className="px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
Shared
</div>
{shared.map(renderRow)}
</>
)}
{personal.length > 0 && (
<>
<div className="px-3 pt-2 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
Personal
</div>
{personal.map(renderRow)}
</>
)}
</>
)
})()
: mainList.map(renderRow)}
</div>
)}
</div>

View file

@ -194,16 +194,18 @@ export default function Account() {
() => org?.members?.find((member) => member.userId === user?.id) ?? null,
[org?.members, user?.id],
)
// Only treat as a personal single-member org when members are actually loaded —
// otherwise default to least privilege (member), never owner.
const membersLoaded = Array.isArray(org?.members)
const isSingleMemberPersonalOrg =
membersLoaded &&
(org?.members?.length ?? 0) <= 1 &&
(!org?.members?.[0]?.userId || org.members[0].userId === user?.id)
const currentRole = isSingleMemberPersonalOrg
? "owner"
: (
activeMemberRoleQuery.data ??
currentMember?.role ??
"member"
).toLowerCase()
const currentRole = (
activeMemberRoleQuery.data ??
currentMember?.role ??
(isSingleMemberPersonalOrg ? "owner" : "member")
).toLowerCase()
const canManageTeam = currentRole === "owner" || currentRole === "admin"
const isOwner = currentRole === "owner"
@ -496,6 +498,14 @@ export default function Account() {
>
{org?.name ?? "Personal"}
</span>
<span
className={cn(
dmSans125ClassName(),
"inline-flex h-[18px] shrink-0 items-center justify-center rounded-[3px] bg-[#2E353D] px-1.5 text-[10px] font-mono font-medium uppercase tracking-[0.12em] text-[#A3A3A3]",
)}
>
{currentRole}
</span>
{canManageTeam ? (
<button
type="button"
@ -587,7 +597,7 @@ export default function Account() {
</div>
</section>
<OrgContext />
{canManageTeam && <OrgContext />}
<DigestPreferences />

View file

@ -0,0 +1,433 @@
"use client"
import { authClient } from "@lib/auth"
import { cn } from "@lib/utils"
import { useQuery } from "@tanstack/react-query"
import { Loader2, Lock } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { dmSans125ClassName } from "@/lib/fonts"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { PillButton } from "../integrations/install-steps"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
type ConnRow = { toolkit: string; org: boolean; user: boolean }
type SlackStatus = { connected: boolean; teamName: string | null }
function SecondaryButton({
children,
href,
}: {
children: React.ReactNode
href: string
}) {
return (
<a
href={href}
className={cn(
dmSans125ClassName(),
"inline-flex shrink-0 items-center justify-center gap-2 rounded-full border border-[#1E293B] bg-[#0D121A] px-4 h-9",
"text-[13px] font-medium text-[#FAFAFA] transition-colors hover:bg-[#1E293B]",
)}
>
{children}
</a>
)
}
function SlackMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
<path
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
fill="#E01E5A"
/>
<path
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
fill="#E01E5A"
/>
<path
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
fill="#36C5F0"
/>
<path
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
fill="#36C5F0"
/>
<path
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
fill="#2EB67D"
/>
<path
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
fill="#2EB67D"
/>
<path
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
fill="#ECB22E"
/>
<path
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
fill="#ECB22E"
/>
</svg>
)
}
function GithubMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
)
}
function LinearMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
<title>Linear</title>
<path d="M3.084 12.866a8.916 8.916 0 0 0 8.05 8.05.27.27 0 0 0 .222-.46l-7.812-7.812a.27.27 0 0 0-.46.222Zm-.044-1.955a.27.27 0 0 0 .078.21l9.76 9.76c.06.06.142.087.21.078a8.87 8.87 0 0 0 1.273-.218.27.27 0 0 0 .127-.453L3.712 9.51a.27.27 0 0 0-.453.127 8.87 8.87 0 0 0-.218 1.273Zm.69-2.706a.27.27 0 0 0 .06.29l11.715 11.716a.27.27 0 0 0 .29.06 8.96 8.96 0 0 0 .837-.384.27.27 0 0 0 .066-.439L4.553 7.302a.27.27 0 0 0-.44.066 8.96 8.96 0 0 0-.383.837Zm1.11-1.798a.27.27 0 0 1-.017-.366A8.948 8.948 0 0 1 18.07 18.69a.27.27 0 0 1-.366-.017L4.94 6.407Z" />
</svg>
)
}
const TOOLKITS: Record<
string,
{ label: string; subtitle: string; icon: React.ReactNode }
> = {
github: {
label: "GitHub",
subtitle: "Repos, pull requests and issues",
icon: <GithubMark className="size-5 text-[#FAFAFA]" />,
},
linear: {
label: "Linear",
subtitle: "Issues, projects and cycles",
icon: <LinearMark className="size-5 text-[#5E6AD2]" />,
},
}
function StatusDot({ connected }: { connected: boolean }) {
return (
<span
className={cn(
dmSans125ClassName(),
"flex items-center gap-1.5 text-[13px] font-medium",
connected ? "text-[#FAFAFA]" : "text-[#737373]",
)}
>
<span
className={cn(
"size-[7px] shrink-0 rounded-full",
connected ? "bg-[#00AC3F]" : "bg-[#3A4150]",
)}
/>
{connected ? "Connected" : "Not connected"}
</span>
)
}
function AppCard({
toolkit,
connected,
canConnect,
canDisconnect,
lockedHint,
busy,
onConnect,
onDisconnect,
}: {
toolkit: string
connected: boolean
canConnect: boolean
canDisconnect: boolean
lockedHint?: string
busy: boolean
onConnect: () => void
onDisconnect: () => void
}) {
const meta = TOOLKITS[toolkit] ?? {
label: toolkit,
subtitle: "",
icon: null,
}
return (
<div className="flex items-center justify-between gap-4 rounded-[14px] bg-[#14161A] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] sm:p-5">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
{meta.icon}
</div>
<div className="min-w-0">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[15px] tracking-[-0.15px] text-[#FAFAFA]",
)}
>
{meta.label}
</p>
<p
className={cn(
dmSans125ClassName(),
"truncate text-[12px] font-medium text-[#737373]",
)}
>
{meta.subtitle}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
<StatusDot connected={connected} />
{connected && canDisconnect ? (
<PillButton onClick={onDisconnect} disabled={busy}>
{busy && <Loader2 className="size-3.5 animate-spin" />}
Disconnect
</PillButton>
) : (
!connected &&
(canConnect ? (
<PillButton onClick={onConnect} disabled={busy}>
{busy && <Loader2 className="size-3.5 animate-spin" />}
Connect
</PillButton>
) : lockedHint ? (
<span className="flex items-center gap-1 text-[12px] font-medium text-[#737373]">
<Lock className="size-3" />
{lockedHint}
</span>
) : null)
)}
</div>
</div>
)
}
function Section({
title,
description,
children,
}: {
title: string
description: string
children: React.ReactNode
}) {
return (
<div className="space-y-3">
<div className="px-1">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[15px] tracking-[-0.15px] text-[#FAFAFA]",
)}
>
{title}
</p>
<p
className={cn(
dmSans125ClassName(),
"mt-0.5 text-[13px] font-medium text-[#737373]",
)}
>
{description}
</p>
</div>
{children}
</div>
)
}
function CardSkeleton() {
return (
<div className="flex items-center gap-3 rounded-[14px] bg-[#14161A] p-5 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<div className="size-10 animate-pulse rounded-[10px] bg-[#1c1f24]" />
<div className="space-y-2">
<div className="h-3.5 w-24 animate-pulse rounded bg-[#1c1f24]" />
<div className="h-3 w-40 animate-pulse rounded bg-[#1c1f24]" />
</div>
</div>
)
}
export default function CompanyBrainConnections() {
const isCompanyBrain = useHasCompanyBrain()
const [rows, setRows] = useState<ConnRow[] | null>(null)
const [slackStatus, setSlackStatus] = useState<SlackStatus | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const roleQuery = useQuery({
queryKey: ["company-brain-connections", "role"],
queryFn: async () =>
(await authClient.organization.getActiveMember()).data?.role ?? null,
staleTime: 60_000,
enabled: isCompanyBrain,
})
const role = (roleQuery.data ?? "").toLowerCase()
const isAdmin = role === "owner" || role === "admin"
const load = useCallback(async () => {
const [connRes, slackRes] = await Promise.all([
fetch(`${BACKEND}/brain/connections`, { credentials: "include" }),
fetch(`${BACKEND}/brain/slack/status`, { credentials: "include" }),
])
if (connRes.ok) {
setRows(((await connRes.json()) as { toolkits: ConnRow[] }).toolkits)
} else {
setRows([])
toast.error("Couldn't load connections.")
}
if (slackRes.ok) {
setSlackStatus((await slackRes.json()) as SlackStatus)
} else {
setSlackStatus({ connected: false, teamName: null })
}
}, [])
useEffect(() => {
if (!isCompanyBrain) return
void load()
const onFocus = () => void load()
window.addEventListener("focus", onFocus)
return () => window.removeEventListener("focus", onFocus)
}, [isCompanyBrain, load])
const connect = async (toolkit: string, scope: "user" | "org") => {
setBusy(`${toolkit}:${scope}`)
try {
const res = await fetch(
`${BACKEND}/brain/connections/${toolkit}/link?scope=${scope}`,
{ method: "POST", credentials: "include" },
)
if (res.status === 403) {
toast.error("Only admins can connect the shared org account.")
return
}
if (!res.ok) {
toast.error("Couldn't start the connection.")
return
}
const data = (await res.json()) as { url?: string; error?: string }
if (data.url) window.open(data.url, "_blank", "noopener")
else toast.error(data.error ?? "Couldn't start the connection.")
} catch {
toast.error("Couldn't start the connection.")
} finally {
setBusy(null)
}
}
const disconnect = async (toolkit: string, scope: "user" | "org") => {
const label = TOOLKITS[toolkit]?.label ?? toolkit
if (
!window.confirm(
`Disconnect ${label} from ${scope === "org" ? "the shared org account" : "your personal account"}?`,
)
)
return
setBusy(`${toolkit}:${scope}`)
try {
const res = await fetch(
`${BACKEND}/brain/connections/${toolkit}?scope=${scope}`,
{ method: "DELETE", credentials: "include" },
)
if (res.status === 403) {
toast.error("Only admins can disconnect the shared org account.")
return
}
if (!res.ok) {
toast.error("Couldn't disconnect.")
return
}
toast.success(`${label} disconnected.`)
await load()
} catch {
toast.error("Couldn't disconnect.")
} finally {
setBusy(null)
}
}
if (!isCompanyBrain) {
return (
<p
className={cn(
dmSans125ClassName(),
"text-[14px] font-medium text-[#737373]",
)}
>
Company Brain isn't enabled for this organization.
</p>
)
}
const loading = rows === null
return (
<div className="space-y-7">
<div className="flex items-center justify-end gap-3">
{slackStatus?.connected && slackStatus.teamName ? (
<p
className={cn(
dmSans125ClassName(),
"mr-auto text-[13px] font-medium text-[#737373]",
)}
>
Slack · {slackStatus.teamName}
</p>
) : null}
{isAdmin ? (
<SecondaryButton href={`${BACKEND}/brain/slack/oauth/install`}>
<SlackMark className="size-4" />
Reconnect Slack
</SecondaryButton>
) : null}
</div>
<Section
title="Organization (shared)"
description="Connected by admins. Used for reads when you haven't connected your own."
>
{loading ? (
<CardSkeleton />
) : (
rows.map((row) => (
<AppCard
key={`org-${row.toolkit}`}
toolkit={row.toolkit}
connected={row.org}
canConnect={isAdmin}
canDisconnect={isAdmin}
lockedHint="Admin only"
busy={busy === `${row.toolkit}:org`}
onConnect={() => connect(row.toolkit, "org")}
onDisconnect={() => disconnect(row.toolkit, "org")}
/>
))
)}
</Section>
<Section
title="Your connections"
description="Your personal accounts — used for your actions and your reads."
>
{loading ? (
<CardSkeleton />
) : (
rows.map((row) => (
<AppCard
key={`user-${row.toolkit}`}
toolkit={row.toolkit}
connected={row.user}
canConnect
canDisconnect
busy={busy === `${row.toolkit}:user`}
onConnect={() => connect(row.toolkit, "user")}
onDisconnect={() => disconnect(row.toolkit, "user")}
/>
))
)}
</Section>
</div>
)
}

View file

@ -710,7 +710,7 @@ export default function ConnectionsMCP() {
</p>
<PillButton
onClick={() => router.push("/?view=integrations&cat=ai-clients")}
onClick={() => router.push("/integrations?cat=ai-clients")}
>
<Plus className="size-[10px] text-[#FAFAFA]" />
<span className="text-[14px] tracking-[-0.14px] text-[#FAFAFA] font-medium">

View file

@ -3,13 +3,14 @@
import { Logo } from "@ui/assets/Logo"
import { useAuth } from "@lib/auth-context"
import NovaOrb from "@/components/nova/nova-orb"
import { useState } from "react"
import { useEffect, useRef, useState } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
import Account from "@/components/settings/account"
import Billing from "@/components/settings/billing"
import Integrations from "@/components/settings/integrations"
import ConnectionsMCP from "@/components/settings/connections-mcp"
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
import Support from "@/components/settings/support"
import { ErrorBoundary } from "@/components/error-boundary"
import { useRouter } from "next/navigation"
@ -30,9 +31,15 @@ import {
ChevronRight,
ArrowUpRight,
Building2,
X,
} from "lucide-react"
import { authClient } from "@lib/auth"
import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog"
import {
Dialog,
DialogContent,
DialogClose,
DialogTitle,
} from "@ui/components/dialog"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
import { useResetOrganization } from "@/hooks/use-reset-organization"
import { useDeleteUserAccount } from "@/hooks/use-account-settings"
@ -44,6 +51,7 @@ export const TABS = [
"billing",
"integrations",
"connections",
"company-brain",
"support",
] as const
export type SettingsTab = (typeof TABS)[number]
@ -80,6 +88,12 @@ const NAV_ITEMS: NavItem[] = [
description: "Drive, Notion, OneDrive, MCP",
icon: <Zap className="size-[18px]" />,
},
{
id: "company-brain",
label: "Company Brain",
description: "GitHub & Linear — org and personal",
icon: <Building2 className="size-[18px]" />,
},
{
id: "support",
label: "Support & Help",
@ -88,6 +102,11 @@ const NAV_ITEMS: NavItem[] = [
},
]
const MODAL_SURFACE_SHADOW =
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset"
const INSET_SHADOW = "inset 1.313px 1.313px 3.938px rgba(0,0,0,0.7)"
export function parseHashToTab(hash: string): SettingsTab {
const cleaned = hash.replace("#", "").toLowerCase()
return TABS.includes(cleaned as SettingsTab)
@ -124,11 +143,13 @@ function IdentityCard({ displayName }: { displayName: string }) {
export function SettingsContent({
activeTab,
onTabChange,
dialogPortalContainer,
className,
showIdentity = true,
}: {
activeTab: SettingsTab
onTabChange: (tab: SettingsTab) => void
dialogPortalContainer?: HTMLElement | null
className?: string
showIdentity?: boolean
}) {
@ -147,7 +168,13 @@ export function SettingsContent({
const [isDeleteOrgDialogOpen, setIsDeleteOrgDialogOpen] = useState(false)
const [deleteOrgConfirm, setDeleteOrgConfirm] = useState("")
const deleteOrgInputRef = useRef<HTMLInputElement>(null)
const deleteOrgDialogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const deleteOrganization = useDeleteOrganization()
const activeOrgId = org?.id
const previousOrgIdRef = useRef(activeOrgId)
// Only owners can delete the organization.
const activeMemberRoleQuery = useQuery({
@ -166,6 +193,46 @@ export function SettingsContent({
const [dangerMenuOpen, setDangerMenuOpen] = useState(false)
useEffect(() => {
if (previousOrgIdRef.current === activeOrgId) return
previousOrgIdRef.current = activeOrgId
setDangerMenuOpen(false)
setIsDeleteOrgDialogOpen(false)
setDeleteOrgConfirm("")
}, [activeOrgId])
useEffect(() => {
if (!isDeleteOrgDialogOpen) return
document.body.style.pointerEvents = ""
const focusTimer = setTimeout(() => {
deleteOrgInputRef.current?.focus()
}, 0)
return () => clearTimeout(focusTimer)
}, [isDeleteOrgDialogOpen])
useEffect(() => {
return () => {
if (deleteOrgDialogTimerRef.current) {
clearTimeout(deleteOrgDialogTimerRef.current)
}
}
}, [])
const openDeleteOrganizationDialog = () => {
setDangerMenuOpen(false)
setDeleteOrgConfirm("")
if (deleteOrgDialogTimerRef.current) {
clearTimeout(deleteOrgDialogTimerRef.current)
}
deleteOrgDialogTimerRef.current = setTimeout(() => {
document.body.style.pointerEvents = ""
setIsDeleteOrgDialogOpen(true)
deleteOrgDialogTimerRef.current = null
}, 120)
}
const displayName =
user?.displayUsername ||
localStorageUsername ||
@ -179,7 +246,7 @@ export function SettingsContent({
}
const handleIntegrations = () => {
void router.push("/?view=integrations")
void router.push("/integrations")
}
const handleDeleteAccount = async () => {
@ -361,10 +428,7 @@ export function SettingsContent({
<button
type="button"
onClick={() => {
setDangerMenuOpen(false)
setIsDeleteOrgDialogOpen(true)
}}
onClick={openDeleteOrganizationDialog}
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#C73B1B] hover:bg-[#290F0A]/60 transition-colors cursor-pointer"
>
<Building2 className="size-[16px] shrink-0" />
@ -416,6 +480,7 @@ export function SettingsContent({
{activeTab === "billing" && <Billing />}
{activeTab === "integrations" && <Integrations />}
{activeTab === "connections" && <ConnectionsMCP />}
{activeTab === "company-brain" && <CompanyBrainConnections />}
{activeTab === "support" && <Support />}
</ErrorBoundary>
</section>
@ -596,77 +661,114 @@ export function SettingsContent({
{/* Delete organization dialog */}
<Dialog
key={activeOrgId ?? "delete-organization"}
open={isDeleteOrgDialogOpen}
onOpenChange={(open) => {
setIsDeleteOrgDialogOpen(open)
if (!open) setDeleteOrgConfirm("")
}}
>
<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]">
<DialogContent
className={cn(
dmSans125ClassName(),
"sm:max-w-[560px] border border-white/[0.12] bg-[#1B1F24] p-0 px-4 pt-4 pb-5 gap-0 rounded-[22px] overflow-hidden text-[#FAFAFA]",
)}
portalContainer={dialogPortalContainer}
showCloseButton={false}
style={{ boxShadow: MODAL_SURFACE_SHADOW }}
onOpenAutoFocus={(event) => {
event.preventDefault()
deleteOrgInputRef.current?.focus()
}}
>
<div className="flex shrink-0 items-center gap-3">
<div className="min-w-0 flex-1">
<DialogTitle className="text-[18px] font-semibold leading-tight text-[#FAFAFA]">
Delete this organization?
</h2>
<p className="text-sm text-[#8B8B8B]">
Permanently deletes{" "}
<strong className="text-[#FAFAFA]">
{org?.name || "this organization"}
</strong>{" "}
its documents, spaces, connections, and members.{" "}
<strong className="text-[#FAFAFA]">
This cannot be undone.
</strong>
</DialogTitle>
<p className="mt-0.5 truncate text-[13px] text-[#A1A1AA]">
This action permanently removes the selected workspace.
</p>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-[#8B8B8B]">
Type <strong className="text-[#FAFAFA]">{org?.name}</strong> to
confirm:
</p>
<DialogClose asChild>
<button
type="button"
aria-label="Close"
className="flex size-9 shrink-0 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none"
style={{ boxShadow: INSET_SHADOW }}
>
<X className="size-5 text-[#737373]" />
</button>
</DialogClose>
</div>
<div className="mt-4 flex flex-col gap-4 rounded-[14px] bg-[#14161A] p-4 sm:p-5 shadow-[inset_1.313px_1.313px_3.938px_rgba(0,0,0,0.7)]">
<p className="text-[13.5px] leading-relaxed text-[#A1A1AA]">
Permanently deletes{" "}
<strong className="font-semibold text-[#FAFAFA]">
{org?.name || "this organization"}
</strong>{" "}
and all of its documents, spaces, connections, and members.{" "}
<strong className="font-semibold text-[#FAFAFA]">
This cannot be undone.
</strong>
</p>
<label className="flex flex-col gap-2">
<span className="text-[13px] text-[#A1A1AA]">
Type{" "}
<strong className="font-semibold text-[#FAFAFA]">
{org?.name}
</strong>{" "}
to confirm
</span>
<input
ref={deleteOrgInputRef}
type="text"
value={deleteOrgConfirm}
onChange={(e) => setDeleteOrgConfirm(e.target.value)}
placeholder={org?.name ?? "Organization 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-[#C73B1B]/50 transition-colors"
className="h-11 w-full rounded-[12px] border border-white/[0.08] bg-[#0D121A] px-4 text-[14px] text-white placeholder:text-[#525D6E] transition-colors focus:border-[#C73B1B]/55 focus:outline-none focus:ring-2 focus:ring-[#C73B1B]/15"
style={{ boxShadow: INSET_SHADOW }}
/>
</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>
</label>
</div>
<div className="mt-4 flex shrink-0 flex-wrap items-center justify-end gap-2">
<DialogClose asChild>
<button
type="button"
disabled={
!org?.name ||
deleteOrgConfirm !== org.name ||
deleteOrganization.isPending
}
title={
!org?.name || deleteOrgConfirm !== org.name
? `Type "${org?.name ?? "the organization name"}" exactly to confirm`
: undefined
}
onClick={handleDeleteOrganization}
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"
>
{deleteOrganization.isPending ? (
<LoaderIcon className="size-[15px] animate-spin" />
) : (
<Building2 className="size-[15px]" />
className={cn(
"px-3 py-2 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#FAFAFA]",
dmSansClassName(),
)}
{deleteOrganization.isPending
? "Deleting…"
: "Delete organization"}
>
Cancel
</button>
</div>
</DialogClose>
<button
type="button"
disabled={
!org?.name ||
deleteOrgConfirm !== org.name ||
deleteOrganization.isPending
}
title={
!org?.name || deleteOrgConfirm !== org.name
? `Type "${org?.name ?? "the organization name"}" exactly to confirm`
: undefined
}
onClick={handleDeleteOrganization}
className="flex h-10 shrink-0 items-center justify-center gap-2 rounded-full bg-[#0D121A] px-5 text-[14px] font-semibold text-[#FAFAFA] transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-45"
style={{ boxShadow: INSET_SHADOW }}
>
{deleteOrganization.isPending ? (
<LoaderIcon className="size-4 animate-spin text-[#C73B1B]" />
) : null}
{deleteOrganization.isPending
? "Deleting..."
: "Delete organization"}
</button>
</div>
</DialogContent>
</Dialog>

View file

@ -5,6 +5,7 @@ import {
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react"
import { useQueryState } from "nuqs"
@ -50,6 +51,8 @@ const SettingsModalContext = createContext<SettingsModalContextValue | null>(
export function SettingsModalProvider({ children }: { children: ReactNode }) {
const [param, setParam] = useQueryState(SETTINGS_PARAM)
const [settingsDialogContent, setSettingsDialogContent] =
useState<HTMLDivElement | null>(null)
const open = param !== null
const tab = parseTab(param)
@ -85,6 +88,7 @@ export function SettingsModalProvider({ children }: { children: ReactNode }) {
}}
>
<DialogContent
ref={setSettingsDialogContent}
showCloseButton={false}
style={{
display: "flex",
@ -122,6 +126,7 @@ export function SettingsModalProvider({ children }: { children: ReactNode }) {
<SettingsContent
activeTab={tab}
onTabChange={handleTabChange}
dialogPortalContainer={settingsDialogContent}
showIdentity={false}
className="flex-1 min-h-0 w-full overflow-y-auto md:overflow-hidden px-5 md:px-4 pt-4 pb-6"
/>

View file

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

View file

@ -0,0 +1,106 @@
"use client"
import { useEffect, useState } from "react"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
const BACKEND =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
type SlackStatus = { connected: boolean; teamName: string | null }
function SlackMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
<title>Slack</title>
<path
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
fill="#E01E5A"
/>
<path
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
fill="#E01E5A"
/>
<path
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
fill="#36C5F0"
/>
<path
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
fill="#36C5F0"
/>
<path
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
fill="#2EB67D"
/>
<path
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
fill="#2EB67D"
/>
<path
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
fill="#ECB22E"
/>
<path
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
fill="#ECB22E"
/>
</svg>
)
}
export function SlackConnectCard() {
const isCompanyBrain = useHasCompanyBrain()
const [status, setStatus] = useState<SlackStatus | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!isCompanyBrain) return
let active = true
;(async () => {
try {
const res = await fetch(`${BACKEND}/brain/slack/status`, {
credentials: "include",
})
if (active && res.ok) setStatus((await res.json()) as SlackStatus)
} finally {
if (active) setLoading(false)
}
})()
return () => {
active = false
}
}, [isCompanyBrain])
if (!isCompanyBrain || loading) return null
const connected = status?.connected
return (
<div className="flex items-center justify-between gap-4 rounded-[14px] bg-[#191D24] p-4 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)] sm:px-5">
<div className="min-w-0">
<p className="text-sm font-semibold text-fg-primary">
Add Supermemory to your Slack
</p>
<p className="mt-0.5 truncate text-[12px] text-fg-muted">
{connected
? `Connected to ${status?.teamName ?? "your workspace"}.`
: "Answer from your company brain and act on connected apps — right inside Slack."}
</p>
</div>
{connected ? (
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-surface-skeleton px-3 py-1.5 text-[12px] font-medium text-fg-muted ring-1 ring-surface-border">
<span className="size-1.5 rounded-full bg-[#2EB67D]" />
Connected
</span>
) : (
<a
href={`${BACKEND}/brain/slack/oauth/install`}
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-white px-3.5 py-2 text-[13px] font-semibold text-[#1D1C1D] transition-transform hover:scale-[1.02]"
>
<SlackMark className="size-4" />
Add to Slack
</a>
)}
</div>
)
}

View file

@ -6,7 +6,8 @@ import { useQuery } from "@tanstack/react-query"
import { cn } from "@lib/utils"
import { $fetch } from "@lib/api"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import { DEFAULT_PROJECT_ID, SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
import { ChevronDownIcon, Pencil, XIcon, Loader2, Trash2 } from "lucide-react"
import type { ContainerTagListType } from "@lib/types"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
@ -60,8 +61,8 @@ export interface SpaceSelectorProps {
const triggerVariants = {
default:
"h-10 min-h-10 shrink-0 rounded-full border border-[#161F2C] bg-muted px-3 gap-2 " +
"hover:bg-white/5 hover:border-[#2261CA33] " +
"h-10 min-h-10 shrink-0 rounded-full bg-muted px-3 gap-2 " +
"hover:bg-white/5 " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2261CA33]/35",
insideOut:
"h-10 min-h-10 gap-2 px-3 rounded-full bg-[#0D121A] shadow-inside-out hover:bg-[#121820]",
@ -147,12 +148,16 @@ export function SpaceSelector({
useProjectMutations()
const { allProjects, isLoading } = useContainerTags()
const { user } = useAuth()
const hasCompanyBrain = useHasCompanyBrain()
const defaultTag = hasCompanyBrain
? SHARED_TEAM_BRAIN_TAG
: DEFAULT_PROJECT_ID
useEffect(() => {
setRecents(readRecents())
}, [])
const activeTag = selectedProjects[0] ?? DEFAULT_PROJECT_ID
const activeTag = selectedProjects[0] ?? defaultTag
const { data: spaceCountData } = useQuery({
queryKey: ["space-selector-count", activeTag],
queryFn: async (): Promise<number> => {
@ -194,7 +199,7 @@ export function SpaceSelector({
isAuto: boolean
isOwnSpace: boolean
}>(() => {
const containerTag = selectedProjects[0] ?? ""
const containerTag = selectedProjects[0] ?? defaultTag
if (includeAuto && containerTag === AUTO_CHAT_SPACE_ID) {
return {
name: "Auto",
@ -233,7 +238,14 @@ export function SpaceSelector({
isAuto: false,
isOwnSpace,
}
}, [allProjects, selectedProjects, pluginMetaMap, includeAuto, user?.id])
}, [
allProjects,
selectedProjects,
pluginMetaMap,
includeAuto,
user?.id,
defaultTag,
])
const canEditCurrent =
enableEdit &&

View file

@ -0,0 +1,16 @@
import { useAuth } from "@lib/auth-context"
import {
getBrainMode,
getCompanyBrainOverride,
hasCompanyBrain,
} from "@/lib/billing-utils"
export function useHasCompanyBrain(): boolean {
const { org } = useAuth()
const metadata = org?.metadata as Record<string, unknown> | string | undefined
// An explicit concierge override wins over the team-onboarding fallback.
const override = getCompanyBrainOverride(metadata)
if (override !== undefined) return override
// Team-brain orgs use brain spaces even before the add-on webhook lands.
return hasCompanyBrain(metadata) || getBrainMode(metadata) === "team"
}

View file

@ -1,16 +1,53 @@
import posthog from "posthog-js"
import type { BrainStep } from "@/components/onboarding-brain/types"
export type OnboardingStep = "profile_input" | "processing" | "done" | "error"
export type OnboardingSource = "x" | "linkedin" | "resume"
const pendingEvents: Array<{
eventName: string
properties?: Record<string, unknown>
}> = []
let flushTimer: ReturnType<typeof setInterval> | undefined
let flushTimeout: ReturnType<typeof setTimeout> | undefined
const flushPendingEvents = () => {
if (!posthog.__loaded) return
while (pendingEvents.length > 0) {
const event = pendingEvents.shift()
if (!event) return
posthog.capture(event.eventName, event.properties)
}
if (flushTimer) {
clearInterval(flushTimer)
flushTimer = undefined
}
if (flushTimeout) {
clearTimeout(flushTimeout)
flushTimeout = undefined
}
}
const scheduleFlush = () => {
if (flushTimer) return
flushTimer = setInterval(flushPendingEvents, 200)
flushTimeout = setTimeout(() => {
if (!flushTimer) return
clearInterval(flushTimer)
flushTimer = undefined
flushTimeout = undefined
pendingEvents.length = 0
}, 10000)
}
// Helper function to safely capture events
const safeCapture = (
eventName: string,
properties?: Record<string, unknown>,
) => {
if (posthog.__loaded) {
flushPendingEvents()
posthog.capture(eventName, properties)
return
}
pendingEvents.push({ eventName, properties })
scheduleFlush()
}
export const analytics = {
@ -57,13 +94,6 @@ export const analytics = {
close_reason: "dismiss" | "close_button" | "im_good" | "action"
}) => safeCapture("integration_info_modal_closed", props),
nextAppResearchCtaDismissed: () =>
safeCapture("next_app_research_cta_dismissed"),
nextAppResearchCtaBookCallClicked: () =>
safeCapture("next_app_research_cta_book_call_clicked"),
nextAppResearchCtaLobbysideCallClicked: () =>
safeCapture("next_app_research_cta_lobbyside_call_clicked"),
mcpViewOpened: () => safeCapture("mcp_view_opened"),
mcpInstallCmdCopied: () => safeCapture("mcp_install_cmd_copied"),
@ -82,32 +112,62 @@ export const analytics = {
addDocumentModalOpened: () => safeCapture("add_document_modal_opened"),
// onboarding analytics
onboardingStarted: (props: { mode: string; entry_step: BrainStep }) =>
safeCapture("onboarding_started", props),
onboardingStepViewed: (props: {
step: OnboardingStep
step: BrainStep
index: number
trigger: "user" | "auto"
}) => safeCapture("onboarding_step_viewed", props),
onboardingProfileSubmitted: (props: { source: OnboardingSource }) =>
safeCapture("onboarding_profile_submitted", props),
onboardingStepCompleted: (props: { step: BrainStep; index: number }) =>
safeCapture("onboarding_step_completed", props),
onboardingModeSelected: (props: { mode: string }) =>
safeCapture("onboarding_mode_selected", props),
onboardingWorkspaceCreated: (props: {
mode: string
has_about: boolean
has_domain: boolean
}) => safeCapture("onboarding_workspace_created", props),
onboardingWorkspaceCreateFailed: (props: { error: string }) =>
safeCapture("onboarding_workspace_create_failed", props),
onboardingIntegrationClicked: (props: { integration: string }) =>
safeCapture("onboarding_integration_clicked", props),
onboardingSourcesCompleted: (props: { connected_count: number }) =>
safeCapture("onboarding_sources_completed", props),
onboardingAgentSelected: (props: { agent: string }) =>
safeCapture("onboarding_agent_selected", props),
onboardingIngestCompleted: () => safeCapture("onboarding_ingest_completed"),
onboardingIngestSkipped: () => safeCapture("onboarding_ingest_skipped"),
onboardingInvitesSent: (props: { sent: number; failed: number }) =>
safeCapture("onboarding_invites_sent", props),
onboardingTeamSkipped: () => safeCapture("onboarding_team_skipped"),
onboardingChromeExtensionClicked: (props: {
source: "onboarding" | "settings" | "integrations"
}) => safeCapture("onboarding_chrome_extension_clicked", props),
onboardingMcpDetailOpened: () => safeCapture("onboarding_mcp_detail_opened"),
onboardingXBookmarksDetailOpened: () =>
safeCapture("onboarding_x_bookmarks_detail_opened"),
onboardingSkipped: (props: { from_step: OnboardingStep }) =>
onboardingSkipped: (props: { from_step: BrainStep }) =>
safeCapture("onboarding_skipped", props),
onboardingCompleted: (props?: {
source?: OnboardingSource
memories_count?: number
onboardingCompleted: (props: {
mode: string
steps_completed: number
sources_connected: number
invites_sent: number
}) => safeCapture("onboarding_completed", props),
// main app analytics

View file

@ -1,3 +1,97 @@
const COMPANY_BRAIN_PRODUCT_ID = "company_brain"
// Add-on resolved by product presence, not tier.
// better-auth returns org.metadata as a JSON string, so accept string or object.
export function hasCompanyBrain(
metadataRaw: Record<string, unknown> | string | null | undefined,
): boolean {
if (!metadataRaw) return false
let metadata: Record<string, unknown>
if (typeof metadataRaw === "string") {
try {
metadata = JSON.parse(metadataRaw) as Record<string, unknown>
} catch {
return false
}
} else {
metadata = metadataRaw
}
const overrides = metadata.featureOverrides as
| Record<string, { allow?: boolean }>
| undefined
const override = overrides?.[COMPANY_BRAIN_PRODUCT_ID]
if (override) return Boolean(override.allow)
const activeProducts = Array.isArray(metadata.activeProducts)
? (metadata.activeProducts as string[])
: []
return activeProducts.includes(COMPANY_BRAIN_PRODUCT_ID)
}
// Explicit concierge override for company_brain, or undefined when none is set.
export function getCompanyBrainOverride(
metadataRaw: Record<string, unknown> | string | null | undefined,
): boolean | undefined {
if (!metadataRaw) return undefined
let metadata: Record<string, unknown>
if (typeof metadataRaw === "string") {
try {
metadata = JSON.parse(metadataRaw) as Record<string, unknown>
} catch {
return undefined
}
} else {
metadata = metadataRaw
}
const overrides = metadata.featureOverrides as
| Record<string, { allow?: boolean }>
| undefined
const override = overrides?.[COMPANY_BRAIN_PRODUCT_ID]
return override ? Boolean(override.allow) : undefined
}
// Origin of the org. Consumer (app.supermemory) orgs get company_brain attached,
// but the add-on lands async — signupSource is set at creation, so it's the
// reliable "this org uses brain spaces" signal in the UI.
export function getSignupSource(
metadataRaw: Record<string, unknown> | string | null | undefined,
): string | null {
if (!metadataRaw) return null
let metadata: Record<string, unknown>
if (typeof metadataRaw === "string") {
try {
metadata = JSON.parse(metadataRaw) as Record<string, unknown>
} catch {
return null
}
} else {
metadata = metadataRaw
}
return typeof metadata.signupSource === "string"
? (metadata.signupSource as string)
: null
}
// Brain mode chosen during onboarding ("personal" | "team"). Set synchronously
// at org creation, so it's the reliable pre-webhook signal for company brain.
export function getBrainMode(
metadataRaw: Record<string, unknown> | string | null | undefined,
): string | null {
if (!metadataRaw) return null
let metadata: Record<string, unknown>
if (typeof metadataRaw === "string") {
try {
metadata = JSON.parse(metadataRaw) as Record<string, unknown>
} catch {
return null
}
} else {
metadata = metadataRaw
}
return typeof metadata.brainMode === "string"
? (metadata.brainMode as string)
: null
}
/**
* Format a number with K/M suffix for display
* @example formatUsageNumber(1500000) => "1.5M"

View file

@ -1,4 +1,8 @@
import type { UIMessage } from "@ai-sdk/react"
import {
extractDocumentIdsFromMemoryOutput,
extractMemoryToolOutputs,
} from "@/lib/chat-memory-tools"
import { memoryResultsFromSearchToolOutput } from "@/lib/chat-search-memory-results"
const UUID_IN_STRING =
@ -42,7 +46,8 @@ export function documentIdsFromBashText(text: string): string[] {
const found = new Set<string>()
// [doc:<nanoid>] annotations from sgrep --include-doc-ids (highest confidence)
for (const m of text.matchAll(DOC_ANNOTATION)) {
found.add(m[1])
const id = m[1]
if (id) found.add(id)
}
// Standard UUID format
for (const m of text.matchAll(UUID_IN_STRING)) {
@ -52,7 +57,8 @@ export function documentIdsFromBashText(text: string): string[] {
const quoted = /"documentId"\s*:\s*"([^"]+)"/g
let q = quoted.exec(text)
while (q !== null) {
found.add(q[1])
const id = q[1]
if (id) found.add(id)
q = quoted.exec(text)
}
return [...found]
@ -136,9 +142,23 @@ export function extractHighlightDocumentIdsFromMessages(
if (message.role !== "assistant") continue
const parts = message.parts
if (!parts) continue
for (const memoryOutput of extractMemoryToolOutputs(message)) {
for (const id of extractDocumentIdsFromMemoryOutput(
memoryOutput.output,
)) {
ids.add(id)
}
}
for (const part of parts) {
const p = part as Record<string, unknown>
if (
p.type === "tool-searchMemories" ||
p.type === "tool-recallContext" ||
p.type === "tool-discoverSpaces"
) {
continue
}
if (p.type === "source-document") {
const sid = (p as { sourceId?: unknown }).sourceId

View file

@ -0,0 +1,177 @@
import { describe, expect, it } from "bun:test"
import { extractHighlightDocumentIdsFromMessages } from "./chat-highlight-documents"
import {
buildCitationIndex,
extractDocumentIdsFromMemoryOutput,
extractMemoryToolOutputs,
getDocumentSourceUrl,
mapDocumentsByKnownIds,
} from "./chat-memory-tools"
const assistantMessage = {
id: "m1",
role: "assistant",
parts: [
{
type: "tool-recallContext",
state: "output-available",
output: {
sourceIds: ["S1"],
documentIds: ["topDoc"],
results: [
{
citationId: "S1",
content: "memo",
document: {
id: "docA",
customId: "customA",
title: "Doc A",
type: "google_doc",
summary: "sum",
},
},
],
},
},
{
type: "tool-discoverSpaces",
state: "input-streaming",
output: { sourceIds: ["ignored"], documentIds: ["ignoredDoc"] },
},
{
type: "text",
text: 'Answer <response source="S1">from memory</response>',
},
],
} as const
describe("chat memory tool citation mapping", () => {
it("extracts only ready memory tool outputs", () => {
const outputs = extractMemoryToolOutputs({
parts: [
...assistantMessage.parts,
{
type: "tool-searchMemories",
state: "done",
output: { sourceIds: ["done"], documentIds: ["doneDoc"] },
},
{
type: "tool-searchMemories",
output: { sourceIds: ["stateless"], documentIds: ["statelessDoc"] },
},
],
})
expect(outputs).toHaveLength(3)
expect(outputs.map((output) => output.output.sourceIds?.[0])).toEqual([
"S1",
"done",
"stateless",
])
})
it("maps citation ids to document and custom ids", () => {
const [output] = extractMemoryToolOutputs(assistantMessage)
const index = buildCitationIndex(output ? [output] : [])
expect(index.get("S1")?.documentId).toBe("docA")
expect(index.get("S1")?.customId).toBe("customA")
expect(index.has("ignored")).toBe(false)
})
it("extracts graph highlight document ids from memory outputs", () => {
const [output] = extractMemoryToolOutputs(assistantMessage)
expect(output && extractDocumentIdsFromMemoryOutput(output.output)).toEqual(
["topDoc", "docA", "customA"],
)
expect(
extractHighlightDocumentIdsFromMessages([assistantMessage as never]),
).toEqual(["topDoc", "docA", "customA"])
})
it("keeps graph highlights for legacy memory tool states and ids", () => {
const legacyMessage = {
id: "legacy",
role: "assistant",
parts: [
{
type: "tool-searchMemories",
state: "done",
output: { results: [{ id: "legacyDoc" }] },
},
{
type: "tool-recallContext",
output: { documentIds: ["statelessDoc"] },
},
],
} as const
expect(extractMemoryToolOutputs(legacyMessage)).toHaveLength(2)
expect(
extractHighlightDocumentIdsFromMessages([legacyMessage as never]),
).toEqual(["legacyDoc", "statelessDoc"])
})
it("normalizes nested discoverSpaces memory results", () => {
const outputs = extractMemoryToolOutputs({
parts: [
{
type: "tool-discoverSpaces",
state: "output-available",
output: {
spaces: [
{
sourceIds: ["S2"],
documentIds: ["spaceDoc"],
results: [{ citationId: "S2", documentIds: ["nestedDoc"] }],
},
],
},
},
],
})
const index = buildCitationIndex(outputs)
expect(index.get("S2")?.documentId).toBe("nestedDoc")
expect(
extractDocumentIdsFromMemoryOutput(outputs[0]?.output ?? {}),
).toEqual(["spaceDoc", "nestedDoc"])
})
it("builds editable Google source URLs from custom ids and API URLs", () => {
expect(
getDocumentSourceUrl({
type: "google_doc",
customId: "docCustom",
url: "https://docs.googleapis.com/v1/documents/apiDoc",
} as never),
).toBe("https://docs.google.com/document/d/docCustom/edit")
expect(
getDocumentSourceUrl({
type: "google_doc",
url: "https://docs.googleapis.com/v1/documents/apiDoc",
} as never),
).toBe("https://docs.google.com/document/d/apiDoc/edit")
expect(
getDocumentSourceUrl({
type: "google_sheet",
url: "https://sheets.googleapis.com/v4/spreadsheets/sheetId/values/A1",
} as never),
).toBe("https://docs.google.com/spreadsheets/d/sheetId/edit")
expect(
getDocumentSourceUrl({
type: "google_slide",
url: "https://slides.googleapis.com/v1/presentations/slideId/pages",
} as never),
).toBe("https://docs.google.com/presentation/d/slideId/edit")
})
it("maps documents by all known ids", () => {
const mapped = mapDocumentsByKnownIds([
{ id: "docA", customId: "customA", type: "text", url: null } as never,
])
expect(mapped.get("docA")?.id).toBe("docA")
expect(mapped.get("customA")?.id).toBe("docA")
})
})

View file

@ -0,0 +1,375 @@
import { $fetch } from "@lib/api"
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { isSafeSourceId } from "./source-annotations"
export const MEMORY_TOOL_PART_TYPES = [
"tool-searchMemories",
"tool-recallContext",
"tool-discoverSpaces",
] as const
export const MAX_INLINE_GRAPH_DOCUMENT_IDS = 20
export type MemoryToolName =
| "searchMemories"
| "recallContext"
| "discoverSpaces"
export type ToolDocumentMetadata = {
id?: string | undefined
internalDocumentId?: string | undefined
customId?: string | null | undefined
title?: string | null | undefined
type?: string | null | undefined
summary?: string | null | undefined
url?: string | null | undefined
}
export type MemoryToolResultItem = {
id?: string | undefined
citationId?: string | undefined
kind?: "memory" | "chunk" | "aggregate" | string | undefined
content?: string | undefined
score?: number | undefined
documentId?: string | undefined
documentIds?: string[] | undefined
internalDocumentId?: string | undefined
customId?: string | undefined
documents?: ToolDocumentMetadata[] | undefined
document?: ToolDocumentMetadata | undefined
}
export type MemoryToolResult = {
query?: string | undefined
count?: number | undefined
sourceIds?: string[] | undefined
documentIds?: string[] | undefined
results?: MemoryToolResultItem[] | undefined
spaces?:
| Array<{
results?: MemoryToolResultItem[] | undefined
sourceIds?: string[] | undefined
documentIds?: string[] | undefined
}>
| undefined
}
export type MemoryToolOutput = {
output: MemoryToolResult
}
export type CitationTarget = {
sourceId: string
documentId?: string | undefined
customId?: string | null | undefined
title?: string | null | undefined
type?: string | null | undefined
summary?: string | null | undefined
url?: string | null | undefined
}
export type DocumentWithMemories = z.infer<
typeof DocumentsWithMemoriesResponseSchema
>["documents"][0]
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function strings(values: unknown): string[] {
if (!Array.isArray(values)) return []
return values.filter(
(value): value is string => typeof value === "string" && value.length > 0,
)
}
function normalizeOutput(output: unknown): MemoryToolResult {
if (!isObject(output)) return {}
const nested = [output]
for (const key of [
"memory",
"search",
"searchResult",
"memoryResult",
"memoryOutput",
"hints",
]) {
const value = output[key]
if (isObject(value)) nested.push(value)
}
const sourceIds: string[] = []
const documentIds: string[] = []
const results: MemoryToolResultItem[] = []
const merged: MemoryToolResult = {
query: typeof output.query === "string" ? output.query : undefined,
count: typeof output.count === "number" ? output.count : undefined,
sourceIds,
documentIds,
results,
spaces: Array.isArray(output.spaces)
? (output.spaces.filter(isObject) as MemoryToolResult["spaces"])
: undefined,
}
for (const value of nested) {
sourceIds.push(...strings(value.sourceIds))
documentIds.push(...strings(value.documentIds))
if (Array.isArray(value.results))
results.push(
...(value.results.filter(isObject) as MemoryToolResultItem[]),
)
}
for (const space of merged.spaces ?? []) {
sourceIds.push(...strings(space.sourceIds))
documentIds.push(...strings(space.documentIds))
if (Array.isArray(space.results))
results.push(
...(space.results.filter(isObject) as MemoryToolResultItem[]),
)
}
merged.sourceIds = dedupe(merged.sourceIds ?? [])
merged.documentIds = dedupe(merged.documentIds ?? [])
return merged
}
function dedupe(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean)))
}
function addTarget(
index: Map<string, CitationTarget>,
key: unknown,
target: CitationTarget,
) {
if (typeof key !== "string" || !isSafeSourceId(key)) return
if (!index.has(key)) index.set(key, { ...target, sourceId: key })
}
function citationTargetForResult(
sourceId: string,
result: MemoryToolResultItem,
): CitationTarget {
const doc = firstDocumentForResult(result)
const docId =
doc?.internalDocumentId ??
result.internalDocumentId ??
doc?.id ??
result.documentIds?.find(Boolean) ??
result.documentId
const target = documentTarget(sourceId, doc)
target.documentId = target.documentId ?? docId
target.customId = target.customId ?? result.customId
return target
}
function documentTarget(
sourceId: string,
doc?: ToolDocumentMetadata | null,
): CitationTarget {
return {
sourceId,
documentId: doc?.internalDocumentId ?? doc?.id,
customId:
doc?.customId ??
(doc?.internalDocumentId && doc.id !== doc.internalDocumentId
? doc.id
: undefined),
title: doc?.title,
type: doc?.type,
summary: doc?.summary,
url: doc?.url,
}
}
function firstDocumentForResult(
result: MemoryToolResultItem,
): ToolDocumentMetadata | null {
if (result.document && isObject(result.document)) return result.document
if (Array.isArray(result.documents) && result.documents.length > 0)
return result.documents.find(isObject) ?? null
const firstId =
result.documentIds?.find(Boolean) ??
result.documentId ??
result.internalDocumentId ??
result.customId
return firstId ? { id: firstId, customId: result.customId } : null
}
export function isMemoryToolOutputReady(
part: Record<string, unknown>,
): boolean {
return (
part.state === "output-available" ||
part.state === "done" ||
(part.state === undefined && part.output !== undefined)
)
}
export function extractMemoryToolOutputs(message: {
parts?: readonly unknown[]
}): MemoryToolOutput[] {
const parts = Array.isArray(message.parts) ? message.parts : []
const outputs: MemoryToolOutput[] = []
for (let partIndex = 0; partIndex < parts.length; partIndex++) {
const part = parts[partIndex]
if (!isObject(part)) continue
const type = part.type
if (
typeof type !== "string" ||
!MEMORY_TOOL_PART_TYPES.includes(
type as (typeof MEMORY_TOOL_PART_TYPES)[number],
)
)
continue
if (!isMemoryToolOutputReady(part)) continue
outputs.push({ output: normalizeOutput(part.output) })
}
return outputs
}
export function buildCitationIndex(
outputs: MemoryToolOutput[],
): Map<string, CitationTarget> {
const index = new Map<string, CitationTarget>()
for (const { output } of outputs) {
for (const result of output.results ?? []) {
if (result.citationId)
addTarget(
index,
result.citationId,
citationTargetForResult(result.citationId, result),
)
}
for (const sourceId of output.sourceIds ?? []) {
if (index.has(sourceId)) continue
const matchingResult = (output.results ?? []).find(
(result) => result.citationId === sourceId,
)
if (matchingResult)
addTarget(
index,
sourceId,
citationTargetForResult(sourceId, matchingResult),
)
}
}
return index
}
export function extractDocumentIdsFromMemoryOutput(
output: MemoryToolResult,
): string[] {
const ids: string[] = []
ids.push(...(output.documentIds ?? []))
for (const result of output.results ?? []) {
if (result.id) ids.push(result.id)
if (result.documentId) ids.push(result.documentId)
if (result.internalDocumentId) ids.push(result.internalDocumentId)
ids.push(...(result.documentIds ?? []))
if (result.document?.id) ids.push(result.document.id)
if (result.document?.customId) ids.push(result.document.customId)
for (const doc of result.documents ?? []) {
if (doc.internalDocumentId) ids.push(doc.internalDocumentId)
if (doc.id) ids.push(doc.id)
if (doc.customId) ids.push(doc.customId)
}
}
return dedupe(ids).slice(0, MAX_INLINE_GRAPH_DOCUMENT_IDS)
}
export async function fetchDocumentsByIds(
ids: string[],
): Promise<DocumentWithMemories[]> {
const uniqueIds = dedupe(ids)
if (uniqueIds.length === 0) return []
const fetchBy = async (by: "id" | "customId", requestedIds: string[]) => {
const response = await $fetch("@post/documents/documents/by-ids", {
body: {
ids: requestedIds,
by,
},
disableValidation: true,
})
const result = response as {
error?: { message?: string } | null
data?: { documents?: DocumentWithMemories[] } | null
}
if (result.error) {
throw new Error("Failed to fetch source documents", {
cause: result.error,
})
}
return result.data?.documents ?? []
}
const byIdDocs = await fetchBy("id", uniqueIds)
const seen = new Set<string>()
const foundLookup = new Set<string>()
for (const doc of byIdDocs) {
if (doc.id) {
seen.add(doc.id)
foundLookup.add(doc.id)
}
if (doc.customId) foundLookup.add(doc.customId)
}
const unresolved = uniqueIds.filter((id) => !foundLookup.has(id))
const byCustomDocs =
unresolved.length > 0 ? await fetchBy("customId", unresolved) : []
const merged = [...byIdDocs]
for (const doc of byCustomDocs) {
if (doc.id && !seen.has(doc.id)) {
seen.add(doc.id)
merged.push(doc)
}
}
return merged
}
export function mapDocumentsByKnownIds(
documents: DocumentWithMemories[],
): Map<string, DocumentWithMemories> {
const map = new Map<string, DocumentWithMemories>()
for (const doc of documents) {
if (doc.id) map.set(doc.id, doc)
if (doc.customId) map.set(doc.customId, doc)
}
return map
}
export function getDocumentSourceUrl(
document: Pick<DocumentWithMemories, "type" | "url"> & {
customId?: string | null
},
) {
const url = document.url ?? null
const googleDocTypes: Record<string, { prefix: string; apiPattern: RegExp }> =
{
google_doc: {
prefix: "https://docs.google.com/document/d/",
apiPattern: /docs\.googleapis\.com\/v1\/documents\/([A-Za-z0-9_-]+)/,
},
google_sheet: {
prefix: "https://docs.google.com/spreadsheets/d/",
apiPattern:
/sheets\.googleapis\.com\/v4\/spreadsheets\/([A-Za-z0-9_-]+)/,
},
google_slide: {
prefix: "https://docs.google.com/presentation/d/",
apiPattern:
/slides\.googleapis\.com\/v1\/presentations\/([A-Za-z0-9_-]+)/,
},
}
const googleDoc = document.type ? googleDocTypes[document.type] : undefined
if (!googleDoc) return url
if (document.customId) return `${googleDoc.prefix}${document.customId}/edit`
const apiId = url?.match(googleDoc.apiPattern)?.[1]
return apiId ? `${googleDoc.prefix}${apiId}/edit` : url
}

View file

@ -0,0 +1,40 @@
import type { ViewParamValue } from "@/lib/search-params"
// Integration-family views that live under the real /integrations route.
export const INTEGRATION_VIEWS = [
"integrations",
"mcp",
"plugins",
"chrome",
"connections",
"shortcuts",
"raycast",
"import",
] as const
export type IntegrationView = (typeof INTEGRATION_VIEWS)[number]
// Sub-view cards — each is a nested route segment under /integrations.
export const INTEGRATION_CARDS = INTEGRATION_VIEWS.filter(
(v) => v !== "integrations",
) as Exclude<IntegrationView, "integrations">[]
export function isIntegrationView(view: string): view is IntegrationView {
return (INTEGRATION_VIEWS as readonly string[]).includes(view)
}
export function isIntegrationCard(slug: string): slug is IntegrationView {
return (INTEGRATION_CARDS as readonly string[]).includes(slug)
}
export function integrationViewToPath(view: IntegrationView): string {
return view === "integrations" ? "/integrations" : `/integrations/${view}`
}
export function pathToIntegrationView(pathname: string): ViewParamValue | null {
const trimmed = pathname.replace(/\/$/, "")
if (trimmed === "/integrations") return "integrations"
const slug = trimmed.match(/^\/integrations\/([^/]+)$/)?.[1]
if (slug && isIntegrationCard(slug)) return slug
return null
}

View file

@ -47,7 +47,7 @@ export const PLUGIN_CATALOG: Record<string, PluginInfo> = {
{
title: "Install the plugin",
description: "Run these commands inside a Claude Code session:",
code: "/plugin marketplace add supermemoryai/claude-supermemory\n/plugin install claude-supermemory",
code: "/plugin marketplace add supermemoryai/claude-supermemory\n/plugin install supermemory",
},
],
},

View file

@ -0,0 +1,92 @@
import { describe, expect, it } from "bun:test"
import {
isSafeSourceId,
parseSourceAnnotatedMarkdown,
stripSourceMarkup,
} from "./source-annotations"
describe("source annotation parsing", () => {
it("turns allowed response source spans into internal citation links", () => {
const parsed = parseSourceAnnotatedMarkdown(
'Alpha <response source="S1">Beta [x]</response> Gamma',
new Set(["S1"]),
)
expect(parsed.markdown).toBe("Alpha [Beta \\[x\\]](#sm-source:S1) Gamma")
})
it("renders repeated allowed citations as separate internal links", () => {
const parsed = parseSourceAnnotatedMarkdown(
'<response source="S1">First</response> and <response source="S1">Second</response>',
new Set(["S1"]),
)
expect(parsed.markdown).toBe(
"[First](#sm-source:S1) and [Second](#sm-source:S1)",
)
})
it("renders unknown or unsafe source ids as plain text", () => {
expect(
parseSourceAnnotatedMarkdown(
'Unknown <response source="missing">plain</response>',
new Set(["S1"]),
).markdown,
).toBe("Unknown plain")
expect(
parseSourceAnnotatedMarkdown(
'Unsafe <response source="bad/id">plain</response>',
new Set(["bad/id"]),
).markdown,
).toBe("Unsafe plain")
})
it("keeps unclosed, incomplete, nested, and malformed source markup safe", () => {
expect(
parseSourceAnnotatedMarkdown(
'Lead <response source="S1">unfinished answer',
new Set(["S1"]),
).markdown,
).toBe("Lead unfinished answer")
expect(
parseSourceAnnotatedMarkdown("Lead <response", new Set(["S1"])).markdown,
).toBe("Lead ")
expect(
parseSourceAnnotatedMarkdown(
'Outer <response source="S1">A <response source="S2">B</response> C</response>',
new Set(["S1", "S2"]),
).markdown,
).toBe("Outer A B C")
expect(
parseSourceAnnotatedMarkdown(
"Malformed <response source=S1>plain</response>",
new Set(["S1"]),
).markdown,
).toBe("Malformed plain")
})
it("does not mutate inline code or fenced code blocks", () => {
const input =
'`<response source="S1">code</response>`\n```\n<response source="S1">fenced</response>\n```'
expect(parseSourceAnnotatedMarkdown(input, new Set(["S1"])).markdown).toBe(
input,
)
})
it("strips source markup for copy text", () => {
expect(
stripSourceMarkup('Alpha <response source="S1">Beta</response>'),
).toBe("Alpha Beta")
})
it("allows only source ids that are safe in internal fragments", () => {
expect(isSafeSourceId("S1._:-")).toBe(true)
expect(isSafeSourceId("bad/id")).toBe(false)
expect(isSafeSourceId("bad space")).toBe(false)
})
})

View file

@ -0,0 +1,194 @@
export type ParsedSourceAnnotations = {
markdown: string
}
const RESPONSE_OPEN_PREFIX = "<response"
const RESPONSE_CLOSE_TAG = "</response>"
const SOURCE_ATTR_PREFIX = 'source="'
const SAFE_SOURCE_ID_RE = /^[A-Za-z0-9_.:-]+$/
export function isSafeSourceId(id: string): boolean {
return id.length > 0 && SAFE_SOURCE_ID_RE.test(id)
}
function escapeMarkdownLinkText(text: string): string {
return text.replace(/([\\[\]])/g, "\\$1").replace(/\n/g, " ")
}
function parseOpeningTag(
text: string,
index: number,
): { end: number; sourceId: string } | null | "incomplete" {
if (!text.startsWith(RESPONSE_OPEN_PREFIX, index)) return null
const tagEnd = text.indexOf(">", index + RESPONSE_OPEN_PREFIX.length)
if (tagEnd === -1) return "incomplete"
const rawTag = text.slice(index, tagEnd + 1)
const inside = rawTag.slice(1, -1).trim()
if (!inside.startsWith("response")) return null
let cursor = "response".length
while (
inside[cursor] === " " ||
inside[cursor] === "\t" ||
inside[cursor] === "\n" ||
inside[cursor] === "\r"
)
cursor++
if (!inside.startsWith(SOURCE_ATTR_PREFIX, cursor)) return null
cursor += SOURCE_ATTR_PREFIX.length
const sourceEnd = inside.indexOf('"', cursor)
if (sourceEnd === -1) return null
const sourceId = inside.slice(cursor, sourceEnd)
cursor = sourceEnd + 1
while (
inside[cursor] === " " ||
inside[cursor] === "\t" ||
inside[cursor] === "\n" ||
inside[cursor] === "\r"
)
cursor++
if (cursor !== inside.length) return null
if (!isSafeSourceId(sourceId)) return null
return { end: tagEnd + 1, sourceId }
}
function advanceCodeState(
text: string,
index: number,
state: { inFence: boolean; inInlineCode: boolean; lineStart: boolean },
): boolean {
if (state.lineStart && text.startsWith("```", index)) {
state.inFence = !state.inFence
return true
}
if (!state.inFence && text[index] === "`") {
state.inInlineCode = !state.inInlineCode
return true
}
return false
}
function appendChar(
text: string,
index: number,
output: string[],
state: { lineStart: boolean },
) {
const ch = text[index] ?? ""
output.push(ch)
state.lineStart = ch === "\n"
}
export function parseSourceAnnotatedMarkdown(
text: string,
allowedSourceIds: ReadonlySet<string>,
): ParsedSourceAnnotations {
const output: string[] = []
const codeState = { inFence: false, inInlineCode: false, lineStart: true }
let i = 0
while (i < text.length) {
if (advanceCodeState(text, i, codeState)) {
appendChar(text, i, output, codeState)
i++
continue
}
if (
!codeState.inFence &&
!codeState.inInlineCode &&
text.startsWith(RESPONSE_OPEN_PREFIX, i)
) {
const opening = parseOpeningTag(text, i)
if (opening === "incomplete") {
break
}
if (opening) {
const closeIndex = text.indexOf(RESPONSE_CLOSE_TAG, opening.end)
if (closeIndex === -1) {
output.push(stripSourceMarkup(text.slice(opening.end)))
break
}
const inner = text.slice(opening.end, closeIndex)
const hasNested =
inner.includes(RESPONSE_OPEN_PREFIX) ||
inner.includes(RESPONSE_CLOSE_TAG)
const isAllowed = allowedSourceIds.has(opening.sourceId)
if (hasNested) {
const outerCloseIndex = text.indexOf(
RESPONSE_CLOSE_TAG,
closeIndex + RESPONSE_CLOSE_TAG.length,
)
const fallbackEnd =
outerCloseIndex === -1 ? closeIndex : outerCloseIndex
output.push(stripSourceMarkup(text.slice(opening.end, fallbackEnd)))
i = fallbackEnd + RESPONSE_CLOSE_TAG.length
continue
}
const plainInner = stripSourceMarkup(inner)
if (isAllowed && plainInner.trim().length > 0) {
output.push(
`[${escapeMarkdownLinkText(plainInner)}](#sm-source:${encodeURIComponent(opening.sourceId)})`,
)
} else {
output.push(plainInner)
}
i = closeIndex + RESPONSE_CLOSE_TAG.length
codeState.lineStart =
output.length === 0 ||
output[output.length - 1]?.endsWith("\n") === true
continue
}
const nextClose = text.indexOf(RESPONSE_CLOSE_TAG, i)
if (nextClose !== -1) {
const tagEnd = text.indexOf(">", i)
if (tagEnd !== -1 && tagEnd < nextClose) {
output.push(stripSourceMarkup(text.slice(tagEnd + 1, nextClose)))
i = nextClose + RESPONSE_CLOSE_TAG.length
continue
}
}
}
appendChar(text, i, output, codeState)
i++
}
return { markdown: output.join("") }
}
export function stripSourceMarkup(text: string): string {
let output = ""
let i = 0
while (i < text.length) {
if (text.startsWith(RESPONSE_CLOSE_TAG, i)) {
i += RESPONSE_CLOSE_TAG.length
continue
}
if (text.startsWith(RESPONSE_OPEN_PREFIX, i)) {
const tagEnd = text.indexOf(">", i + RESPONSE_OPEN_PREFIX.length)
if (tagEnd === -1) break
i = tagEnd + 1
continue
}
output += text[i]
i++
}
return output
}

View file

@ -77,7 +77,7 @@ export const isValidUrl = (url: string): boolean => {
*/
export const normalizeUrl = (url: string): string => {
if (!url.trim()) return ""
if (url.startsWith("http://") || url.startsWith("https://")) {
if (/^https?:\/\//i.test(url)) {
return url
}
return `https://${url}`
@ -120,13 +120,40 @@ export const extractUrls = (
return { urls, duplicates }
}
const parseWebUrl = (url: string): URL | null => {
const trimmed = url.trim()
if (!trimmed) return null
try {
const parsed = new URL(trimmed)
return parsed.protocol === "http:" || parsed.protocol === "https:"
? parsed
: null
} catch {
try {
return new URL(`https://${trimmed}`)
} catch {
return null
}
}
}
const hostnameMatches = (hostname: string, domain: string): boolean => {
const normalizedHostname = hostname.toLowerCase()
return (
normalizedHostname === domain || normalizedHostname.endsWith(`.${domain}`)
)
}
/**
* Checks if a URL is a Twitter/X URL.
*/
export const isTwitterUrl = (url: string): boolean => {
const normalizedUrl = url.toLowerCase()
const parsed = parseWebUrl(url)
if (!parsed) return false
return (
normalizedUrl.includes("twitter.com") || normalizedUrl.includes("x.com")
hostnameMatches(parsed.hostname, "twitter.com") ||
hostnameMatches(parsed.hostname, "x.com")
)
}
@ -134,11 +161,11 @@ export const isTwitterUrl = (url: string): boolean => {
* Checks if a URL is a LinkedIn profile URL (not a company page).
*/
export const isLinkedInProfileUrl = (url: string): boolean => {
const normalizedUrl = url.toLowerCase()
return (
normalizedUrl.includes("linkedin.com/in/") &&
!normalizedUrl.includes("linkedin.com/company/")
)
const parsed = parseWebUrl(url)
if (!parsed || !hostnameMatches(parsed.hostname, "linkedin.com")) return false
const [section, handle] = parsed.pathname.split("/").filter(Boolean)
return section?.toLowerCase() === "in" && Boolean(handle)
}
/**

View file

@ -1,24 +1,76 @@
"use client"
import { useQueryState } from "nuqs"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { viewParam, type ViewParamValue } from "@/lib/search-params"
import {
integrationViewToPath,
isIntegrationView,
pathToIntegrationView,
} from "@/lib/integration-routes"
import { analytics } from "@/lib/analytics"
import { useCallback } from "react"
import { useCallback, useEffect } from "react"
export type ViewMode = ViewParamValue
type SetViewMode = (value: ViewMode | null) => Promise<URLSearchParams>
const TRACKED_VIEW_MODES = [
"dashboard",
"graph",
"list",
"integrations",
"chat",
"digests",
] as const
function isTrackedViewMode(
mode: ViewMode,
): mode is (typeof TRACKED_VIEW_MODES)[number] {
return (TRACKED_VIEW_MODES as readonly string[]).includes(mode)
}
export function useViewMode() {
const [viewMode, _setViewMode] = useQueryState("view", viewParam)
const pathname = usePathname()
const router = useRouter()
const [paramView, setParamView] = useQueryState("view", viewParam)
// On /integrations[/card] the path is the source of truth; elsewhere the ?view param is.
const pathView = pathToIntegrationView(pathname)
const viewMode: ViewMode = pathView ?? paramView
const setViewMode = useCallback(
(mode: ViewMode) => {
analytics.viewModeChanged(mode)
;(_setViewMode as SetViewMode)(mode)
if (isTrackedViewMode(mode)) analytics.viewModeChanged(mode)
if (isIntegrationView(mode)) {
router.push(integrationViewToPath(mode))
return
}
// Leaving (or already off) the integrations route for a non-integration view.
if (pathToIntegrationView(pathname)) {
router.push(mode === "dashboard" ? "/" : `/?view=${mode}`)
return
}
void setParamView(mode)
},
[_setViewMode],
[router, pathname, setParamView],
)
return { viewMode, setViewMode, isInitialized: true }
}
// Forwards legacy /?view=integrations (and sub-views) to the canonical /integrations route,
// preserving any other query params. Call once near the app root.
export function useLegacyViewRedirect() {
const pathname = usePathname()
const router = useRouter()
const searchParams = useSearchParams()
useEffect(() => {
if (pathname !== "/") return
const view = searchParams.get("view")
if (!view || !isIntegrationView(view)) return
const params = new URLSearchParams(searchParams.toString())
params.delete("view")
const qs = params.toString()
router.replace(integrationViewToPath(view) + (qs ? `?${qs}` : ""))
}, [pathname, searchParams, router])
}

View file

@ -36,6 +36,14 @@ export default async function proxy(request: Request) {
return NextResponse.next()
}
// Real integrations routes, public in guest mode (mirrors view=integrations / view=mcp).
if (
url.pathname === "/integrations" ||
url.pathname === "/integrations/mcp"
) {
return NextResponse.next()
}
if (url.pathname.startsWith("/api/")) {
if (!sessionCookie) {
console.debug("[MIDDLEWARE] API route without session, returning 401")

View file

@ -30,7 +30,6 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.27.0",
"@lobbyside/react": "0.2.0",
"@opennextjs/cloudflare": "^1.12.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",

View file

@ -3,18 +3,27 @@
import { useQueryState } from "nuqs"
import { projectParam } from "@/lib/search-params"
import { useCallback } from "react"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import { DEFAULT_PROJECT_ID, SHARED_TEAM_BRAIN_TAG } from "@lib/constants"
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
export function useProject() {
const [selectedProjects, _setSelectedProjects] = useQueryState(
"project",
projectParam,
)
const hasCompanyBrain = useHasCompanyBrain()
const defaultTag = hasCompanyBrain
? SHARED_TEAM_BRAIN_TAG
: DEFAULT_PROJECT_ID
const selectedProject = selectedProjects[0] ?? DEFAULT_PROJECT_ID
// Normalize empty selection to the default tag so the selector, counts, and
// queries all agree (shared Team Brain for company-brain orgs).
const normalizedProjects =
selectedProjects.length === 0 ? [defaultTag] : selectedProjects
const effectiveContainerTags =
selectedProjects.length === 0 ? [DEFAULT_PROJECT_ID] : selectedProjects
const selectedProject = normalizedProjects[0]
const effectiveContainerTags = normalizedProjects
const setSelectedProjects = useCallback(
(projects: string[]) => {
@ -31,7 +40,7 @@ export function useProject() {
)
return {
selectedProjects,
selectedProjects: normalizedProjects,
selectedProject,
setSelectedProjects,
setSelectedProject,

View file

@ -151,7 +151,6 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.27.0",
"@lobbyside/react": "0.2.0",
"@opennextjs/cloudflare": "^1.12.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
@ -992,10 +991,6 @@
"@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="],
"@instantdb/core": ["@instantdb/core@1.0.15", "", { "dependencies": { "@instantdb/version": "1.0.15", "mutative": "^1.0.10", "uuid": "^11.1.0" } }, "sha512-1A4n47U0YLHKhvl0G+CiPGfBynq1cj+NqIVRhUMc/yHYT6rePeRWesxvevNiEJU2sLxOKML6Htcbtdh7jUjSQA=="],
"@instantdb/version": ["@instantdb/version@1.0.15", "", {}, "sha512-xHDT23QK0tKAdxC2Z98mBx+znwnVShYWDsp0juY4xxzTGjrb37qNqRYb+6IIJc1J3GZ+xW/fCIexVK3b0B6fog=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/ttlcache": ["@isaacs/ttlcache@2.1.4", "", {}, "sha512-7kMz0BJpMvgAMkyglums7B2vtrn5g0a0am77JY0GjkZZNetOBCFn7AG7gKCwT0QPiXyxW7YIQSgtARknUEOcxQ=="],
@ -1026,8 +1021,6 @@
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
"@lobbyside/react": ["@lobbyside/react@0.2.0", "", { "peerDependencies": { "@instantdb/core": ">=1.0.0", "react": ">=18.0.0" } }, "sha512-24dTNDImAqZrlCu+vlPKulP1fvL3yTIc6qwxqBsqvit4z9WXnAdMRwB5Bvn31dTuaBokQEVZa3t0VfMnyhGvuw=="],
"@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="],
"@lukeed/uuid": ["@lukeed/uuid@2.0.1", "", { "dependencies": { "@lukeed/csprng": "^1.1.0" } }, "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w=="],
@ -3868,8 +3861,6 @@
"multimatch": ["multimatch@6.0.0", "", { "dependencies": { "@types/minimatch": "^3.0.5", "array-differ": "^4.0.0", "array-union": "^3.0.1", "minimatch": "^3.0.4" } }, "sha512-I7tSVxHGPlmPN/enE3mS1aOSo6bWBfls+3HmuEeCUBCE7gWnm3cBXCBkpurzFjVRwC6Kld8lLaZ1Iv5vOcjvcQ=="],
"mutative": ["mutative@1.3.0", "", {}, "sha512-8MJj6URmOZAV70dpFe1YnSppRTKC4DsMkXQiBDFayLcDI4ljGokHxmpqaBQuDWa4iAxWaJJ1PS8vAmbntjjKmQ=="],
"mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
@ -5228,8 +5219,6 @@
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"@instantdb/core/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],

View file

@ -18,6 +18,23 @@ type OrganizationListItem = NonNullable<
const STORAGE_KEY = "supermemory-consumer-last-org-slug"
// Reads ?org=<slug> from the URL once and removes it, so a deep link that
// selects an org doesn't re-fire on refresh or back-navigation.
function consumeRequestedOrgSlug(): string | null {
if (typeof window === "undefined") return null
const params = new URLSearchParams(window.location.search)
const slug = params.get("org")
if (!slug) return null
params.delete("org")
const qs = params.toString()
window.history.replaceState(
null,
"",
`${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`,
)
return slug
}
interface AuthContextType {
session: SessionData["session"] | null
user: SessionData["user"] | null
@ -123,6 +140,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const activeOrgId = session.session.activeOrganizationId
// Deep link (?org=<slug>) takes priority — used when arriving from
// the console. Strip the param so refresh/back doesn't re-trigger.
const requestedSlug = consumeRequestedOrgSlug()
if (requestedSlug) {
const match = orgs.find((o) => o.slug === requestedSlug)
if (match) {
if (activeOrgId === match.id) {
const full = await authClient.organization.getFullOrganization()
if (!cancelled) setOrg(full?.data ?? null)
} else {
await setActiveOrg(requestedSlug)
}
return
}
}
if (orgs.length === 1) {
const one = orgs[0]
if (!one) return

View file

@ -1,5 +1,6 @@
const BIG_DIMENSIONS_NEW = 1536
const DEFAULT_PROJECT_ID = "sm_project_default"
const SHARED_TEAM_BRAIN_TAG = "sm_org_shared"
const SEARCH_MEMORY_SHORTCUT_URL =
"https://www.icloud.com/shortcuts/b0a132cc3c0d475196bc7014aa702a5c"
const ADD_MEMORY_SHORTCUT_URL =
@ -12,6 +13,7 @@ const POKE_RECIPE_URL = "https://supermemory.link/poke"
export {
BIG_DIMENSIONS_NEW,
DEFAULT_PROJECT_ID,
SHARED_TEAM_BRAIN_TAG,
SEARCH_MEMORY_SHORTCUT_URL,
ADD_MEMORY_SHORTCUT_URL,
RAYCAST_EXTENSION_URL,

View file

@ -2,6 +2,7 @@
import { usePathname, useSearchParams } from "next/navigation"
import posthog from "posthog-js"
import { PostHogProvider as PHProvider } from "posthog-js/react"
import { Suspense, useEffect } from "react"
import { useSession } from "./auth"
@ -65,12 +66,12 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
}, [session?.user])
return (
<>
<PHProvider client={posthog}>
<Suspense fallback={null}>
{process.env.NODE_ENV === "production" && <PostHogPageTracking />}
</Suspense>
{children}
</>
</PHProvider>
)
}

View file

@ -6,6 +6,7 @@ export interface Project {
updatedAt: string
isExperimental?: boolean
emoji?: string
visibility?: "public" | "private" | "unlisted"
}
export interface ContainerTagListType extends Project {

View file

@ -48,13 +48,18 @@ function DialogOverlay({
function DialogContent({
className,
children,
portalContainer,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
portalContainer?: HTMLElement | null
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogPortal
container={portalContainer ?? undefined}
data-slot="dialog-portal"
>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(

View file

@ -1496,6 +1496,10 @@ export const ContainerTagListTypeSchema = z
description: "True if containerTag starts with 'sm_project_'",
example: true,
}),
visibility: z.enum(["public", "private", "unlisted"]).optional().openapi({
description: "Space visibility (company brain spaces)",
example: "public",
}),
})
.openapi({
description: