mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-08 03:08:21 +00:00
Merge main into timeout additions PR
Co-authored-by: Dhravya Shah <dhravya@supermemory.com>
This commit is contained in:
commit
efdc475990
63 changed files with 10143 additions and 2323 deletions
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
title: "Changelog"
|
||||
sidebarTitle: "Supermemory"
|
||||
description: "New updates and improvements to Supermemory"
|
||||
---
|
||||
|
||||
|
|
|
|||
81
apps/docs/changelog/plugins.mdx
Normal file
81
apps/docs/changelog/plugins.mdx
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
288
apps/docs/memory-review.mdx
Normal 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
|
||||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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
3480
apps/mcp/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
13
apps/web/app/(app)/integrations/[card]/page.tsx
Normal file
13
apps/web/app/(app)/integrations/[card]/page.tsx
Normal 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 />
|
||||
}
|
||||
5
apps/web/app/(app)/integrations/page.tsx
Normal file
5
apps/web/app/(app)/integrations/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { AppExperience } from "@/components/app-experience"
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
return <AppExperience />
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
388
apps/web/app/org/invite/[invitationId]/page.tsx
Normal file
388
apps/web/app/org/invite/[invitationId]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
879
apps/web/components/app-experience.tsx
Normal file
879
apps/web/components/app-experience.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
360
apps/web/components/brain-home/brain-home-view.tsx
Normal file
360
apps/web/components/brain-home/brain-home-view.tsx
Normal 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()
|
||||
}
|
||||
393
apps/web/components/brain-home/connections-board.tsx
Normal file
393
apps/web/components/brain-home/connections-board.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
582
apps/web/components/orbit-memory.tsx
Normal file
582
apps/web/components/orbit-memory.tsx
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
433
apps/web/components/settings/company-brain-connections.tsx
Normal file
433
apps/web/components/settings/company-brain-connections.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
106
apps/web/components/slack-connect-card.tsx
Normal file
106
apps/web/components/slack-connect-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 &&
|
||||
|
|
|
|||
16
apps/web/hooks/use-company-brain.ts
Normal file
16
apps/web/hooks/use-company-brain.ts
Normal 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"
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
177
apps/web/lib/chat-memory-tools.test.ts
Normal file
177
apps/web/lib/chat-memory-tools.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
375
apps/web/lib/chat-memory-tools.ts
Normal file
375
apps/web/lib/chat-memory-tools.ts
Normal 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
|
||||
}
|
||||
40
apps/web/lib/integration-routes.ts
Normal file
40
apps/web/lib/integration-routes.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
92
apps/web/lib/source-annotations.test.ts
Normal file
92
apps/web/lib/source-annotations.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
194
apps/web/lib/source-annotations.ts
Normal file
194
apps/web/lib/source-annotations.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
11
bun.lock
11
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface Project {
|
|||
updatedAt: string
|
||||
isExperimental?: boolean
|
||||
emoji?: string
|
||||
visibility?: "public" | "private" | "unlisted"
|
||||
}
|
||||
|
||||
export interface ContainerTagListType extends Project {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue