mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
Merge main into MCP revamp PR
Resolve MCP revamp conflicts by keeping the new server layout, deleting legacy root MCP files, and carrying forward API timeout handling into the new auth/client modules.
This commit is contained in:
commit
d36aa64556
66 changed files with 10359 additions and 2346 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
|
|
@ -6,6 +6,8 @@
|
|||
|
||||
import type { ContainerTagAccess } from "../../shared/types"
|
||||
|
||||
const FETCH_TIMEOUT_MS = 30_000
|
||||
|
||||
export type { ContainerTagAccess }
|
||||
|
||||
export interface AuthUser {
|
||||
|
|
@ -30,6 +32,7 @@ export async function validateApiKey(
|
|||
const response = await fetch(`${apiUrl}/v3/session`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -82,6 +85,7 @@ export async function validateOAuthToken(
|
|||
const response = await fetch(`${apiUrl}/v3/mcp/session-with-key`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -124,6 +128,7 @@ export async function validateOAuthToken(
|
|||
const rbacResponse = await fetch(`${apiUrl}/v3/session`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${data.apiKey}` },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
})
|
||||
if (!rbacResponse.ok) {
|
||||
console.error("RBAC fetch returned non-OK:", rbacResponse.status)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
|
||||
const MAX_CHARS = 200000
|
||||
const DEFAULT_PROJECT_ID = "sm_project_default"
|
||||
const FETCH_TIMEOUT_MS = 30_000
|
||||
|
||||
export type {
|
||||
ContainerTag,
|
||||
|
|
@ -82,6 +83,7 @@ export class SupermemoryClient {
|
|||
this.client = new Supermemory({
|
||||
apiKey: bearerToken,
|
||||
baseURL: apiUrl,
|
||||
timeout: FETCH_TIMEOUT_MS,
|
||||
})
|
||||
this.containerTag = containerTag || DEFAULT_PROJECT_ID
|
||||
}
|
||||
|
|
@ -242,12 +244,14 @@ export class SupermemoryClient {
|
|||
|
||||
async listContainerTags(): Promise<ContainerTag[]> {
|
||||
try {
|
||||
const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
||||
const response = await fetch(`${this.apiUrl}/v3/container-tags/list`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.bearerToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -270,8 +274,10 @@ export class SupermemoryClient {
|
|||
containerTags?: string[],
|
||||
page = 1,
|
||||
limit = 200,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<DocumentsApiResponse> {
|
||||
try {
|
||||
const signal = options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
||||
const response = await fetch(`${this.apiUrl}/v3/documents/documents`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -285,6 +291,7 @@ export class SupermemoryClient {
|
|||
order: "desc",
|
||||
containerTags,
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error("Failed to fetch documents"), {
|
||||
|
|
@ -338,6 +345,15 @@ export class SupermemoryClient {
|
|||
}
|
||||
|
||||
private handleError(error: unknown): never {
|
||||
// Handle request timeout (AbortSignal.timeout or explicit abort)
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.name === "AbortError" || error.name === "TimeoutError")
|
||||
) {
|
||||
throw new Error("Request to Supermemory API timed out")
|
||||
}
|
||||
|
||||
// Handle network/fetch errors
|
||||
if (error instanceof TypeError) {
|
||||
if (
|
||||
error.message.includes("fetch") ||
|
||||
|
|
|
|||
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 />
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"
|
|||
import { toast } from "sonner"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { BrainShell } from "@/components/onboarding-brain/shell"
|
||||
import {
|
||||
StepAbout,
|
||||
|
|
@ -15,6 +16,7 @@ import {
|
|||
type SourcesValues,
|
||||
} from "@/components/onboarding-brain/step-sources"
|
||||
import { StepIngest } from "@/components/onboarding-brain/step-ingest"
|
||||
import { useFeatureFlagEnabled } from "posthog-js/react"
|
||||
import {
|
||||
StepTeam,
|
||||
type TeamValues,
|
||||
|
|
@ -34,12 +36,29 @@ import {
|
|||
|
||||
const STORAGE_KEY = "supermemory-brain-onboarding-v1"
|
||||
|
||||
const countsAsConnectedSource = (state: unknown) =>
|
||||
state === "connected" || state === "waitlist"
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string) => {
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
if (typeof error === "string" && error.trim()) return error
|
||||
if (typeof error === "object" && error !== null && "message" in error) {
|
||||
const message = (error as { message?: unknown }).message
|
||||
if (typeof message === "string" && message.trim()) return message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export default function BrainOnboardingPage() {
|
||||
const router = useRouter()
|
||||
const params = useSearchParams()
|
||||
const { user, org, organizations, setActiveOrg, refetchOrganizations } =
|
||||
useAuth()
|
||||
|
||||
// `?new=1` forces creating an additional org even when the user already has one.
|
||||
const forceCreate = params?.get("new") === "1"
|
||||
const nameParam = params?.get("name")?.trim() || ""
|
||||
|
||||
const stepFromUrl = (params?.get("step") as BrainStep | null) ?? "about"
|
||||
const initialStep: BrainStep = BRAIN_STEPS.includes(stepFromUrl)
|
||||
? stepFromUrl
|
||||
|
|
@ -60,11 +79,13 @@ export default function BrainOnboardingPage() {
|
|||
[user?.email],
|
||||
)
|
||||
|
||||
// Team (Company Brain) onboarding is gated behind a private-beta flag.
|
||||
const allowTeam = useFeatureFlagEnabled("company-brain-beta") ?? false
|
||||
const [mode, setMode] = useState<BrainMode>(detectedMode)
|
||||
const [about, setAbout] = useState<AboutValues>({
|
||||
name: user?.name ?? "",
|
||||
about: "",
|
||||
workspaceName: suggestedWorkspaceName,
|
||||
workspaceName: nameParam || suggestedWorkspaceName,
|
||||
workspaceDomain: domain ?? "",
|
||||
})
|
||||
const [sources, setSources] = useState<SourcesValues>({
|
||||
|
|
@ -78,6 +99,7 @@ export default function BrainOnboardingPage() {
|
|||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (forceCreate) return
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return
|
||||
|
|
@ -92,7 +114,7 @@ export default function BrainOnboardingPage() {
|
|||
if (cached.sources) setSources((s) => ({ ...s, ...cached.sources }))
|
||||
if (cached.team) setTeam((t) => ({ ...t, ...cached.team }))
|
||||
} catch {}
|
||||
}, [])
|
||||
}, [forceCreate])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
|
|
@ -103,8 +125,40 @@ export default function BrainOnboardingPage() {
|
|||
} catch {}
|
||||
}, [mode, about, sources, team])
|
||||
|
||||
const navTrigger = useRef<"user" | "auto">("auto")
|
||||
const startedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (startedRef.current) return
|
||||
startedRef.current = true
|
||||
analytics.onboardingStarted({
|
||||
mode: detectedMode,
|
||||
entry_step: initialStep,
|
||||
})
|
||||
analytics.onboardingStepViewed({
|
||||
step: initialStep,
|
||||
index: BRAIN_STEPS.indexOf(initialStep),
|
||||
trigger: "auto",
|
||||
})
|
||||
}, [detectedMode, initialStep])
|
||||
|
||||
const firstStepRender = useRef(true)
|
||||
useEffect(() => {
|
||||
// Skip the mount run — the gated effect above fires the initial view.
|
||||
if (firstStepRender.current) {
|
||||
firstStepRender.current = false
|
||||
return
|
||||
}
|
||||
analytics.onboardingStepViewed({
|
||||
step,
|
||||
index: BRAIN_STEPS.indexOf(step),
|
||||
trigger: navTrigger.current,
|
||||
})
|
||||
navTrigger.current = "auto"
|
||||
}, [step])
|
||||
|
||||
const setStepAndUrl = useCallback(
|
||||
(next: BrainStep) => {
|
||||
navTrigger.current = "user"
|
||||
setStep(next)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("step", next)
|
||||
|
|
@ -127,37 +181,67 @@ export default function BrainOnboardingPage() {
|
|||
return plan === "scale" || plan === "scale_yearly"
|
||||
}, [org])
|
||||
|
||||
// Personal onboarding has no team step — drop it from the flow + stepper.
|
||||
const steps = useMemo<BrainStep[]>(
|
||||
() =>
|
||||
mode === "team" ? BRAIN_STEPS : BRAIN_STEPS.filter((s) => s !== "team"),
|
||||
[mode],
|
||||
)
|
||||
|
||||
const finish = useCallback(async () => {
|
||||
analytics.onboardingCompleted({
|
||||
mode,
|
||||
steps_completed: BRAIN_STEPS.length,
|
||||
sources_connected: Object.values(sources.connected).filter(
|
||||
countsAsConnectedSource,
|
||||
).length,
|
||||
invites_sent: team.invites.filter((i) => i.email.trim()).length,
|
||||
})
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
} catch {}
|
||||
// Extra org from settings: hard-reload so org-scoped caches don't show the previous org's data.
|
||||
if (forceCreate) {
|
||||
window.location.href = "/?onboarded=1"
|
||||
return
|
||||
}
|
||||
router.push("/?onboarded=1")
|
||||
}, [router])
|
||||
}, [router, mode, sources, team, forceCreate])
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
const idx = BRAIN_STEPS.indexOf(step)
|
||||
const next = BRAIN_STEPS[idx + 1]
|
||||
const idx = steps.indexOf(step)
|
||||
analytics.onboardingStepCompleted({ step, index: idx })
|
||||
const next = steps[idx + 1]
|
||||
if (!next) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
setStepAndUrl(next)
|
||||
}, [step, setStepAndUrl, finish])
|
||||
}, [step, steps, setStepAndUrl, finish])
|
||||
|
||||
// If the current step isn't valid for the mode (e.g. switched to personal),
|
||||
// fall back to the last valid step.
|
||||
useEffect(() => {
|
||||
if (!steps.includes(step)) {
|
||||
setStepAndUrl(steps[steps.length - 1] ?? "about")
|
||||
}
|
||||
}, [steps, step, setStepAndUrl])
|
||||
|
||||
const [creatingOrg, setCreatingOrg] = useState(false)
|
||||
const creatingOrgRef = useRef(false)
|
||||
|
||||
const ensureOrg = useCallback(async () => {
|
||||
if (organizations && organizations.length > 0) return
|
||||
if (!forceCreate && organizations && organizations.length > 0) return
|
||||
const name = (about.workspaceName || suggestedWorkspaceName).trim()
|
||||
const slug = generateOrgSlug(name)
|
||||
const effectiveMode = allowTeam ? mode : "personal"
|
||||
const metadata: BrainMetadata & { signupSource: string } = {
|
||||
signupSource: "consumer",
|
||||
brainOnboardingVersion: "v1",
|
||||
brainMode: mode,
|
||||
brainMode: effectiveMode,
|
||||
brainWorkspaceName: name,
|
||||
brainWorkspaceDomain:
|
||||
mode === "team" ? about.workspaceDomain || domain : null,
|
||||
effectiveMode === "team" ? about.workspaceDomain || domain : null,
|
||||
brainContainerTag: containerTag,
|
||||
...(about.about.trim() ? { brainAbout: about.about.trim() } : {}),
|
||||
}
|
||||
|
|
@ -166,7 +250,12 @@ export default function BrainOnboardingPage() {
|
|||
slug,
|
||||
metadata,
|
||||
})
|
||||
await setActiveOrg(result.data?.slug ?? slug)
|
||||
if (result.error || !result.data?.slug) {
|
||||
throw new Error(
|
||||
getErrorMessage(result.error, "Organization was not created."),
|
||||
)
|
||||
}
|
||||
await setActiveOrg(result.data.slug)
|
||||
if (about.name.trim()) {
|
||||
await authClient.updateUser({
|
||||
name: about.name.trim(),
|
||||
|
|
@ -175,15 +264,30 @@ export default function BrainOnboardingPage() {
|
|||
})
|
||||
}
|
||||
await refetchOrganizations()
|
||||
analytics.onboardingWorkspaceCreated({
|
||||
mode,
|
||||
has_about: Boolean(about.about.trim()),
|
||||
has_domain: Boolean(mode === "team" && (about.workspaceDomain || domain)),
|
||||
})
|
||||
// Drop new=1 so a reload or back+Continue reuses this org instead of creating a duplicate.
|
||||
if (forceCreate) {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete("new")
|
||||
url.searchParams.delete("name")
|
||||
router.replace(url.pathname + url.search, { scroll: false })
|
||||
}
|
||||
}, [
|
||||
organizations,
|
||||
about,
|
||||
suggestedWorkspaceName,
|
||||
mode,
|
||||
allowTeam,
|
||||
domain,
|
||||
containerTag,
|
||||
setActiveOrg,
|
||||
refetchOrganizations,
|
||||
forceCreate,
|
||||
router,
|
||||
])
|
||||
|
||||
const handleAboutContinue = useCallback(async () => {
|
||||
|
|
@ -194,13 +298,22 @@ export default function BrainOnboardingPage() {
|
|||
await ensureOrg()
|
||||
goNext()
|
||||
} catch (e) {
|
||||
const message = getErrorMessage(e, "Organization was not created.")
|
||||
console.error("Failed to create organization:", e)
|
||||
toast.error("Couldn't create your workspace. Please try again.")
|
||||
analytics.onboardingWorkspaceCreateFailed({
|
||||
error: message,
|
||||
})
|
||||
toast.error("Organization was not created", {
|
||||
description: "Please try again from Settings.",
|
||||
})
|
||||
if (forceCreate && (organizations?.length ?? 0) > 0) {
|
||||
router.replace("/")
|
||||
}
|
||||
} finally {
|
||||
creatingOrgRef.current = false
|
||||
setCreatingOrg(false)
|
||||
}
|
||||
}, [ensureOrg, goNext])
|
||||
}, [ensureOrg, goNext, forceCreate, organizations, router])
|
||||
|
||||
const [sendingInvites, setSendingInvites] = useState(false)
|
||||
const sendingInvitesRef = useRef(false)
|
||||
|
|
@ -209,6 +322,7 @@ export default function BrainOnboardingPage() {
|
|||
if (sendingInvitesRef.current) return
|
||||
const pending = team.invites.filter((i) => i.email.trim())
|
||||
if (pending.length === 0) {
|
||||
analytics.onboardingTeamSkipped()
|
||||
goNext()
|
||||
return
|
||||
}
|
||||
|
|
@ -231,6 +345,10 @@ export default function BrainOnboardingPage() {
|
|||
r.status === "rejected" ||
|
||||
(r.status === "fulfilled" && Boolean(r.value?.error)),
|
||||
).length
|
||||
analytics.onboardingInvitesSent({
|
||||
sent: pending.length - failed,
|
||||
failed,
|
||||
})
|
||||
if (failed > 0) {
|
||||
toast.error(
|
||||
`${failed} of ${pending.length} invite${pending.length === 1 ? "" : "s"} couldn't be sent.`,
|
||||
|
|
@ -255,12 +373,17 @@ export default function BrainOnboardingPage() {
|
|||
return (
|
||||
<BrainShell
|
||||
step={step}
|
||||
steps={steps}
|
||||
domain={mode === "team" ? about.workspaceDomain || domain : null}
|
||||
>
|
||||
{step === "about" && (
|
||||
<StepAbout
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
onModeChange={(m) => {
|
||||
analytics.onboardingModeSelected({ mode: m })
|
||||
setMode(m)
|
||||
}}
|
||||
allowTeam={allowTeam}
|
||||
domain={domain}
|
||||
suggestedWorkspaceName={suggestedWorkspaceName}
|
||||
defaultName={user?.name ?? ""}
|
||||
|
|
@ -281,7 +404,13 @@ export default function BrainOnboardingPage() {
|
|||
onContinue={goNext}
|
||||
/>
|
||||
)}
|
||||
{step === "ingest" && <StepIngest mcpUrl={mcpUrl} onContinue={goNext} />}
|
||||
{step === "ingest" && (
|
||||
<StepIngest
|
||||
mode={allowTeam ? mode : "personal"}
|
||||
mcpUrl={mcpUrl}
|
||||
onContinue={goNext}
|
||||
/>
|
||||
)}
|
||||
{step === "team" && (
|
||||
<StepTeam
|
||||
mode={mode}
|
||||
|
|
@ -290,7 +419,10 @@ export default function BrainOnboardingPage() {
|
|||
values={team}
|
||||
onChange={setTeam}
|
||||
onContinue={handleTeamContinue}
|
||||
onSkip={goNext}
|
||||
onSkip={() => {
|
||||
analytics.onboardingTeamSkipped()
|
||||
goNext()
|
||||
}}
|
||||
submitting={sendingInvites}
|
||||
onUpgrade={() => router.push("/settings/billing")}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,20 +1,89 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import type { ReactNode } from "react"
|
||||
import Image from "next/image"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { MCPIcon } from "@ui/assets/icons"
|
||||
import { ArrowRight, Check, Copy, EyeOff, Eye } from "lucide-react"
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Plug,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { toast } from "sonner"
|
||||
import { MCPSteps } from "@/components/mcp-modal/mcp-detail-view"
|
||||
import { PLUGIN_CATALOG } from "@/lib/plugin-catalog"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import type { BrainMode } from "./types"
|
||||
|
||||
interface Props {
|
||||
mcpUrl: string
|
||||
onContinue: () => void
|
||||
const BACKEND =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
|
||||
const TEST_PROMPT = "What do we know about [topic]?"
|
||||
|
||||
type FlowToolId = "slack" | "mcp" | "codex" | "claude-code"
|
||||
type FlowToolKind = "slack" | "mcp" | "plugin"
|
||||
|
||||
type FlowTool = {
|
||||
id: FlowToolId
|
||||
label: string
|
||||
blurb: string
|
||||
kind: FlowToolKind
|
||||
pluginId?: string
|
||||
recommended?: boolean
|
||||
}
|
||||
|
||||
const TOOL_OPTIONS: Record<BrainMode, [FlowTool, ...FlowTool[]]> = {
|
||||
team: [
|
||||
{
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
blurb: "Ask questions in-channel.",
|
||||
kind: "slack",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "claude-code",
|
||||
label: "Claude Code",
|
||||
blurb: "Shared context in your terminal.",
|
||||
kind: "plugin",
|
||||
pluginId: "claude_code",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
blurb: "OpenAI's coding agent.",
|
||||
kind: "plugin",
|
||||
pluginId: "codex",
|
||||
},
|
||||
],
|
||||
personal: [
|
||||
{
|
||||
id: "mcp",
|
||||
label: "MCP",
|
||||
blurb: "Use the universal URL in any client.",
|
||||
kind: "mcp",
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
blurb: "OpenAI's coding agent.",
|
||||
kind: "plugin",
|
||||
pluginId: "codex",
|
||||
},
|
||||
{
|
||||
id: "claude-code",
|
||||
label: "Claude Code",
|
||||
blurb: "Context in your terminal.",
|
||||
kind: "plugin",
|
||||
pluginId: "claude_code",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const modalCardStyle = {
|
||||
|
|
@ -27,175 +96,440 @@ const inputBevelStyle = {
|
|||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
||||
}
|
||||
|
||||
type AgentCategory = "coding" | "productivity"
|
||||
|
||||
type Agent = {
|
||||
key: string
|
||||
name: string
|
||||
tagline: string
|
||||
category: AgentCategory
|
||||
pluginId?: string
|
||||
interface Props {
|
||||
mode: BrainMode
|
||||
mcpUrl: string
|
||||
onContinue: () => void
|
||||
}
|
||||
|
||||
const AGENTS: Agent[] = [
|
||||
{
|
||||
key: "cursor",
|
||||
name: "Cursor",
|
||||
tagline: "Persistent context across coding sessions.",
|
||||
category: "coding",
|
||||
},
|
||||
{
|
||||
key: "claude-code",
|
||||
name: "Claude Code",
|
||||
tagline: "Memory and decisions across CLI sessions.",
|
||||
category: "coding",
|
||||
pluginId: "claude_code",
|
||||
},
|
||||
{
|
||||
key: "vscode",
|
||||
name: "VS Code",
|
||||
tagline: "Inline context while you write.",
|
||||
category: "coding",
|
||||
},
|
||||
{
|
||||
key: "cline",
|
||||
name: "Cline",
|
||||
tagline: "Agentic dev tasks with your memory.",
|
||||
category: "coding",
|
||||
},
|
||||
{
|
||||
key: "codex",
|
||||
name: "Codex",
|
||||
tagline: "OpenAI Codex with persistent memory.",
|
||||
category: "coding",
|
||||
pluginId: "codex",
|
||||
},
|
||||
{
|
||||
key: "gemini-cli",
|
||||
name: "Gemini CLI",
|
||||
tagline: "Gemini in your terminal, brain-aware.",
|
||||
category: "coding",
|
||||
},
|
||||
{
|
||||
key: "claude",
|
||||
name: "Claude Desktop",
|
||||
tagline: "Memory across every Claude conversation.",
|
||||
category: "productivity",
|
||||
},
|
||||
{
|
||||
key: "chatgpt",
|
||||
name: "ChatGPT",
|
||||
tagline: "Custom GPT backed by your brain.",
|
||||
category: "productivity",
|
||||
},
|
||||
]
|
||||
|
||||
const CATEGORY_ORDER: { id: AgentCategory; label: string }[] = [
|
||||
{ id: "coding", label: "Coding" },
|
||||
{ id: "productivity", label: "Productivity" },
|
||||
]
|
||||
|
||||
function agentIcon(agent: Agent) {
|
||||
if (agent.pluginId) {
|
||||
const plugin = PLUGIN_CATALOG[agent.pluginId]
|
||||
if (plugin) return plugin.icon
|
||||
}
|
||||
const file = agent.key === "claude-code" ? "claude" : agent.key
|
||||
return `/mcp-supported-tools/${file}.png`
|
||||
}
|
||||
|
||||
export function StepIngest({ mcpUrl, onContinue }: Props) {
|
||||
const [activeCategory, setActiveCategory] = useState<AgentCategory>("coding")
|
||||
const [selectedKey, setSelectedKey] = useState<string>("cursor")
|
||||
const [, setMcpClient] = useQueryState("mcpClient", parseAsString)
|
||||
|
||||
const selectedAgent = AGENTS.find((a) => a.key === selectedKey) ?? AGENTS[0]
|
||||
export function StepIngest({ mode, mcpUrl, onContinue }: Props) {
|
||||
const tools = TOOL_OPTIONS[mode]
|
||||
const [selected, setSelected] = useState<FlowToolId>(tools[0].id)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAgent && !selectedAgent.pluginId) {
|
||||
setMcpClient(selectedAgent.key)
|
||||
} else {
|
||||
setMcpClient(null)
|
||||
}
|
||||
}, [selectedAgent, setMcpClient])
|
||||
setSelected(tools[0].id)
|
||||
}, [tools])
|
||||
|
||||
const selectAgent = (agent: Agent) => {
|
||||
setSelectedKey(agent.key)
|
||||
const activeTool = useMemo(
|
||||
() => tools.find((t) => t.id === selected) ?? tools[0],
|
||||
[tools, selected],
|
||||
)
|
||||
|
||||
const handleContinue = () => {
|
||||
analytics.onboardingIngestCompleted()
|
||||
onContinue()
|
||||
}
|
||||
|
||||
const filtered = AGENTS.filter((a) => a.category === activeCategory)
|
||||
const handleSkip = () => {
|
||||
analytics.onboardingIngestSkipped()
|
||||
onContinue()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="px-1">
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa] text-[22px]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Use your brain anywhere
|
||||
</p>
|
||||
<p className="text-[#737373] font-medium text-[15px] leading-[1.4] mt-1.5">
|
||||
Now plug it into the tools you already use to write code, chat, think.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-[900px] pb-10">
|
||||
<section className="relative py-4">
|
||||
<div className="mb-6 px-1">
|
||||
<p
|
||||
className={cn(
|
||||
"text-[22px] font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Use your brain where you work
|
||||
</p>
|
||||
<p className="mt-1.5 text-[15px] font-medium leading-[1.4] text-[#737373]">
|
||||
{mode === "team"
|
||||
? "Pick where your team asks questions, then set it up."
|
||||
: "Pick the tool you open every day — about 60 seconds."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<McpHero url={mcpUrl} />
|
||||
|
||||
<div className="grid lg:grid-cols-[300px_1fr] gap-4 h-[560px]">
|
||||
<aside
|
||||
className="rounded-[16px] bg-[#1B1F24] p-3 flex flex-col gap-3 overflow-hidden h-full"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<CategoryTabs value={activeCategory} onChange={setActiveCategory} />
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-1 scrollbar-thin pr-1">
|
||||
{filtered.map((agent) => (
|
||||
<AgentRow
|
||||
key={agent.key}
|
||||
agent={agent}
|
||||
active={selectedKey === agent.key}
|
||||
onClick={() => selectAgent(agent)}
|
||||
<div className="grid items-start gap-4 lg:grid-cols-[250px_minmax(0,1fr)]">
|
||||
{/* Left rail: pick a tool */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{tools.map((tool) => (
|
||||
<FlowToolRow
|
||||
key={tool.id}
|
||||
tool={tool}
|
||||
active={selected === tool.id}
|
||||
onSelect={() => {
|
||||
analytics.onboardingAgentSelected({ agent: tool.id })
|
||||
setSelected(tool.id)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="mt-1 inline-flex items-center gap-1.5 px-2 py-1.5 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
>
|
||||
More tools
|
||||
<span className="text-[#525D6E]">(full catalog)</span>
|
||||
<ExternalLink className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section
|
||||
className="rounded-[16px] bg-[#1B1F24] p-5 md:p-6 overflow-hidden flex flex-col h-full"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
{selectedAgent?.pluginId ? (
|
||||
<PluginSteps pluginId={selectedAgent.pluginId} />
|
||||
) : (
|
||||
<MCPSteps variant="embedded" />
|
||||
)}
|
||||
</section>
|
||||
{/* Right pane: setup detail */}
|
||||
<div
|
||||
className="relative flex min-h-[300px] flex-col overflow-hidden rounded-[22px] bg-[#1B1F24] p-6 md:p-7"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px right-10 left-10 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.4), transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#14161A]"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<ToolIcon id={activeTool.id} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[18px] font-semibold text-[#fafafa]">
|
||||
Set up {activeTool.label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12px] font-medium text-[#737373]">
|
||||
{activeTool.blurb}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTool.kind === "slack" ? (
|
||||
<SlackSetupPanel />
|
||||
) : activeTool.kind === "mcp" ? (
|
||||
<McpGenericSetup mcpUrl={mcpUrl} />
|
||||
) : activeTool.pluginId ? (
|
||||
<PluginSetup
|
||||
key={activeTool.pluginId}
|
||||
pluginId={activeTool.pluginId}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-[22px] border-t border-white/[0.06] pt-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className="text-[14px] font-medium text-[#737373] transition-colors hover:text-[#999]"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={handleContinue}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolIcon({ id, className }: { id: FlowToolId; className?: string }) {
|
||||
if (id === "slack") return <SlackMark className={className ?? "size-5"} />
|
||||
if (id === "mcp")
|
||||
return <Plug className={cn("text-[#4BA0FA]", className ?? "size-5")} />
|
||||
const file = id === "claude-code" ? "claude" : id
|
||||
return (
|
||||
<Image
|
||||
src={`/mcp-supported-tools/${file}.png`}
|
||||
alt=""
|
||||
width={28}
|
||||
height={28}
|
||||
unoptimized
|
||||
className={cn("object-contain", className ?? "size-5")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FlowToolRow({
|
||||
tool,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
tool: FlowTool
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"group relative flex w-full items-center gap-3 overflow-hidden rounded-[14px] p-3 text-left transition-all duration-150",
|
||||
active
|
||||
? "bg-[#10151D] ring-2 ring-[#4BA0FA]/45"
|
||||
: "bg-[#1B1F24] ring-1 ring-white/[0.05] hover:ring-white/[0.12]",
|
||||
)}
|
||||
style={modalCardStyle}
|
||||
>
|
||||
{active && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px right-5 left-5 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.55), transparent)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-[10px] border bg-[#14161A] transition-colors",
|
||||
active ? "border-[#2261CA]/45" : "border-[rgba(82,89,102,0.2)]",
|
||||
)}
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<ToolIcon id={tool.id} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-[22px] px-1 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onContinue}
|
||||
className="text-[#737373] font-medium text-[14px] hover:text-[#999] transition-colors"
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={onContinue}
|
||||
className="rounded-full px-5 py-[10px] text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[14px] font-semibold leading-tight text-[#fafafa]">
|
||||
{tool.label}
|
||||
</p>
|
||||
{tool.recommended && (
|
||||
<span className="shrink-0 rounded-full bg-[#4BA0FA]/12 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-[#4BA0FA]">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[12px] font-medium text-[#737373]">
|
||||
{tool.blurb}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"flex size-[18px] shrink-0 items-center justify-center rounded-full border transition-colors",
|
||||
active
|
||||
? "border-[#4BA0FA] bg-[#4BA0FA]"
|
||||
: "border-[rgba(82,89,102,0.4)] group-hover:border-[rgba(115,115,115,0.5)]",
|
||||
)}
|
||||
>
|
||||
{active && <Check className="size-3 text-white" />}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function StepRow({
|
||||
index,
|
||||
title,
|
||||
done,
|
||||
children,
|
||||
}: {
|
||||
index: number
|
||||
title: ReactNode
|
||||
done?: boolean
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"mt-0.5 flex size-[22px] shrink-0 items-center justify-center rounded-full text-[12px] font-semibold transition-colors",
|
||||
done
|
||||
? "bg-[#4BA0FA] text-white"
|
||||
: "border border-[rgba(82,89,102,0.3)] bg-[#14161A] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="size-3" /> : index}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<div className="text-[13px] font-medium leading-[1.5] text-[#fafafa]">
|
||||
{title}
|
||||
</div>
|
||||
{children ? <div className="mt-2.5">{children}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function McpHero({ url }: { url: string }) {
|
||||
// Coding-agent plugins (Codex, Claude Code) auto-login via OAuth, so the
|
||||
// "Save your API key" step is dropped — we render the remaining install steps.
|
||||
function PluginSetup({ pluginId }: { pluginId: string }) {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
const steps = (plugin?.installSteps ?? []).filter(
|
||||
(s) => !s.secret && !s.code?.includes("sm_..."),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{steps.map((step, i) => (
|
||||
<StepRow key={step.title} index={i + 1} title={step.title}>
|
||||
{step.description ? (
|
||||
<p className="mb-2 text-[12px] font-medium leading-[1.5] text-[#737373]">
|
||||
{step.description}
|
||||
</p>
|
||||
) : null}
|
||||
{step.code ? <CopyCodeBlock code={step.code} /> : null}
|
||||
</StepRow>
|
||||
))}
|
||||
<StepRow index={steps.length + 1} title="Ask your brain to test it">
|
||||
<CopyCodeBlock code={TEST_PROMPT} />
|
||||
</StepRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function McpGenericSetup({ mcpUrl }: { mcpUrl: string }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<StepRow index={1} title="Copy your universal MCP URL">
|
||||
<McpUrlRow url={mcpUrl} />
|
||||
</StepRow>
|
||||
<StepRow index={2} title="Paste it into any MCP client">
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
>
|
||||
Per-client setup guides
|
||||
<ExternalLink className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
</StepRow>
|
||||
<StepRow index={3} title="Ask your brain to test it">
|
||||
<CopyCodeBlock code={TEST_PROMPT} />
|
||||
</StepRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SlackSetupPanel() {
|
||||
const [status, setStatus] = useState<{
|
||||
connected: boolean
|
||||
teamName: string | null
|
||||
} | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
;(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND}/brain/slack/status`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (active && res.ok) {
|
||||
setStatus(
|
||||
(await res.json()) as {
|
||||
connected: boolean
|
||||
teamName: string | null
|
||||
},
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const connected = status?.connected ?? false
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<StepRow
|
||||
index={1}
|
||||
done={connected}
|
||||
title={
|
||||
connected
|
||||
? `Connected to ${status?.teamName ?? "your workspace"}`
|
||||
: "Add Supermemory to your Slack workspace"
|
||||
}
|
||||
>
|
||||
{!connected &&
|
||||
(loading ? (
|
||||
<span className="inline-flex items-center gap-2 text-[12px] font-medium text-[#737373]">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking…
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="insideOut"
|
||||
asChild
|
||||
className="h-9 gap-2 rounded-full px-4 text-[13px] font-medium text-[#fafafa]"
|
||||
>
|
||||
<a href={`${BACKEND}/brain/slack/oauth/install`}>
|
||||
<SlackMark className="size-4" />
|
||||
Add to Slack
|
||||
</a>
|
||||
</Button>
|
||||
))}
|
||||
</StepRow>
|
||||
<StepRow
|
||||
index={2}
|
||||
title={
|
||||
<>
|
||||
Mention <span className="text-[#4BA0FA]">@supermemory</span> in{" "}
|
||||
<span className="font-mono text-[12px]">#general</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<StepRow index={3} title="Ask your brain to test it">
|
||||
<CopyCodeBlock code="@supermemory what do we know about [topic]?" />
|
||||
</StepRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyCodeBlock({ code }: { code: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
setCopied(true)
|
||||
toast.success("Copied")
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
toast.error("Could not copy")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-start gap-2 rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#0F1217] p-3"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<pre className="min-w-0 flex-1 overflow-x-auto whitespace-pre font-mono text-[11px] text-[#fafafa]">
|
||||
{code}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-[#737373] transition-colors hover:text-[#fafafa]"
|
||||
aria-label="Copy"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function McpUrlRow({ url }: { url: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
|
|
@ -208,36 +542,22 @@ function McpHero({ url }: { url: string }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-[16px] bg-[#1B1F24] px-5 py-3 flex items-center gap-3 relative overflow-hidden"
|
||||
style={modalCardStyle}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute -top-px left-0 right-0 h-px"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(75,160,250,0.3), transparent)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="size-9 rounded-[10px] bg-[#14161A] border border-[rgba(82,89,102,0.2)] flex items-center justify-center shrink-0"
|
||||
className="min-w-0 flex-1 rounded-[12px] border border-[rgba(82,89,102,0.2)] bg-[#0F1217] px-4 py-3"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<MCPIcon className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[10px] uppercase tracking-[0.08em] text-[#737373] font-semibold">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[#525D6E]">
|
||||
Universal MCP URL
|
||||
</p>
|
||||
<p className="text-[13px] text-[#fafafa] font-mono truncate mt-0.5">
|
||||
<p className="mt-0.5 truncate font-mono text-[13px] text-[#fafafa]">
|
||||
{url}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={copy}
|
||||
className="rounded-full h-9 px-4 text-[12px] font-medium text-[#fafafa] shrink-0"
|
||||
className="h-9 shrink-0 rounded-full px-4 text-[12px] font-medium text-[#fafafa]"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5" />
|
||||
|
|
@ -246,235 +566,46 @@ function McpHero({ url }: { url: string }) {
|
|||
)}
|
||||
{copied ? "Copied" : "Copy URL"}
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginSteps({ pluginId }: { pluginId: string }) {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
if (!plugin) return null
|
||||
const steps = plugin.installSteps ?? []
|
||||
return (
|
||||
<div className="h-full overflow-y-auto pr-1 scrollbar-thin">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="size-10 rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)] flex items-center justify-center shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src={plugin.icon}
|
||||
alt={plugin.name}
|
||||
width={28}
|
||||
height={28}
|
||||
unoptimized
|
||||
className="size-6 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[16px] font-semibold text-[#fafafa] leading-tight">
|
||||
Set up {plugin.name}
|
||||
</p>
|
||||
<p className="text-[12px] text-[#A1A1AA] font-medium mt-0.5">
|
||||
{plugin.tagline}
|
||||
</p>
|
||||
</div>
|
||||
{plugin.docsUrl && (
|
||||
<a
|
||||
href={plugin.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[12px] text-[#A1A1AA] hover:text-[#fafafa] transition-colors font-medium shrink-0"
|
||||
>
|
||||
Docs ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{steps.map((step, i) => (
|
||||
<PluginStep key={step.title} idx={i + 1} step={step} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-[10px] border border-[#4BA0FA]/20 bg-[#4BA0FA]/[0.04] p-3 flex items-start gap-2">
|
||||
<div className="text-[11px] text-[#A1A1AA] leading-[1.5] font-medium">
|
||||
Your <span className="text-[#fafafa]">API key</span> is minted in
|
||||
Settings → Integrations → Plugins. Mint it once and paste into the
|
||||
step above.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginStep({
|
||||
idx,
|
||||
step,
|
||||
}: {
|
||||
idx: number
|
||||
step: import("@/lib/plugin-catalog").InstallStep
|
||||
}) {
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copy = async () => {
|
||||
if (!step.code) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(step.code)
|
||||
setCopied(true)
|
||||
toast.success("Copied")
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
toast.error("Could not copy")
|
||||
}
|
||||
}
|
||||
function SlackMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center shrink-0 pt-1">
|
||||
<div className="size-5 rounded-full bg-[#4BA0FA]/15 text-[#4BA0FA] flex items-center justify-center text-[10px] font-semibold">
|
||||
{idx}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] font-semibold text-[#fafafa]">
|
||||
{step.title}
|
||||
{step.optional && (
|
||||
<span className="ml-2 text-[10px] uppercase tracking-[0.08em] text-[#525D6E]">
|
||||
Optional
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{step.description && (
|
||||
<p className="text-[12px] text-[#A1A1AA] mt-1 leading-[1.5] font-medium">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
{step.code && (
|
||||
<div
|
||||
className="mt-2 rounded-[10px] bg-[#0F1217] border border-[rgba(82,89,102,0.2)] p-3 flex items-start gap-2"
|
||||
style={inputBevelStyle}
|
||||
>
|
||||
<pre
|
||||
className={cn(
|
||||
"flex-1 min-w-0 text-[11px] text-[#fafafa] font-mono overflow-x-auto whitespace-pre",
|
||||
step.secret && !revealed && "blur-[3px] select-none",
|
||||
)}
|
||||
>
|
||||
{step.code}
|
||||
</pre>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{step.secret && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRevealed((v) => !v)}
|
||||
className="size-7 rounded-md text-[#737373] hover:text-[#fafafa] flex items-center justify-center transition-colors"
|
||||
aria-label={revealed ? "Hide" : "Reveal"}
|
||||
>
|
||||
{revealed ? (
|
||||
<EyeOff className="size-3.5" />
|
||||
) : (
|
||||
<Eye className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="size-7 rounded-md text-[#737373] hover:text-[#fafafa] flex items-center justify-center transition-colors"
|
||||
aria-label="Copy"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryTabs({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: AgentCategory
|
||||
onChange: (c: AgentCategory) => void
|
||||
}) {
|
||||
const counts: Record<AgentCategory, number> = {
|
||||
coding: 0,
|
||||
productivity: 0,
|
||||
}
|
||||
for (const a of AGENTS) counts[a.category] += 1
|
||||
return (
|
||||
<div className="scrollbar-none flex items-center gap-0.5 overflow-x-auto rounded-full bg-[#0D121A] p-0.5 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)] w-full">
|
||||
{CATEGORY_ORDER.map((cat) => {
|
||||
const isActive = value === cat.id
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => onChange(cat.id)}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"flex flex-1 h-7 shrink-0 items-center justify-center gap-1.5 rounded-full px-3 text-[12px] font-medium leading-none transition-colors",
|
||||
isActive
|
||||
? "bg-white/[0.10] text-[#FAFAFA]"
|
||||
: "text-[#A1A1AA] hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<span className="leading-none">{cat.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-semibold tabular-nums leading-none",
|
||||
isActive ? "text-[#A1A1AA]" : "text-[#525D6E]",
|
||||
)}
|
||||
>
|
||||
{counts[cat.id]}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentRow({
|
||||
agent,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
agent: Agent
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"w-full text-left flex items-center gap-2.5 rounded-[10px] px-2.5 py-2 transition-colors",
|
||||
active
|
||||
? "bg-white/[0.08] text-[#fafafa]"
|
||||
: "text-[#A1A1AA] hover:bg-white/[0.04] hover:text-[#fafafa]",
|
||||
)}
|
||||
>
|
||||
<div className="size-8 rounded-[8px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)] flex items-center justify-center shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src={agentIcon(agent)}
|
||||
alt={agent.name}
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-medium truncate">{agent.name}</p>
|
||||
<p className="text-[11px] text-[#737373] truncate font-medium">
|
||||
{agent.tagline}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<svg viewBox="0 0 122.8 122.8" className={className} aria-hidden="true">
|
||||
<title>Slack</title>
|
||||
<path
|
||||
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M32.3 77.6c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
|
||||
fill="#E01E5A"
|
||||
/>
|
||||
<path
|
||||
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M45.2 32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
|
||||
fill="#36C5F0"
|
||||
/>
|
||||
<path
|
||||
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M90.5 45.2c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
|
||||
fill="#2EB67D"
|
||||
/>
|
||||
<path
|
||||
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
<path
|
||||
d="M77.6 90.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
|
||||
fill="#ECB22E"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ import {
|
|||
} from "@lib/constants"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { toast } from "sonner"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import type { BrainMode } from "./types"
|
||||
|
||||
type SourceId =
|
||||
|
|
@ -213,6 +214,9 @@ const PLAN_CARDS: PlanCardDefinition[] = [
|
|||
|
||||
const PLAN_CARD_SCROLL_STEP = 406
|
||||
|
||||
const countsAsConnected = (state: SourceState | undefined) =>
|
||||
state === "connected" || state === "waitlist"
|
||||
|
||||
export interface SourcesValues {
|
||||
connected: Partial<Record<SourceId, SourceState>>
|
||||
driveScope: DriveScope
|
||||
|
|
@ -309,6 +313,7 @@ export function StepSources({
|
|||
provider: "google-drive" | "notion" | "onedrive",
|
||||
id: SourceId,
|
||||
) => {
|
||||
analytics.onboardingIntegrationClicked({ integration: provider })
|
||||
setState(id, "connecting")
|
||||
try {
|
||||
const metadata: Record<string, string> = {}
|
||||
|
|
@ -338,10 +343,16 @@ export function StepSources({
|
|||
}
|
||||
|
||||
const openExternal = (id: SourceId, url: string) => {
|
||||
analytics.onboardingIntegrationClicked({ integration: id })
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
setState(id, "connected")
|
||||
}
|
||||
|
||||
const requestWaitlist = (id: SourceId) => {
|
||||
analytics.onboardingIntegrationClicked({ integration: id })
|
||||
setState(id, "waitlist")
|
||||
}
|
||||
|
||||
const guard = (
|
||||
plan: RequiredPlan | undefined,
|
||||
title: string,
|
||||
|
|
@ -359,9 +370,14 @@ export function StepSources({
|
|||
}
|
||||
|
||||
const connectedCount = Object.values(values.connected).filter(
|
||||
(s) => s === "connected" || s === "waitlist",
|
||||
countsAsConnected,
|
||||
).length
|
||||
|
||||
const handleContinue = () => {
|
||||
analytics.onboardingSourcesCompleted({ connected_count: connectedCount })
|
||||
onContinue()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1400px] pb-10">
|
||||
<section className="relative min-h-[calc(100dvh-136px)] py-4">
|
||||
|
|
@ -454,7 +470,7 @@ export function StepSources({
|
|||
</button>
|
||||
<SourceActions
|
||||
connectedCount={connectedCount}
|
||||
onContinue={onContinue}
|
||||
onContinue={handleContinue}
|
||||
className="mt-0 px-0"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -467,8 +483,8 @@ export function StepSources({
|
|||
onChange={onChange}
|
||||
isLocked={isLocked}
|
||||
guard={guard}
|
||||
setState={setState}
|
||||
openExternal={openExternal}
|
||||
requestWaitlist={requestWaitlist}
|
||||
connectRealProvider={connectRealProvider}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -917,8 +933,8 @@ function MoreSourcesGrid({
|
|||
onChange,
|
||||
isLocked,
|
||||
guard,
|
||||
setState,
|
||||
openExternal,
|
||||
requestWaitlist,
|
||||
connectRealProvider,
|
||||
}: {
|
||||
mode: BrainMode
|
||||
|
|
@ -930,8 +946,8 @@ function MoreSourcesGrid({
|
|||
title: string,
|
||||
fn: () => void,
|
||||
) => () => void
|
||||
setState: (id: SourceId, state: SourceState) => void
|
||||
openExternal: (id: SourceId, url: string) => void
|
||||
requestWaitlist: (id: SourceId) => void
|
||||
connectRealProvider: (
|
||||
provider: "google-drive" | "notion" | "onedrive",
|
||||
id: SourceId,
|
||||
|
|
@ -1027,7 +1043,7 @@ function MoreSourcesGrid({
|
|||
"Decisions and follow-ups surfaced",
|
||||
"You control which labels sync",
|
||||
]}
|
||||
onConnect={guard("max", "Gmail", () => setState("gmail", "waitlist"))}
|
||||
onConnect={guard("max", "Gmail", () => requestWaitlist("gmail"))}
|
||||
/>
|
||||
<SourceCard
|
||||
title="GitHub"
|
||||
|
|
@ -1042,7 +1058,7 @@ function MoreSourcesGrid({
|
|||
"READMEs and docs indexed",
|
||||
"Stays in sync with new activity",
|
||||
]}
|
||||
onConnect={guard("max", "GitHub", () => setState("github", "waitlist"))}
|
||||
onConnect={guard("max", "GitHub", () => requestWaitlist("github"))}
|
||||
/>
|
||||
<SourceCard
|
||||
title="Granola"
|
||||
|
|
@ -1057,9 +1073,10 @@ function MoreSourcesGrid({
|
|||
"Decisions and action items extracted",
|
||||
"Synced after every meeting",
|
||||
]}
|
||||
onConnect={guard("max", "Granola", () =>
|
||||
toast.info("Granola is coming soon."),
|
||||
)}
|
||||
onConnect={guard("max", "Granola", () => {
|
||||
analytics.onboardingIntegrationClicked({ integration: "granola" })
|
||||
toast.info("Granola is coming soon.")
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
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 { useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
||||
import Account from "@/components/settings/account"
|
||||
import Billing from "@/components/settings/billing"
|
||||
import Integrations from "@/components/settings/integrations"
|
||||
import ConnectionsMCP from "@/components/settings/connections-mcp"
|
||||
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
|
||||
import Support from "@/components/settings/support"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
|
@ -30,9 +31,15 @@ import {
|
|||
ChevronRight,
|
||||
ArrowUpRight,
|
||||
Building2,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogClose,
|
||||
DialogTitle,
|
||||
} from "@ui/components/dialog"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
||||
import { useResetOrganization } from "@/hooks/use-reset-organization"
|
||||
import { useDeleteUserAccount } from "@/hooks/use-account-settings"
|
||||
|
|
@ -44,6 +51,7 @@ export const TABS = [
|
|||
"billing",
|
||||
"integrations",
|
||||
"connections",
|
||||
"company-brain",
|
||||
"support",
|
||||
] as const
|
||||
export type SettingsTab = (typeof TABS)[number]
|
||||
|
|
@ -80,6 +88,12 @@ const NAV_ITEMS: NavItem[] = [
|
|||
description: "Drive, Notion, OneDrive, MCP",
|
||||
icon: <Zap className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "company-brain",
|
||||
label: "Company Brain",
|
||||
description: "GitHub & Linear — org and personal",
|
||||
icon: <Building2 className="size-[18px]" />,
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
label: "Support & Help",
|
||||
|
|
@ -88,6 +102,11 @@ const NAV_ITEMS: NavItem[] = [
|
|||
},
|
||||
]
|
||||
|
||||
const MODAL_SURFACE_SHADOW =
|
||||
"0 2.842px 14.211px 0 rgba(0,0,0,0.25), 0.711px 0.711px 0.711px 0 rgba(255,255,255,0.10) inset"
|
||||
|
||||
const INSET_SHADOW = "inset 1.313px 1.313px 3.938px rgba(0,0,0,0.7)"
|
||||
|
||||
export function parseHashToTab(hash: string): SettingsTab {
|
||||
const cleaned = hash.replace("#", "").toLowerCase()
|
||||
return TABS.includes(cleaned as SettingsTab)
|
||||
|
|
@ -124,11 +143,13 @@ function IdentityCard({ displayName }: { displayName: string }) {
|
|||
export function SettingsContent({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
dialogPortalContainer,
|
||||
className,
|
||||
showIdentity = true,
|
||||
}: {
|
||||
activeTab: SettingsTab
|
||||
onTabChange: (tab: SettingsTab) => void
|
||||
dialogPortalContainer?: HTMLElement | null
|
||||
className?: string
|
||||
showIdentity?: boolean
|
||||
}) {
|
||||
|
|
@ -147,7 +168,13 @@ export function SettingsContent({
|
|||
|
||||
const [isDeleteOrgDialogOpen, setIsDeleteOrgDialogOpen] = useState(false)
|
||||
const [deleteOrgConfirm, setDeleteOrgConfirm] = useState("")
|
||||
const deleteOrgInputRef = useRef<HTMLInputElement>(null)
|
||||
const deleteOrgDialogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
)
|
||||
const deleteOrganization = useDeleteOrganization()
|
||||
const activeOrgId = org?.id
|
||||
const previousOrgIdRef = useRef(activeOrgId)
|
||||
|
||||
// Only owners can delete the organization.
|
||||
const activeMemberRoleQuery = useQuery({
|
||||
|
|
@ -166,6 +193,46 @@ export function SettingsContent({
|
|||
|
||||
const [dangerMenuOpen, setDangerMenuOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (previousOrgIdRef.current === activeOrgId) return
|
||||
previousOrgIdRef.current = activeOrgId
|
||||
setDangerMenuOpen(false)
|
||||
setIsDeleteOrgDialogOpen(false)
|
||||
setDeleteOrgConfirm("")
|
||||
}, [activeOrgId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDeleteOrgDialogOpen) return
|
||||
|
||||
document.body.style.pointerEvents = ""
|
||||
const focusTimer = setTimeout(() => {
|
||||
deleteOrgInputRef.current?.focus()
|
||||
}, 0)
|
||||
|
||||
return () => clearTimeout(focusTimer)
|
||||
}, [isDeleteOrgDialogOpen])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (deleteOrgDialogTimerRef.current) {
|
||||
clearTimeout(deleteOrgDialogTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openDeleteOrganizationDialog = () => {
|
||||
setDangerMenuOpen(false)
|
||||
setDeleteOrgConfirm("")
|
||||
if (deleteOrgDialogTimerRef.current) {
|
||||
clearTimeout(deleteOrgDialogTimerRef.current)
|
||||
}
|
||||
deleteOrgDialogTimerRef.current = setTimeout(() => {
|
||||
document.body.style.pointerEvents = ""
|
||||
setIsDeleteOrgDialogOpen(true)
|
||||
deleteOrgDialogTimerRef.current = null
|
||||
}, 120)
|
||||
}
|
||||
|
||||
const displayName =
|
||||
user?.displayUsername ||
|
||||
localStorageUsername ||
|
||||
|
|
@ -179,7 +246,7 @@ export function SettingsContent({
|
|||
}
|
||||
|
||||
const handleIntegrations = () => {
|
||||
void router.push("/?view=integrations")
|
||||
void router.push("/integrations")
|
||||
}
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
|
|
@ -361,10 +428,7 @@ export function SettingsContent({
|
|||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDangerMenuOpen(false)
|
||||
setIsDeleteOrgDialogOpen(true)
|
||||
}}
|
||||
onClick={openDeleteOrganizationDialog}
|
||||
className="w-full flex items-center gap-3 rounded-[10px] px-3 py-2 text-left text-[#C73B1B] hover:bg-[#290F0A]/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<Building2 className="size-[16px] shrink-0" />
|
||||
|
|
@ -416,6 +480,7 @@ export function SettingsContent({
|
|||
{activeTab === "billing" && <Billing />}
|
||||
{activeTab === "integrations" && <Integrations />}
|
||||
{activeTab === "connections" && <ConnectionsMCP />}
|
||||
{activeTab === "company-brain" && <CompanyBrainConnections />}
|
||||
{activeTab === "support" && <Support />}
|
||||
</ErrorBoundary>
|
||||
</section>
|
||||
|
|
@ -596,77 +661,114 @@ export function SettingsContent({
|
|||
|
||||
{/* Delete organization dialog */}
|
||||
<Dialog
|
||||
key={activeOrgId ?? "delete-organization"}
|
||||
open={isDeleteOrgDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeleteOrgDialogOpen(open)
|
||||
if (!open) setDeleteOrgConfirm("")
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
|
||||
<DialogContent
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"sm:max-w-[560px] border border-white/[0.12] bg-[#1B1F24] p-0 px-4 pt-4 pb-5 gap-0 rounded-[22px] overflow-hidden text-[#FAFAFA]",
|
||||
)}
|
||||
portalContainer={dialogPortalContainer}
|
||||
showCloseButton={false}
|
||||
style={{ boxShadow: MODAL_SURFACE_SHADOW }}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
deleteOrgInputRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle className="text-[18px] font-semibold leading-tight text-[#FAFAFA]">
|
||||
Delete this organization?
|
||||
</h2>
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Permanently deletes{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
{org?.name || "this organization"}
|
||||
</strong>{" "}
|
||||
— its documents, spaces, connections, and members.{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
This cannot be undone.
|
||||
</strong>
|
||||
</DialogTitle>
|
||||
<p className="mt-0.5 truncate text-[13px] text-[#A1A1AA]">
|
||||
This action permanently removes the selected workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Type <strong className="text-[#FAFAFA]">{org?.name}</strong> to
|
||||
confirm:
|
||||
</p>
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80 focus:outline-none"
|
||||
style={{ boxShadow: INSET_SHADOW }}
|
||||
>
|
||||
<X className="size-5 text-[#737373]" />
|
||||
</button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-4 rounded-[14px] bg-[#14161A] p-4 sm:p-5 shadow-[inset_1.313px_1.313px_3.938px_rgba(0,0,0,0.7)]">
|
||||
<p className="text-[13.5px] leading-relaxed text-[#A1A1AA]">
|
||||
Permanently deletes{" "}
|
||||
<strong className="font-semibold text-[#FAFAFA]">
|
||||
{org?.name || "this organization"}
|
||||
</strong>{" "}
|
||||
and all of its documents, spaces, connections, and members.{" "}
|
||||
<strong className="font-semibold text-[#FAFAFA]">
|
||||
This cannot be undone.
|
||||
</strong>
|
||||
</p>
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-[13px] text-[#A1A1AA]">
|
||||
Type{" "}
|
||||
<strong className="font-semibold text-[#FAFAFA]">
|
||||
{org?.name}
|
||||
</strong>{" "}
|
||||
to confirm
|
||||
</span>
|
||||
<input
|
||||
ref={deleteOrgInputRef}
|
||||
type="text"
|
||||
value={deleteOrgConfirm}
|
||||
onChange={(e) => setDeleteOrgConfirm(e.target.value)}
|
||||
placeholder={org?.name ?? "Organization name"}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C73B1B]/50 transition-colors"
|
||||
className="h-11 w-full rounded-[12px] border border-white/[0.08] bg-[#0D121A] px-4 text-[14px] text-white placeholder:text-[#525D6E] transition-colors focus:border-[#C73B1B]/55 focus:outline-none focus:ring-2 focus:ring-[#C73B1B]/15"
|
||||
style={{ boxShadow: INSET_SHADOW }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!org?.name ||
|
||||
deleteOrgConfirm !== org.name ||
|
||||
deleteOrganization.isPending
|
||||
}
|
||||
title={
|
||||
!org?.name || deleteOrgConfirm !== org.name
|
||||
? `Type "${org?.name ?? "the organization name"}" exactly to confirm`
|
||||
: undefined
|
||||
}
|
||||
onClick={handleDeleteOrganization}
|
||||
className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
{deleteOrganization.isPending ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<Building2 className="size-[15px]" />
|
||||
className={cn(
|
||||
"px-3 py-2 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#FAFAFA]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
{deleteOrganization.isPending
|
||||
? "Deleting…"
|
||||
: "Delete organization"}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!org?.name ||
|
||||
deleteOrgConfirm !== org.name ||
|
||||
deleteOrganization.isPending
|
||||
}
|
||||
title={
|
||||
!org?.name || deleteOrgConfirm !== org.name
|
||||
? `Type "${org?.name ?? "the organization name"}" exactly to confirm`
|
||||
: undefined
|
||||
}
|
||||
onClick={handleDeleteOrganization}
|
||||
className="flex h-10 shrink-0 items-center justify-center gap-2 rounded-full bg-[#0D121A] px-5 text-[14px] font-semibold text-[#FAFAFA] transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
style={{ boxShadow: INSET_SHADOW }}
|
||||
>
|
||||
{deleteOrganization.isPending ? (
|
||||
<LoaderIcon className="size-4 animate-spin text-[#C73B1B]" />
|
||||
) : null}
|
||||
{deleteOrganization.isPending
|
||||
? "Deleting..."
|
||||
: "Delete organization"}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -1,16 +1,53 @@
|
|||
import posthog from "posthog-js"
|
||||
import type { BrainStep } from "@/components/onboarding-brain/types"
|
||||
|
||||
export type OnboardingStep = "profile_input" | "processing" | "done" | "error"
|
||||
export type OnboardingSource = "x" | "linkedin" | "resume"
|
||||
const pendingEvents: Array<{
|
||||
eventName: string
|
||||
properties?: Record<string, unknown>
|
||||
}> = []
|
||||
let flushTimer: ReturnType<typeof setInterval> | undefined
|
||||
let flushTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const flushPendingEvents = () => {
|
||||
if (!posthog.__loaded) return
|
||||
while (pendingEvents.length > 0) {
|
||||
const event = pendingEvents.shift()
|
||||
if (!event) return
|
||||
posthog.capture(event.eventName, event.properties)
|
||||
}
|
||||
if (flushTimer) {
|
||||
clearInterval(flushTimer)
|
||||
flushTimer = undefined
|
||||
}
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout)
|
||||
flushTimeout = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleFlush = () => {
|
||||
if (flushTimer) return
|
||||
flushTimer = setInterval(flushPendingEvents, 200)
|
||||
flushTimeout = setTimeout(() => {
|
||||
if (!flushTimer) return
|
||||
clearInterval(flushTimer)
|
||||
flushTimer = undefined
|
||||
flushTimeout = undefined
|
||||
pendingEvents.length = 0
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
// Helper function to safely capture events
|
||||
const safeCapture = (
|
||||
eventName: string,
|
||||
properties?: Record<string, unknown>,
|
||||
) => {
|
||||
if (posthog.__loaded) {
|
||||
flushPendingEvents()
|
||||
posthog.capture(eventName, properties)
|
||||
return
|
||||
}
|
||||
pendingEvents.push({ eventName, properties })
|
||||
scheduleFlush()
|
||||
}
|
||||
|
||||
export const analytics = {
|
||||
|
|
@ -57,13 +94,6 @@ export const analytics = {
|
|||
close_reason: "dismiss" | "close_button" | "im_good" | "action"
|
||||
}) => safeCapture("integration_info_modal_closed", props),
|
||||
|
||||
nextAppResearchCtaDismissed: () =>
|
||||
safeCapture("next_app_research_cta_dismissed"),
|
||||
nextAppResearchCtaBookCallClicked: () =>
|
||||
safeCapture("next_app_research_cta_book_call_clicked"),
|
||||
nextAppResearchCtaLobbysideCallClicked: () =>
|
||||
safeCapture("next_app_research_cta_lobbyside_call_clicked"),
|
||||
|
||||
mcpViewOpened: () => safeCapture("mcp_view_opened"),
|
||||
mcpInstallCmdCopied: () => safeCapture("mcp_install_cmd_copied"),
|
||||
|
||||
|
|
@ -82,32 +112,62 @@ export const analytics = {
|
|||
addDocumentModalOpened: () => safeCapture("add_document_modal_opened"),
|
||||
|
||||
// onboarding analytics
|
||||
onboardingStarted: (props: { mode: string; entry_step: BrainStep }) =>
|
||||
safeCapture("onboarding_started", props),
|
||||
|
||||
onboardingStepViewed: (props: {
|
||||
step: OnboardingStep
|
||||
step: BrainStep
|
||||
index: number
|
||||
trigger: "user" | "auto"
|
||||
}) => safeCapture("onboarding_step_viewed", props),
|
||||
|
||||
onboardingProfileSubmitted: (props: { source: OnboardingSource }) =>
|
||||
safeCapture("onboarding_profile_submitted", props),
|
||||
onboardingStepCompleted: (props: { step: BrainStep; index: number }) =>
|
||||
safeCapture("onboarding_step_completed", props),
|
||||
|
||||
onboardingModeSelected: (props: { mode: string }) =>
|
||||
safeCapture("onboarding_mode_selected", props),
|
||||
|
||||
onboardingWorkspaceCreated: (props: {
|
||||
mode: string
|
||||
has_about: boolean
|
||||
has_domain: boolean
|
||||
}) => safeCapture("onboarding_workspace_created", props),
|
||||
|
||||
onboardingWorkspaceCreateFailed: (props: { error: string }) =>
|
||||
safeCapture("onboarding_workspace_create_failed", props),
|
||||
|
||||
onboardingIntegrationClicked: (props: { integration: string }) =>
|
||||
safeCapture("onboarding_integration_clicked", props),
|
||||
|
||||
onboardingSourcesCompleted: (props: { connected_count: number }) =>
|
||||
safeCapture("onboarding_sources_completed", props),
|
||||
|
||||
onboardingAgentSelected: (props: { agent: string }) =>
|
||||
safeCapture("onboarding_agent_selected", props),
|
||||
|
||||
onboardingIngestCompleted: () => safeCapture("onboarding_ingest_completed"),
|
||||
|
||||
onboardingIngestSkipped: () => safeCapture("onboarding_ingest_skipped"),
|
||||
|
||||
onboardingInvitesSent: (props: { sent: number; failed: number }) =>
|
||||
safeCapture("onboarding_invites_sent", props),
|
||||
|
||||
onboardingTeamSkipped: () => safeCapture("onboarding_team_skipped"),
|
||||
|
||||
onboardingChromeExtensionClicked: (props: {
|
||||
source: "onboarding" | "settings" | "integrations"
|
||||
}) => safeCapture("onboarding_chrome_extension_clicked", props),
|
||||
|
||||
onboardingMcpDetailOpened: () => safeCapture("onboarding_mcp_detail_opened"),
|
||||
|
||||
onboardingXBookmarksDetailOpened: () =>
|
||||
safeCapture("onboarding_x_bookmarks_detail_opened"),
|
||||
|
||||
onboardingSkipped: (props: { from_step: OnboardingStep }) =>
|
||||
onboardingSkipped: (props: { from_step: BrainStep }) =>
|
||||
safeCapture("onboarding_skipped", props),
|
||||
|
||||
onboardingCompleted: (props?: {
|
||||
source?: OnboardingSource
|
||||
memories_count?: number
|
||||
onboardingCompleted: (props: {
|
||||
mode: string
|
||||
steps_completed: number
|
||||
sources_connected: number
|
||||
invites_sent: number
|
||||
}) => safeCapture("onboarding_completed", props),
|
||||
|
||||
// main app analytics
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -151,7 +151,6 @@
|
|||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@floating-ui/react": "^0.27.0",
|
||||
"@lobbyside/react": "0.2.0",
|
||||
"@opennextjs/cloudflare": "^1.12.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
|
|
@ -992,10 +991,6 @@
|
|||
|
||||
"@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="],
|
||||
|
||||
"@instantdb/core": ["@instantdb/core@1.0.15", "", { "dependencies": { "@instantdb/version": "1.0.15", "mutative": "^1.0.10", "uuid": "^11.1.0" } }, "sha512-1A4n47U0YLHKhvl0G+CiPGfBynq1cj+NqIVRhUMc/yHYT6rePeRWesxvevNiEJU2sLxOKML6Htcbtdh7jUjSQA=="],
|
||||
|
||||
"@instantdb/version": ["@instantdb/version@1.0.15", "", {}, "sha512-xHDT23QK0tKAdxC2Z98mBx+znwnVShYWDsp0juY4xxzTGjrb37qNqRYb+6IIJc1J3GZ+xW/fCIexVK3b0B6fog=="],
|
||||
|
||||
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"@isaacs/ttlcache": ["@isaacs/ttlcache@2.1.4", "", {}, "sha512-7kMz0BJpMvgAMkyglums7B2vtrn5g0a0am77JY0GjkZZNetOBCFn7AG7gKCwT0QPiXyxW7YIQSgtARknUEOcxQ=="],
|
||||
|
|
@ -1026,8 +1021,6 @@
|
|||
|
||||
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
|
||||
|
||||
"@lobbyside/react": ["@lobbyside/react@0.2.0", "", { "peerDependencies": { "@instantdb/core": ">=1.0.0", "react": ">=18.0.0" } }, "sha512-24dTNDImAqZrlCu+vlPKulP1fvL3yTIc6qwxqBsqvit4z9WXnAdMRwB5Bvn31dTuaBokQEVZa3t0VfMnyhGvuw=="],
|
||||
|
||||
"@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="],
|
||||
|
||||
"@lukeed/uuid": ["@lukeed/uuid@2.0.1", "", { "dependencies": { "@lukeed/csprng": "^1.1.0" } }, "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w=="],
|
||||
|
|
@ -3868,8 +3861,6 @@
|
|||
|
||||
"multimatch": ["multimatch@6.0.0", "", { "dependencies": { "@types/minimatch": "^3.0.5", "array-differ": "^4.0.0", "array-union": "^3.0.1", "minimatch": "^3.0.4" } }, "sha512-I7tSVxHGPlmPN/enE3mS1aOSo6bWBfls+3HmuEeCUBCE7gWnm3cBXCBkpurzFjVRwC6Kld8lLaZ1Iv5vOcjvcQ=="],
|
||||
|
||||
"mutative": ["mutative@1.3.0", "", {}, "sha512-8MJj6URmOZAV70dpFe1YnSppRTKC4DsMkXQiBDFayLcDI4ljGokHxmpqaBQuDWa4iAxWaJJ1PS8vAmbntjjKmQ=="],
|
||||
|
||||
"mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="],
|
||||
|
||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
|
@ -5228,8 +5219,6 @@
|
|||
|
||||
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"@instantdb/core/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
||||
|
||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
|
|
|||
|
|
@ -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