Merge main into timeout additions PR

Co-authored-by: Dhravya Shah <dhravya@supermemory.com>
This commit is contained in:
Cursor Agent 2026-06-29 19:23:27 +00:00
commit efdc475990
No known key found for this signature in database
63 changed files with 10143 additions and 2323 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 (0–1) 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 (1–500). 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 `-`/`_`, 1–64 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

@ -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

@ -16,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,
@ -38,12 +39,26 @@ 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
@ -64,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>({
@ -82,6 +99,7 @@ export default function BrainOnboardingPage() {
})
useEffect(() => {
if (forceCreate) return
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
@ -96,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 {
@ -163,6 +181,13 @@ 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,
@ -175,34 +200,48 @@ export default function BrainOnboardingPage() {
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, mode, sources, team])
}, [router, mode, sources, team, forceCreate])
const goNext = useCallback(() => {
const idx = BRAIN_STEPS.indexOf(step)
const idx = steps.indexOf(step)
analytics.onboardingStepCompleted({ step, index: idx })
const next = BRAIN_STEPS[idx + 1]
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() } : {}),
}
@ -211,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(),
@ -225,15 +269,25 @@ export default function BrainOnboardingPage() {
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 () => {
@ -244,16 +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)
analytics.onboardingWorkspaceCreateFailed({
error: e instanceof Error ? e.message : String(e),
error: message,
})
toast.error("Couldn't create your workspace. Please try again.")
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)
@ -313,6 +373,7 @@ export default function BrainOnboardingPage() {
return (
<BrainShell
step={step}
steps={steps}
domain={mode === "team" ? about.workspaceDomain || domain : null}
>
{step === "about" && (
@ -322,6 +383,7 @@ export default function BrainOnboardingPage() {
analytics.onboardingModeSelected({ mode: m })
setMode(m)
}}
allowTeam={allowTeam}
domain={domain}
suggestedWorkspaceName={suggestedWorkspaceName}
defaultName={user?.name ?? ""}
@ -342,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}

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. We’d 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,21 +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 = {
@ -28,102 +96,24 @@ 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) => {
analytics.onboardingAgentSelected({ agent: agent.key })
setSelectedKey(agent.key)
}
const activeTool = useMemo(
() => tools.find((t) => t.id === selected) ?? tools[0],
[tools, selected],
)
const handleContinue = () => {
analytics.onboardingIngestCompleted()
@ -135,79 +125,411 @@ export function StepIngest({ mcpUrl, onContinue }: Props) {
onContinue()
}
const filtered = AGENTS.filter((a) => a.category === activeCategory)
return (
<div className="space-y-5">
<div className="px-1">
<p
className={cn(
"font-semibold text-[#fafafa] text-[22px]",
dmSans125ClassName(),
)}
>
Use your brain anywhere
</p>
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
Now plug it into the tools you already use to write code, chat, think.
</p>
</div>
<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={handleSkip}
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors"
>
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 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)
@ -220,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" />
@ -258,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>
)
}

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 { useRef, 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
}) {
@ -148,7 +169,12 @@ 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({
@ -167,11 +193,44 @@ 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)
window.requestAnimationFrame(() => {
setDeleteOrgConfirm("")
if (deleteOrgDialogTimerRef.current) {
clearTimeout(deleteOrgDialogTimerRef.current)
}
deleteOrgDialogTimerRef.current = setTimeout(() => {
document.body.style.pointerEvents = ""
setIsDeleteOrgDialogOpen(true)
})
deleteOrgDialogTimerRef.current = null
}, 120)
}
const displayName =
@ -187,7 +246,7 @@ export function SettingsContent({
}
const handleIntegrations = () => {
void router.push("/?view=integrations")
void router.push("/integrations")
}
const handleDeleteAccount = async () => {
@ -421,6 +480,7 @@ export function SettingsContent({
{activeTab === "billing" && <Billing />}
{activeTab === "integrations" && <Integrations />}
{activeTab === "connections" && <ConnectionsMCP />}
{activeTab === "company-brain" && <CompanyBrainConnections />}
{activeTab === "support" && <Support />}
</ErrorBoundary>
</section>
@ -601,6 +661,7 @@ export function SettingsContent({
{/* Delete organization dialog */}
<Dialog
key={activeOrgId ?? "delete-organization"}
open={isDeleteOrgDialogOpen}
onOpenChange={(open) => {
setIsDeleteOrgDialogOpen(open)
@ -608,33 +669,58 @@ export function SettingsContent({
}}
>
<DialogContent
className="sm:max-w-md"
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={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]">
<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"
@ -642,43 +728,47 @@ export function SettingsContent({
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

@ -94,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"),

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

@ -140,7 +140,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",
@ -981,10 +980,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=="],
@ -1015,8 +1010,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=="],
@ -3877,8 +3870,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=="],
@ -5241,8 +5232,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: