mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Merge branch 'main' of https://github.com/supermemoryai/supermemory
This commit is contained in:
commit
8ad7055fe2
114 changed files with 8959 additions and 9805 deletions
|
|
@ -39,13 +39,13 @@ const result = await generateText({
|
|||
```
|
||||
|
||||
<Note>
|
||||
**Memory saving is disabled by default.** The middleware only retrieves existing memories. To automatically save new memories from conversations, enable it explicitly:
|
||||
|
||||
**Memory saving is enabled by default** (`addMemory: "always"`). New conversations are persisted automatically. To opt out, set `addMemory: "never"`:
|
||||
|
||||
```typescript
|
||||
const modelWithMemory = withSupermemory(openai("gpt-5"), {
|
||||
containerTag: "user-123",
|
||||
customId: "conversation-456",
|
||||
addMemory: "always",
|
||||
addMemory: "never",
|
||||
})
|
||||
```
|
||||
</Note>
|
||||
|
|
|
|||
|
|
@ -46,13 +46,13 @@ The `withSupermemory` middleware:
|
|||
All of this happens transparently - you write code as if using a normal model, but get personalized responses.
|
||||
|
||||
<Note>
|
||||
**Memory saving is disabled by default.** The middleware only retrieves existing memories. To automatically save new memories from conversations, set `addMemory: "always"`:
|
||||
|
||||
**Memory saving is enabled by default** (`addMemory: "always"`). New conversations are persisted automatically. To opt out, set `addMemory: "never"`:
|
||||
|
||||
```typescript
|
||||
const model = withSupermemory(openai("gpt-5"), {
|
||||
containerTag: "user-123",
|
||||
customId: "conversation-456",
|
||||
addMemory: "always",
|
||||
addMemory: "never",
|
||||
})
|
||||
```
|
||||
</Note>
|
||||
|
|
|
|||
|
|
@ -144,6 +144,28 @@
|
|||
"pages": ["supermemory-mcp/claude-desktop"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"anchor": "SMFS",
|
||||
"icon": "database",
|
||||
"pages": [
|
||||
"smfs/overview",
|
||||
"smfs/install",
|
||||
"smfs/mount",
|
||||
"smfs/bash-tool",
|
||||
"smfs/bash-tool-python",
|
||||
{
|
||||
"group": "Providers",
|
||||
"icon": "cloud",
|
||||
"pages": [
|
||||
"smfs/providers/daytona",
|
||||
"smfs/providers/e2b",
|
||||
"smfs/providers/vercel",
|
||||
"smfs/providers/cloudflare"
|
||||
]
|
||||
},
|
||||
"smfs/examples"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tab": "Developer Platform"
|
||||
|
|
@ -170,7 +192,12 @@
|
|||
"integrations/pipecat",
|
||||
"integrations/n8n",
|
||||
"integrations/viasocket",
|
||||
"integrations/zapier"
|
||||
"integrations/zapier",
|
||||
{
|
||||
"group": "Migration Guides",
|
||||
"icon": "arrow-up-right",
|
||||
"pages": ["migration/tools-v2-upgrade"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ icon: "triangle"
|
|||
|
||||
The Supermemory AI SDK provides native integration with Vercel's AI SDK through two approaches: **User Profiles** for automatic personalization and **Memory Tools** for agent-based interactions.
|
||||
|
||||
<Note>
|
||||
Migrating to v2 from 1.4.x? Check the [migration guide](/migration/tools-v2-upgrade).
|
||||
</Note>
|
||||
|
||||
<Card title="@supermemory/tools on npm" icon="npm" href="https://www.npmjs.com/package/@supermemory/tools">
|
||||
Check out the NPM page for more details
|
||||
</Card>
|
||||
|
|
@ -46,14 +50,21 @@ const result = await generateText({
|
|||
})
|
||||
```
|
||||
|
||||
### Required fields
|
||||
|
||||
Both `containerTag` and `customId` are required.
|
||||
|
||||
- **`containerTag`** — *who* the memories belong to. Use a stable identifier per user, workspace, or tenant (e.g. `"user-123"`, `"acme-workspace"`). Memory search and writes are scoped to this tag.
|
||||
- **`customId`** — *which conversation* this turn belongs to. Use it to group messages from the same chat session into a single document (e.g. `"chat-2026-04-25"`, a thread ID, or a UUID per session).
|
||||
|
||||
<Note>
|
||||
**Memory saving is disabled by default.** The middleware only retrieves existing memories. To automatically save new memories:
|
||||
**Memory saving is enabled by default** (`addMemory: "always"`). New conversations are persisted automatically. To opt out, set `addMemory: "never"`:
|
||||
|
||||
```typescript
|
||||
const modelWithMemory = withSupermemory(openai("gpt-5"), {
|
||||
containerTag: "user-123",
|
||||
customId: "conversation-456",
|
||||
addMemory: "always",
|
||||
addMemory: "never",
|
||||
})
|
||||
```
|
||||
</Note>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ icon: "/images/mastra-icon.svg"
|
|||
|
||||
Integrate Supermemory with [Mastra](https://mastra.ai) to give your AI agents persistent memory. Use the `withSupermemory` wrapper for zero-config setup or processors for fine-grained control.
|
||||
|
||||
<Note>
|
||||
Migrating to v2 from 1.4.x? Check the [migration guide](/migration/tools-v2-upgrade).
|
||||
</Note>
|
||||
|
||||
<Card title="@supermemory/tools on npm" icon="npm" href="https://www.npmjs.com/package/@supermemory/tools">
|
||||
Check out the NPM page for more details
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ Add memory capabilities to the official OpenAI SDKs using Supermemory. Two appro
|
|||
1. **`withSupermemory` wrapper** - Automatic memory injection into system prompts (zero-config)
|
||||
2. **Function calling tools** - Explicit tool calls for search/add memory operations
|
||||
|
||||
<Note>
|
||||
Migrating to v2 from 1.4.x? Check the [migration guide](/migration/tools-v2-upgrade).
|
||||
</Note>
|
||||
|
||||
<Tip>
|
||||
**New to Supermemory?** Start with `withSupermemory` for the simplest integration. It automatically injects relevant memories into your prompts.
|
||||
</Tip>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ icon: "bolt"
|
|||
|
||||
Supermemory integrates with [VoltAgent](https://github.com/VoltAgent/voltagent), providing long-term memory capabilities for AI agents. Your VoltAgent applications will remember past conversations and provide personalized responses based on user history.
|
||||
|
||||
<Note>
|
||||
Migrating to v2 from 1.4.x? Check the [migration guide](/migration/tools-v2-upgrade).
|
||||
</Note>
|
||||
|
||||
<Card title="@supermemory/tools on npm" icon="npm" href="https://www.npmjs.com/package/@supermemory/tools">
|
||||
Check out the NPM page for more details
|
||||
</Card>
|
||||
|
|
|
|||
188
apps/docs/migration/tools-v2-upgrade.mdx
Normal file
188
apps/docs/migration/tools-v2-upgrade.mdx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
---
|
||||
title: 'Upgrading @supermemory/tools to v2.0.0'
|
||||
description: 'Migrate your code from @supermemory/tools 1.4.x to 2.0.0 — config-object signature, customId, and new defaults'
|
||||
sidebarTitle: 'Tools: v1.4 → 2.0'
|
||||
---
|
||||
|
||||
`@supermemory/tools` v2.0.0 unifies the API across all four integrations (Vercel AI SDK, OpenAI, Mastra, VoltAgent) around a single config-object signature and a consistent conversation-grouping concept. This guide walks you through the breaking changes.
|
||||
|
||||
<Note>
|
||||
This release is **breaking**. Update calls and re-test before bumping in
|
||||
production.
|
||||
</Note>
|
||||
|
||||
## What changed at a glance
|
||||
|
||||
| Area | v1.4.x | v2.0.0 |
|
||||
| --------------------- | ----------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| Signature | `withSupermemory(model, "user-123", { ... })` | `withSupermemory(model, { containerTag: "user-123", ... })` |
|
||||
| Conversation grouping | `conversationId` (Vercel/OpenAI), `threadId` (Mastra) | **`customId`** everywhere |
|
||||
| `customId` | Optional | **Required** — throws if missing or empty |
|
||||
| `containerTag` | Positional argument | **Required** field on options object |
|
||||
| `addMemory` default | `"never"` | `"always"` |
|
||||
| VoltAgent `verbose` | Hardcoded to `false` | Honored from options |
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @supermemory/tools@^2.0.0
|
||||
```
|
||||
|
||||
## 1. Vercel AI SDK
|
||||
|
||||
```typescript
|
||||
// v1.4.x
|
||||
import { withSupermemory } from '@supermemory/tools/ai-sdk';
|
||||
|
||||
const model = withSupermemory(openai('gpt-4'), 'user-123', {
|
||||
conversationId: 'conv-456',
|
||||
mode: 'full',
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// v2.0.0
|
||||
import { withSupermemory } from '@supermemory/tools/ai-sdk';
|
||||
|
||||
const model = withSupermemory(openai('gpt-4'), {
|
||||
containerTag: 'user-123',
|
||||
customId: 'conv-456',
|
||||
mode: 'full',
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`customId` is now **required**. Passing an empty string or omitting it throws
|
||||
at construction time.
|
||||
</Warning>
|
||||
|
||||
## 2. OpenAI SDK
|
||||
|
||||
```typescript
|
||||
// v1.4.x
|
||||
import { withSupermemory } from '@supermemory/tools/openai';
|
||||
|
||||
const client = withSupermemory(openai, 'user-123', {
|
||||
conversationId: 'conv-456',
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// v2.0.0
|
||||
import { withSupermemory } from '@supermemory/tools/openai';
|
||||
|
||||
const client = withSupermemory(openai, {
|
||||
containerTag: 'user-123',
|
||||
customId: 'conv-456',
|
||||
});
|
||||
```
|
||||
|
||||
Both `containerTag` and `customId` are validated and throw with explicit error messages if missing.
|
||||
|
||||
## 3. Mastra
|
||||
|
||||
Processor constructors and factory functions both moved to a single options argument. `threadId` is gone — use `customId` instead.
|
||||
|
||||
```typescript
|
||||
// v1.4.x
|
||||
import {
|
||||
SupermemoryInputProcessor,
|
||||
createSupermemoryOutputProcessor,
|
||||
} from '@supermemory/tools/mastra';
|
||||
|
||||
const input = new SupermemoryInputProcessor('user-123', {
|
||||
mode: 'full',
|
||||
});
|
||||
|
||||
const output = createSupermemoryOutputProcessor('user-123', {
|
||||
threadId: 'conv-456',
|
||||
addMemory: 'always',
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// v2.0.0
|
||||
import {
|
||||
SupermemoryInputProcessor,
|
||||
createSupermemoryOutputProcessor,
|
||||
} from '@supermemory/tools/mastra';
|
||||
|
||||
const input = new SupermemoryInputProcessor({
|
||||
containerTag: 'user-123',
|
||||
customId: 'conv-456',
|
||||
mode: 'full',
|
||||
});
|
||||
|
||||
const output = createSupermemoryOutputProcessor({
|
||||
containerTag: 'user-123',
|
||||
customId: 'conv-456',
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
In server setups, Mastra's `RequestContext` thread ID still takes precedence
|
||||
over the construction-time `customId` — the option now acts as the fallback
|
||||
when no per-request thread ID is provided.
|
||||
</Note>
|
||||
|
||||
## 4. VoltAgent
|
||||
|
||||
VoltAgent already used a config-object signature, so the call shape is unchanged. Two behavior fixes ship in v2.0.0:
|
||||
|
||||
- `verbose: true` is now honored (was hardcoded to `false` in v1.4.x).
|
||||
- A runtime warning is logged when advanced search params (`threshold`, `limit`, `rerank`, `rewriteQuery`, `filters`, `include`, `searchMode`) are set while `mode: "profile"` — those parameters are ignored in profile mode.
|
||||
|
||||
If you were relying on `verbose: false` implicitly while passing `verbose: true`, you will now see logs. Adjust as needed.
|
||||
|
||||
## 5. New default: `addMemory: "always"`
|
||||
|
||||
Across all four integrations, `addMemory` now defaults to `"always"`. If your v1.4.x code relied on the old default of `"never"`, set it explicitly:
|
||||
|
||||
```typescript
|
||||
const model = withSupermemory(openai('gpt-4'), {
|
||||
containerTag: 'user-123',
|
||||
customId: 'conv-456',
|
||||
addMemory: 'never', // preserve v1.4.x behavior
|
||||
});
|
||||
```
|
||||
|
||||
## Conversation persistence
|
||||
|
||||
In v1.4.x the Vercel middleware fell back to `client.add` with a synthesized `customId` when no `conversationId` was passed. In v2.0.0, because `customId` is required, all conversation persistence goes through the `/v4/conversations` endpoint via `addConversation`. There is no fallback path.
|
||||
|
||||
## Migration checklist
|
||||
|
||||
<Steps>
|
||||
<Step title="Bump the dependency">
|
||||
`npm install @supermemory/tools@^2.0.0`
|
||||
</Step>
|
||||
<Step title="Find every withSupermemory / processor call">
|
||||
Grep your codebase for `withSupermemory(`, `SupermemoryInputProcessor`,
|
||||
`SupermemoryOutputProcessor`, `createSupermemoryProcessor`,
|
||||
`createSupermemoryOutputProcessor`.
|
||||
</Step>
|
||||
<Step title="Move containerTag into the options object">
|
||||
Drop the positional `containerTag` argument and add it to the options
|
||||
object.
|
||||
</Step>
|
||||
<Step title="Rename conversationId / threadId to customId">
|
||||
Make sure every call site provides a non-empty `customId`.
|
||||
</Step>
|
||||
<Step title="Audit addMemory">
|
||||
If you depended on the old `"never"` default, pass `addMemory: "never"`
|
||||
explicitly.
|
||||
</Step>
|
||||
<Step title="Run your test suite">
|
||||
Validation throws happen at construction time, so missing fields surface
|
||||
immediately.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Need help?
|
||||
|
||||
- [Vercel AI SDK integration](/integrations/ai-sdk)
|
||||
- [OpenAI integration](/integrations/openai)
|
||||
- [Mastra integration](/integrations/mastra)
|
||||
- [VoltAgent integration](/integrations/voltagent)
|
||||
|
||||
If you hit something this guide does not cover, open an issue on [GitHub](https://github.com/supermemoryai/supermemory).
|
||||
252
apps/docs/smfs/bash-tool-python.mdx
Normal file
252
apps/docs/smfs/bash-tool-python.mdx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
---
|
||||
title: "Bash Tool (Python)"
|
||||
sidebarTitle: "Bash Tool (Python)"
|
||||
description: "supermemory-bash. The SMFS idea wrapped as a single agent tool, for Python agents and serverless runtimes."
|
||||
icon: "terminal"
|
||||
---
|
||||
|
||||
`supermemory-bash` is the SMFS idea wrapped as a single agent tool: `run_bash(command)`. The "filesystem" is your Supermemory container. Runs anywhere Python runs. AWS Lambda, Modal, Fly Machines, Cloud Run, your laptop. No mount, no FUSE, no local disk.
|
||||
|
||||
Reach for the Bash Tool when your agent runs somewhere it can't mount a real filesystem.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install supermemory-bash
|
||||
```
|
||||
|
||||
Or with uv:
|
||||
|
||||
```bash
|
||||
uv add supermemory-bash
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import os
|
||||
from supermemory_bash import create_bash
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
result = await create_bash(
|
||||
api_key=os.environ["SUPERMEMORY_API_KEY"],
|
||||
container_tag="user_42",
|
||||
)
|
||||
bash = result.bash
|
||||
r = await bash.exec("ls /")
|
||||
print(r.stdout)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
`create_bash` returns a `CreateBashResult` with:
|
||||
|
||||
- `bash`: a `Shell` instance with `.exec(cmd)`
|
||||
- `tool_description`: a pre-written tool description ready to hand to the model
|
||||
- `configure_memory_paths(paths)`: scope which paths get extracted into Supermemory
|
||||
- `refresh()`: re-prime the path index after external writes
|
||||
|
||||
## Use it as a model tool
|
||||
|
||||
### Anthropic SDK
|
||||
|
||||
Pass `tool_description` straight into Claude's tool definition and run a normal agent loop. Each `tool_use` block calls `bash.exec` and the result goes back as a `tool_result`.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import anthropic
|
||||
from supermemory_bash import create_bash
|
||||
|
||||
|
||||
async def run_agent(user_message: str) -> str:
|
||||
result = await create_bash(
|
||||
api_key=os.environ["SUPERMEMORY_API_KEY"],
|
||||
container_tag="user_42",
|
||||
)
|
||||
bash = result.bash
|
||||
|
||||
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
|
||||
tools = [
|
||||
{
|
||||
"name": "bash",
|
||||
"description": result.tool_description,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cmd": {"type": "string", "description": "The bash command to run."}
|
||||
},
|
||||
"required": ["cmd"],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
messages = [{"role": "user", "content": user_message}]
|
||||
|
||||
for _ in range(10):
|
||||
response = client.messages.create(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=4096,
|
||||
tools=tools,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
if response.stop_reason == "end_turn":
|
||||
for block in response.content:
|
||||
if hasattr(block, "text"):
|
||||
return block.text
|
||||
return ""
|
||||
|
||||
messages.append({"role": "assistant", "content": response.content})
|
||||
tool_results = []
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
cmd = block.input.get("cmd", "")
|
||||
r = await bash.exec(cmd)
|
||||
output = r.stdout
|
||||
if r.stderr:
|
||||
output += f"\n[stderr]: {r.stderr}"
|
||||
if r.exit_code != 0:
|
||||
output += f"\n[exit_code]: {r.exit_code}"
|
||||
tool_results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": block.id,
|
||||
"content": output or "(no output)",
|
||||
}
|
||||
)
|
||||
messages.append({"role": "user", "content": tool_results})
|
||||
|
||||
return "(max steps reached)"
|
||||
|
||||
|
||||
asyncio.run(run_agent("What's in my notes about the Q3 launch?"))
|
||||
```
|
||||
|
||||
### OpenAI SDK
|
||||
|
||||
Same idea with OpenAI's function-calling format. Define a single `bash` function, dispatch each `tool_calls` entry to `bash.exec`, and feed the output back as a `tool` message.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
from supermemory_bash import create_bash
|
||||
|
||||
|
||||
async def run_agent(user_message: str) -> str:
|
||||
result = await create_bash(
|
||||
api_key=os.environ["SUPERMEMORY_API_KEY"],
|
||||
container_tag="user_42",
|
||||
)
|
||||
bash = result.bash
|
||||
|
||||
client = OpenAI()
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "bash",
|
||||
"description": result.tool_description,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"cmd": {"type": "string"}},
|
||||
"required": ["cmd"],
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
messages = [{"role": "user", "content": user_message}]
|
||||
|
||||
for _ in range(10):
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
)
|
||||
message = response.choices[0].message
|
||||
|
||||
if not message.tool_calls:
|
||||
return message.content or ""
|
||||
|
||||
messages.append(message.model_dump(exclude_none=True))
|
||||
for call in message.tool_calls:
|
||||
args = json.loads(call.function.arguments or "{}")
|
||||
r = await bash.exec(args.get("cmd", ""))
|
||||
output = r.stdout
|
||||
if r.stderr:
|
||||
output += f"\n[stderr]: {r.stderr}"
|
||||
if r.exit_code != 0:
|
||||
output += f"\n[exit_code]: {r.exit_code}"
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": call.id,
|
||||
"content": output or "(no output)",
|
||||
}
|
||||
)
|
||||
|
||||
return "(max steps reached)"
|
||||
|
||||
|
||||
asyncio.run(run_agent("List my notes"))
|
||||
```
|
||||
|
||||
### Claude Agent SDK
|
||||
|
||||
The [Claude Agent SDK](https://docs.claude.com/en/api/agent-sdk/overview) ships with built-in `Bash`, `Read`, and `Write` tools. If your agent runs somewhere SMFS can be mounted (a long-lived process on macOS or Linux), point those built-ins at an SMFS mount and you don't need `supermemory-bash` at all — the agent just sees your container as a directory.
|
||||
|
||||
See [Mount SMFS](/smfs/mount) for setup, or the [provider guides](/smfs/overview#use-smfs-with-your-sandbox-provider) for sandbox-specific instructions.
|
||||
|
||||
## Memory
|
||||
|
||||
The Bash Tool inherits SMFS memory semantics. By default, files named `user.md` or `memory.md` are extracted as memories. Configure additional memory paths after construction:
|
||||
|
||||
```python
|
||||
result = await create_bash(api_key=api_key, container_tag=container_tag)
|
||||
bash = result.bash
|
||||
await result.configure_memory_paths(["/notes/", "/journal.md"])
|
||||
```
|
||||
|
||||
Trailing `/` matches recursively. No slash matches an exact file. Pass `[]` to disable memory generation.
|
||||
|
||||
The container also exposes a virtual `profile.md` at the root: a live digest of everything in the container. Read it once at the start of a session to give the model context without walking every file.
|
||||
|
||||
```python
|
||||
r = await bash.exec("cat /profile.md")
|
||||
print(r.stdout)
|
||||
```
|
||||
|
||||
## Commands the agent can run
|
||||
|
||||
The Python tool exposes the same command surface as the TypeScript version: standard Unix builtins (`pwd`, `cd`, `ls`, `cat`, `stat`, `mkdir`, `rm`, `mv`, `cp`, `echo`), search and text utilities (`grep`, `find`, `head`, `tail`, `wc`, `sort`, `sed`, `awk`), plus the custom `sgrep <query> [path]` for semantic search across the container. Pipes, redirects, conditionals, loops, and file tests all work.
|
||||
|
||||
See the [TypeScript Bash Tool reference](/smfs/bash-tool#commands-the-agent-can-run) for the full list.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Option | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `api_key` | required | Supermemory API key |
|
||||
| `container_tag` | required | Container to expose as the filesystem |
|
||||
| `base_url` | `None` | Override the API endpoint |
|
||||
| `eager_load` | `True` | Warm the path index when the instance starts |
|
||||
| `eager_content` | `True` | Also warm the content cache during eager load |
|
||||
| `cwd` | `"/home/user"` | Initial working directory |
|
||||
| `env` | `None` | Extra environment variables |
|
||||
| `cache_ttl_ms` | `150_000` | Content cache TTL in ms. `None` = never expires (single-writer). `0` = no cache. |
|
||||
|
||||
The container is what defines the filesystem; setting `cwd` or extra `env` from the host doesn't change the files the agent sees.
|
||||
|
||||
## Limitations
|
||||
|
||||
- `chmod`, `utimes`, and symlinks (`ln -s`, `readlink`) raise `ENOSYS`.
|
||||
- `/dev/null` as a redirect target isn't supported. Write to `/tmp/discard.log` instead.
|
||||
- Binary uploads aren't supported. Text is extracted server-side.
|
||||
203
apps/docs/smfs/bash-tool.mdx
Normal file
203
apps/docs/smfs/bash-tool.mdx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
---
|
||||
title: "Bash Tool"
|
||||
sidebarTitle: "Bash Tool"
|
||||
description: "@supermemory/bash. The SMFS idea wrapped as a single agent tool, for serverless and edge runtimes."
|
||||
icon: "terminal"
|
||||
---
|
||||
|
||||
`@supermemory/bash` is the SMFS idea wrapped as a single agent tool: `run_bash(command)`. The "filesystem" is your Supermemory container. Runs anywhere TypeScript runs. Cloudflare Workers, AWS Lambda, Vercel, Node, the browser. No mount, no FUSE, no local disk.
|
||||
|
||||
Reach for the Bash Tool when your agent runs somewhere it can't mount a real filesystem.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @supermemory/bash
|
||||
```
|
||||
|
||||
Or with bun:
|
||||
|
||||
```bash
|
||||
bun add @supermemory/bash
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
```typescript
|
||||
import { createBash } from "@supermemory/bash";
|
||||
|
||||
const { bash, toolDescription } = await createBash({
|
||||
apiKey: process.env.SUPERMEMORY_API_KEY!,
|
||||
containerTag: "user_42",
|
||||
});
|
||||
|
||||
const result = await bash.exec("ls /");
|
||||
console.log(result.stdout);
|
||||
```
|
||||
|
||||
`createBash` returns:
|
||||
|
||||
- `bash`: the instance with `.exec(cmd)`
|
||||
- `toolDescription`: a pre-written tool description ready to hand to the model
|
||||
- `configureMemoryPaths(paths)`: scope which paths get extracted into Supermemory
|
||||
- `refresh()`: re-prime the path index after external writes
|
||||
|
||||
## Use it as a model tool
|
||||
|
||||
### Vercel AI SDK
|
||||
|
||||
```typescript
|
||||
import { generateText, tool } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { z } from "zod";
|
||||
|
||||
const { bash, toolDescription } = await createBash({
|
||||
apiKey: process.env.SUPERMEMORY_API_KEY!,
|
||||
containerTag: "user_42",
|
||||
});
|
||||
|
||||
const result = await generateText({
|
||||
model: openai("gpt-4o"),
|
||||
tools: {
|
||||
bash: tool({
|
||||
description: toolDescription,
|
||||
inputSchema: z.object({ cmd: z.string() }),
|
||||
execute: async ({ cmd }) => bash.exec(cmd),
|
||||
}),
|
||||
},
|
||||
prompt: "What's in my notes about the Q3 launch?",
|
||||
});
|
||||
```
|
||||
|
||||
### Anthropic SDK
|
||||
|
||||
```typescript
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
|
||||
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
|
||||
const { bash, toolDescription } = await createBash({
|
||||
apiKey: process.env.SUPERMEMORY_API_KEY!,
|
||||
containerTag: "user_42",
|
||||
});
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: "claude-opus-4-7",
|
||||
max_tokens: 4096,
|
||||
tools: [
|
||||
{
|
||||
name: "bash",
|
||||
description: toolDescription,
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: { cmd: { type: "string" } },
|
||||
required: ["cmd"],
|
||||
},
|
||||
},
|
||||
],
|
||||
messages: [{ role: "user", content: "List my notes" }],
|
||||
});
|
||||
```
|
||||
|
||||
### OpenAI SDK
|
||||
|
||||
```typescript
|
||||
import OpenAI from "openai";
|
||||
|
||||
const client = new OpenAI();
|
||||
const { bash, toolDescription } = await createBash({
|
||||
apiKey: process.env.SUPERMEMORY_API_KEY!,
|
||||
containerTag: "user_42",
|
||||
});
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: "gpt-4o",
|
||||
messages: [{ role: "user", content: "List my notes" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "bash",
|
||||
description: toolDescription,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { cmd: { type: "string" } },
|
||||
required: ["cmd"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Memory
|
||||
|
||||
The Bash Tool inherits SMFS memory semantics. By default, files named `user.md` or `memory.md` are extracted as memories. Configure additional memory paths after construction:
|
||||
|
||||
```typescript
|
||||
const { configureMemoryPaths } = await createBash({ apiKey, containerTag });
|
||||
|
||||
await configureMemoryPaths(["/notes/", "/journal.md"]);
|
||||
```
|
||||
|
||||
Trailing `/` matches recursively. No slash matches an exact file. Pass `[]` to disable memory generation.
|
||||
|
||||
The container also exposes a virtual `profile.md` at the root: a live digest of everything in the container. Read it once at the start of a session to give the model context without walking every file.
|
||||
|
||||
```typescript
|
||||
const { stdout } = await bash.exec("cat /profile.md");
|
||||
```
|
||||
|
||||
## Commands the agent can run
|
||||
|
||||
Standard Unix surface, plus one custom command. Each does what you'd expect.
|
||||
|
||||
### Filesystem
|
||||
|
||||
- `pwd`: print working directory
|
||||
- `cd`: change directory
|
||||
- `ls`, `ls -la`: list
|
||||
- `cat`: read a file
|
||||
- `stat`: file metadata
|
||||
- `mkdir`: create directory
|
||||
- `rm`, `rm -rf`: delete
|
||||
- `rmdir`: delete empty directory
|
||||
- `mv`: move or rename
|
||||
- `cp`: copy
|
||||
- `echo`: write or append (`echo "x" > file`, `echo "x" >> file`)
|
||||
|
||||
### Search and text
|
||||
|
||||
- `grep`: literal substring match against a known path
|
||||
- `sgrep <query> [path]`: **semantic** search across the container. Trailing `/` on path scopes to a directory. No path searches everything.
|
||||
- `find`: search by name or properties
|
||||
- `head`, `tail`: first or last N lines
|
||||
- `wc`: word, line, byte counts
|
||||
- `sort`: sort lines
|
||||
- `sed`, `awk`: text transformation
|
||||
|
||||
### Shell features
|
||||
|
||||
- Pipes (`|`)
|
||||
- Redirects (`>`, `>>`)
|
||||
- Conditionals (`&&`, `||`)
|
||||
- Loops (`for`, `while`)
|
||||
- File tests (`[ -f ]`, `[ -d ]`, `[ -e ]`)
|
||||
|
||||
## Configuration
|
||||
|
||||
| Option | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `apiKey` | required | Supermemory API key |
|
||||
| `containerTag` | required | Container to expose as the filesystem |
|
||||
| `baseURL` | SDK default | Override the API endpoint |
|
||||
| `eagerLoad` | `true` | Warm the path index when the instance starts |
|
||||
| `eagerContent` | `true` | Also warm the content cache during eager load |
|
||||
| `cacheTtlMs` | `150_000` | Content cache TTL in ms. `null` = never expires (single-writer). `0` = no cache. |
|
||||
|
||||
Other options (`customCommands`, `logger`, plus `just-bash` pass-throughs like `executionLimits`, `network`, `python`, `javascript`, `cwd`, `env`) exist but aren't part of the supported surface for the SMFS use case. The container is what defines the filesystem; setting `cwd` or extra `env` from the host doesn't change that.
|
||||
|
||||
## Limitations
|
||||
|
||||
- `chmod`, `utimes`, and symlinks (`ln -s`, `readlink`) throw `ENOSYS`.
|
||||
- `/dev/null` as a redirect target isn't supported. Write to `/tmp/discard.log` instead.
|
||||
- Binary uploads aren't supported. Text is extracted server-side.
|
||||
48
apps/docs/smfs/examples.mdx
Normal file
48
apps/docs/smfs/examples.mdx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
title: "Examples"
|
||||
sidebarTitle: "Examples"
|
||||
description: "Full web-based demo apps you can clone and run."
|
||||
icon: "code"
|
||||
---
|
||||
|
||||
Web-based example apps showing SMFS in realistic use cases. Each one is a
|
||||
complete project with its own README, dependencies, and a working UI.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card
|
||||
title="Research Assistant"
|
||||
icon="magnifying-glass"
|
||||
href="https://github.com/supermemoryai/examples/tree/main/research-assistant"
|
||||
>
|
||||
Upload documents and chat with an AI that can search and cite them.
|
||||
Next.js + TypeScript + `@supermemory/bash`.
|
||||
</Card>
|
||||
<Card
|
||||
title="Knowledge Base"
|
||||
icon="book"
|
||||
href="https://github.com/supermemoryai/examples/tree/main/knowledge-base"
|
||||
>
|
||||
Add notes and chat with an AI that can search your knowledge base.
|
||||
FastAPI + Python + `supermemory-bash`.
|
||||
</Card>
|
||||
<Card
|
||||
title="Code Sandbox"
|
||||
icon="terminal"
|
||||
href="https://github.com/supermemoryai/examples/tree/main/code-sandbox"
|
||||
>
|
||||
Write and run code in an E2B sandbox with persistent AI memory.
|
||||
Next.js + E2B SDK + SMFS mount.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
The Research Assistant and Knowledge Base examples use the
|
||||
[Bash Tool](/smfs/bash-tool) — the serverless-friendly way to give an agent a
|
||||
Supermemory-backed filesystem. The Code Sandbox example uses an
|
||||
[E2B](/smfs/providers/e2b) sandbox with a real SMFS mount.
|
||||
|
||||
## Running an example
|
||||
|
||||
1. Clone the [examples repo](https://github.com/supermemoryai/examples)
|
||||
2. `cd` into the example you want
|
||||
3. Follow the README — typically: install deps, copy `.env.example` to `.env`,
|
||||
fill in your API keys, and start the dev server
|
||||
68
apps/docs/smfs/install.mdx
Normal file
68
apps/docs/smfs/install.mdx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
---
|
||||
title: "Install SMFS"
|
||||
sidebarTitle: "Install"
|
||||
description: "Install, log in, mount."
|
||||
icon: "download"
|
||||
---
|
||||
|
||||
## 1. Install the binary
|
||||
|
||||
```bash
|
||||
curl -fsSL https://smfs.ai/install | bash
|
||||
```
|
||||
|
||||
Drops `smfs` into `~/.local/bin`. Works on macOS (arm64, x64) and Linux (arm64, x64).
|
||||
|
||||
If `smfs` isn't on your `PATH` after install, add `~/.local/bin` to your shell profile and reopen the terminal.
|
||||
|
||||
## 2. Log in
|
||||
|
||||
```bash
|
||||
smfs login
|
||||
```
|
||||
|
||||
One-time. Prompts you for your Supermemory API key and stores it in your global credentials. Get a key at [console.supermemory.ai](https://console.supermemory.ai).
|
||||
|
||||
You can also pass the key directly:
|
||||
|
||||
```bash
|
||||
smfs login --key sm_...
|
||||
```
|
||||
|
||||
## 3. Mount a container
|
||||
|
||||
```bash
|
||||
smfs mount agent_memory
|
||||
```
|
||||
|
||||
`agent_memory` is your container tag. SMFS creates a folder named `agent_memory/` in the current directory and mounts the container there.
|
||||
|
||||
That's it. Read it with `ls`, `cat`, `grep`. See [Mount](/smfs/mount) for memory paths, sync modes, flags, and every subcommand.
|
||||
|
||||
To mount somewhere else, pass `--path`:
|
||||
|
||||
```bash
|
||||
smfs mount agent_memory --path ~/memory
|
||||
```
|
||||
|
||||
## Optional: refresh the semantic grep wrapper
|
||||
|
||||
`smfs mount` installs the shell wrapper automatically the first time you mount. If you ever need to force a clean reinstall (after upgrading the binary, for example):
|
||||
|
||||
```bash
|
||||
smfs init
|
||||
```
|
||||
|
||||
It writes the wrapper into your `~/.zshrc` directly. Then reopen your terminal (or `source ~/.zshrc`) so the new shell picks it up.
|
||||
|
||||
Inside any mount, plain `grep` becomes semantic. Outside a mount, your normal `grep` is untouched. Pass any flag (`grep -r`, `grep -i`, anything) and you get the real `grep` back.
|
||||
|
||||
## Refresh the binary
|
||||
|
||||
If anything ever feels broken:
|
||||
|
||||
```bash
|
||||
smfs install
|
||||
```
|
||||
|
||||
Re-copies the binary into `~/.local/bin` and resets permissions.
|
||||
289
apps/docs/smfs/mount.mdx
Normal file
289
apps/docs/smfs/mount.mdx
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
---
|
||||
title: "Mount"
|
||||
sidebarTitle: "Mount"
|
||||
description: "Mount a Supermemory container, generate memories, and sync."
|
||||
icon: "hard-drive"
|
||||
---
|
||||
|
||||
A mount turns a Supermemory container into a directory on your machine. macOS uses NFSv3, Linux uses FUSE. Both are handled for you.
|
||||
|
||||
```bash
|
||||
smfs mount <container-tag>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
smfs mount agent_memory
|
||||
```
|
||||
|
||||
`agent_memory` is the container tag. SMFS creates a folder named `agent_memory/` in the current directory and mounts the container there. The mount runs as a background daemon. A marker file `.smfs` is written at the mount root so other tools (and the semantic `grep` wrapper from `smfs init`) can find the mount.
|
||||
|
||||
To mount at a different path:
|
||||
|
||||
```bash
|
||||
smfs mount agent_memory --path ~/memory
|
||||
```
|
||||
|
||||
## Memory
|
||||
|
||||
This is the part most people miss. SMFS isn't a normal filesystem. It generates **memories** from files at specific paths. Memories are extracted, summarized, and indexed by Supermemory.
|
||||
|
||||
Files outside those paths are still semantically searchable; they're indexed through **SuperRAG** by default. Nothing in the mount is dead weight.
|
||||
|
||||
### Defaults
|
||||
|
||||
By default, files named `user.md` or `memory.md` are treated as memory paths. Drop those files anywhere in your mount and Supermemory generates memories from them automatically.
|
||||
|
||||
### Configure your own memory paths
|
||||
|
||||
Pass `--memory-paths` at mount time to control which files become memories:
|
||||
|
||||
```bash
|
||||
smfs mount agent_memory --memory-paths "/notes/,/journal.md"
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Paths are **absolute**, anchored at the mount root. Always start with `/`.
|
||||
- Trailing `/` matches every file inside that folder, recursively (`/notes/` covers `/notes/foo.md`, `/notes/2026/march.md`, etc.).
|
||||
- No trailing slash matches one exact file (`/journal.md`).
|
||||
- Comma-separated. Multiple paths are fine.
|
||||
- Empty string disables memory generation entirely (`--memory-paths ""`).
|
||||
- Omit the flag and Supermemory keeps whatever the container tag already has, falling back to `user.md` and `memory.md`.
|
||||
|
||||
### profile.md
|
||||
|
||||
Every mount has a virtual file at the root called `profile.md`. It's auto-generated, read-only, and backed by Supermemory. The model can `cat profile.md` to get a live digest of everything in the container without walking every file. Useful as a first call at the start of a session.
|
||||
|
||||
```bash
|
||||
cat agent_memory/profile.md
|
||||
```
|
||||
|
||||
You can't write to it. As the underlying memories change, Supermemory regenerates it.
|
||||
|
||||
## Sync modes
|
||||
|
||||
Three modes plus a force-sync command. Pick by what your agent actually needs.
|
||||
|
||||
### Bidirectional (default)
|
||||
|
||||
Local reads hit the cache. Local writes queue and push to Supermemory in the background. Remote changes are pulled on a poll. This is what you get if you pass no flags.
|
||||
|
||||
```bash
|
||||
smfs mount agent_memory
|
||||
```
|
||||
|
||||
Use this when more than one writer (you, another agent, the dashboard) might touch the container.
|
||||
|
||||
### No-sync
|
||||
|
||||
Writes still push to Supermemory. Polling for remote changes is off. The agent sees a view that doesn't shift under it mid-task.
|
||||
|
||||
```bash
|
||||
smfs mount agent_memory --no-sync
|
||||
```
|
||||
|
||||
Use this when your agent is the only writer, or when you want predictable reads.
|
||||
|
||||
### Ephemeral
|
||||
|
||||
Cache is in memory only. Nothing persists after unmount. Writes still push.
|
||||
|
||||
```bash
|
||||
smfs mount agent_memory --ephemeral
|
||||
```
|
||||
|
||||
Use this for short-lived sandboxes. CI jobs, throwaway containers, one-shot agent runs.
|
||||
|
||||
### Force a sync now
|
||||
|
||||
```bash
|
||||
smfs sync
|
||||
```
|
||||
|
||||
Pushes pending writes and pulls remote changes immediately. Useful right before tearing down a sandbox.
|
||||
|
||||
## All mount flags
|
||||
|
||||
| Flag | What it does |
|
||||
| --- | --- |
|
||||
| `--path <path>` | Override the default mount path (`./<container-tag>/`). |
|
||||
| `--memory-paths <paths>` | Scope which files become memories. See [Memory](#memory). |
|
||||
| `--no-sync` | Stop polling for remote changes. Writes still push. |
|
||||
| `--clean` | Wipe local cache before mounting. Pulls fresh from the API. |
|
||||
| `--ephemeral` | In-memory cache. Nothing persists after unmount. |
|
||||
| `--sync-interval <secs>` | Remote-change poll interval. Default `30`. |
|
||||
| `--drain-timeout <secs>` | Max time to flush pending writes during unmount. Default `30`. |
|
||||
| `--foreground` | Run the daemon inline instead of detaching. |
|
||||
| `--backend <name>` | Linux only. `fuse` (default) or `nfs`. |
|
||||
| `--key <key>` | Pass an API key explicitly. Saved to project credentials. |
|
||||
|
||||
## Multiple agents and multiple containers
|
||||
|
||||
- **Different devices, same container tag**: fully supported. Many agents can mount the same container concurrently from different machines.
|
||||
- **Same device, same container tag, mounted twice**: not supported. Use one mount per container per device.
|
||||
- **Same device, different containers**: mount as many as you want in parallel.
|
||||
|
||||
## Commands
|
||||
|
||||
Every `smfs` subcommand. Click any one to expand.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="smfs mount" icon="play">
|
||||
Mount a container. Defaults to `./<container-tag>/`; pass `--path` to mount elsewhere.
|
||||
|
||||
```bash
|
||||
smfs mount agent_memory
|
||||
smfs mount agent_memory --path ~/memory
|
||||
```
|
||||
|
||||
See the flags table above for everything you can pass.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="smfs unmount" icon="square">
|
||||
Unmount a running mount. Drains pending writes up to `--drain-timeout`, then exits the daemon. Anything not drained resumes on the next mount.
|
||||
|
||||
```bash
|
||||
smfs unmount agent_memory
|
||||
```
|
||||
|
||||
Inside the mount, you can omit the tag and let SMFS resolve it from the nearest `.smfs` marker.
|
||||
|
||||
```bash
|
||||
smfs unmount
|
||||
smfs unmount --force
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="smfs list" icon="list">
|
||||
List every SMFS mount running on this machine.
|
||||
|
||||
```bash
|
||||
smfs list
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="smfs status" icon="activity">
|
||||
Show daemon status for a mount: connectivity, queue depth, last sync. Auto-detects the tag via the nearest `.smfs` marker.
|
||||
|
||||
```bash
|
||||
smfs status
|
||||
smfs status agent_memory
|
||||
smfs status --json
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="smfs logs" icon="scroll-text">
|
||||
Tail the daemon log for a mount. Auto-detects the tag via the nearest `.smfs` marker.
|
||||
|
||||
```bash
|
||||
smfs logs
|
||||
smfs logs -f
|
||||
smfs logs -n 500
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="smfs sync" icon="refresh-cw">
|
||||
Force an immediate sync cycle. Push pending writes, pull remote changes.
|
||||
|
||||
```bash
|
||||
smfs sync agent_memory
|
||||
```
|
||||
|
||||
Inside the mount, the tag is optional (resolved from the nearest `.smfs` marker).
|
||||
|
||||
```bash
|
||||
smfs sync
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="smfs grep" icon="search">
|
||||
Semantic search across a container without being inside the mount. The optional second argument scopes the search to a subpath inside the container. Inside a mount, plain `grep` already does this; `smfs grep` is the explicit form for scripts.
|
||||
|
||||
```bash
|
||||
smfs grep "deadline"
|
||||
smfs grep "deadline" /notes/
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="smfs login" icon="log-in">
|
||||
One-time auth. Prompts for your Supermemory API key and stores it in your global credentials. You can also pass it directly with `--key`.
|
||||
|
||||
```bash
|
||||
smfs login
|
||||
smfs login --key sm_...
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="smfs whoami" icon="user">
|
||||
Print the currently-authenticated user, organization, and API endpoint.
|
||||
|
||||
```bash
|
||||
smfs whoami
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="smfs logout" icon="log-out">
|
||||
Remove stored credentials. Active mounts keep running until you `smfs unmount` them.
|
||||
|
||||
```bash
|
||||
smfs logout
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="smfs init" icon="terminal">
|
||||
Force-installs the shell wrapper that makes plain `grep` semantic inside mounts. Writes directly to `~/.zshrc`. `smfs mount` also installs it automatically the first time, so you only need this to refresh after an upgrade.
|
||||
|
||||
```bash
|
||||
smfs init
|
||||
```
|
||||
|
||||
Reopen your terminal (or `source ~/.zshrc`) after running it.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="smfs install" icon="download">
|
||||
Self-install. Copies the running binary to `~/.local/bin` and resets permissions. Run this if your `smfs` install ever feels broken.
|
||||
|
||||
```bash
|
||||
smfs install
|
||||
```
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## FAQ
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Semantic grep isn't working inside my mount" icon="circle-help">
|
||||
Run `smfs init` to force-install the shell wrapper. It writes directly to `~/.zshrc`. Then reopen your terminal so the new shell picks it up.
|
||||
|
||||
The wrapper only triggers when you're inside a mount (it looks for the `.smfs` marker file at the mount root). Outside a mount, `grep` stays normal. Inside a mount, any flag you pass falls through to the real `grep`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Can I install SMFS on Windows?" icon="circle-help">
|
||||
Not yet. SMFS supports macOS (arm64, x64) and Linux (arm64, x64) for now. Windows isn't on the v0 roadmap.
|
||||
|
||||
On Windows, use the [Bash Tool](/smfs/bash-tool) instead. It runs anywhere TypeScript runs and gives your agent the same `ls`, `cat`, `grep`, `sgrep` surface without needing a mount.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="My cache feels stale or out of sync" icon="circle-help">
|
||||
Re-mount with `--clean` to wipe the local cache and pull everything fresh from the API:
|
||||
|
||||
```bash
|
||||
smfs unmount agent_memory
|
||||
smfs mount agent_memory --clean
|
||||
```
|
||||
|
||||
Nothing on the server changes; only the local SQLite cache gets reset.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Can two agents on the same machine share a mount?" icon="circle-help">
|
||||
Yes. Once a container is mounted, anything on that machine can read and write through the mount path. The constraint is one mount per container tag per device. Mount it once, point both agents at the same folder.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Can two separate sandboxes use the same container?" icon="circle-help">
|
||||
Yes, absolutely. Mount the same container tag from each sandbox. Bidirectional sync keeps everything in step as either side writes, so Agent A in sandbox 1 sees Agent B's writes from sandbox 2 within a sync interval.
|
||||
|
||||
To avoid stepping on each other, give each agent its own subdirectory (`/agent_a/`, `/agent_b/`, etc.). They can still read across the whole mount, cross-reference each other's findings, and build on each other's work. The shared container is the point.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
71
apps/docs/smfs/overview.mdx
Normal file
71
apps/docs/smfs/overview.mdx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
---
|
||||
title: "SMFS"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Memory your agent can grep."
|
||||
icon: "database"
|
||||
---
|
||||
|
||||
**SMFS** mounts your Supermemory container as a real directory. Agents read it with `ls`, `cat`, and `grep`. No SDK to learn, no client to wire up, no embeddings to think about.
|
||||
|
||||
SMFS is open source and free for everyone.
|
||||
|
||||
## Why a filesystem
|
||||
|
||||
Every model already knows how a filesystem works. It can `ls`, `cat`, `grep`, `find`, redirect with `>`, pipe with `|`. You don't have to teach it a new API surface, and the grammar carries across runtimes.
|
||||
|
||||
The catch: a filesystem on its own isn't great for memory. Search means walking the tree. Long files burn through context. The model has to hold the directory structure in its head. None of that scales as memory grows.
|
||||
|
||||
SMFS fixes the catch. The shell is real, but underneath:
|
||||
|
||||
- **Semantic `grep` by default.** One call surfaces what matters across the whole container, ranked by meaning. Pass any flag and you fall through to the real `grep` for exact matches.
|
||||
- **Memory paths get distilled.** Files marked as memory paths are extracted and indexed by Supermemory. They don't bloat the model's context.
|
||||
- **Virtual `profile.md`.** A live digest of the container at the mount root. The model can `cat profile.md` for a one-shot summary instead of walking every file.
|
||||
- **Bidirectional sync** runs in the background. Local reads hit cache; writes push to Supermemory.
|
||||
|
||||
You get filesystem ergonomics without paying the filesystem tax in tokens.
|
||||
|
||||
## Two ways to use SMFS
|
||||
|
||||
Pick by where your agent runs.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Mount (smfs binary)" icon="hard-drive" href="/smfs/install">
|
||||
For agents and tools with a real filesystem. Claude Code, Cursor, devcontainers, Docker, Codespaces. NFSv3 on macOS, FUSE on Linux.
|
||||
</Card>
|
||||
<Card title="Bash Tool (TypeScript & Python)" icon="terminal" href="/smfs/bash-tool">
|
||||
For agents running serverless or at the edge. Cloudflare Workers, AWS Lambda, Vercel, Modal. A virtual bash where the filesystem is your container. Available as [`@supermemory/bash`](/smfs/bash-tool) for TypeScript and [`supermemory-bash`](/smfs/bash-tool-python) for Python.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Use SMFS with your sandbox provider
|
||||
|
||||
Already using a sandbox or agent platform? Jump straight to the guide for your provider.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Daytona" icon="server" href="/smfs/providers/daytona">
|
||||
Isolated Linux sandboxes with millisecond boot times. Mount SMFS inside or use the bash tool from your orchestrating code.
|
||||
</Card>
|
||||
<Card title="E2B" icon="cube" href="/smfs/providers/e2b">
|
||||
Firecracker microVMs for AI code execution. Install SMFS directly or use a custom template with it pre-installed.
|
||||
</Card>
|
||||
<Card title="Vercel AI SDK" icon="triangle" href="/smfs/providers/vercel">
|
||||
The most popular TypeScript agent framework. Add memory as a tool with one function call.
|
||||
</Card>
|
||||
<Card title="Cloudflare Workers" icon="cloud" href="/smfs/providers/cloudflare">
|
||||
Edge-first agents. Use the bash tool in Workers, or mount SMFS in Cloudflare Containers.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Install SMFS" icon="download" href="/smfs/install">
|
||||
One curl, one mount, you're done.
|
||||
</Card>
|
||||
<Card title="Use the Bash Tool" icon="terminal" href="/smfs/bash-tool">
|
||||
Drop SMFS into a TypeScript or Python agent without mounting anything.
|
||||
</Card>
|
||||
<Card title="Examples" icon="code" href="/smfs/examples">
|
||||
Full working apps you can clone and run — legal docs, support agents, and more.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
337
apps/docs/smfs/providers/cloudflare.mdx
Normal file
337
apps/docs/smfs/providers/cloudflare.mdx
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
---
|
||||
title: "Cloudflare"
|
||||
description: "Give your AI agent persistent memory inside a Cloudflare Container using SMFS"
|
||||
---
|
||||
|
||||
Mount a Supermemory container inside a
|
||||
[Cloudflare Container](https://developers.cloudflare.com/containers/) so your
|
||||
agent can read and write memory using standard filesystem commands.
|
||||
|
||||
## How it works
|
||||
|
||||
There are two ways to wire SMFS into a Cloudflare Container — pick the one that
|
||||
fits your architecture.
|
||||
|
||||
### Agent inside the container
|
||||
|
||||
The agent process runs inside the container with direct access to the SMFS
|
||||
mount. The entrypoint sets up the mount and starts the agent.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Cloudflare Container
|
||||
Agent["Claude Agent"] -->|"cat, ls, echo"| Mount["/memory<br/>(SMFS mount)"]
|
||||
end
|
||||
Mount -->|sync| SM["Supermemory"]
|
||||
```
|
||||
|
||||
### Agent outside the container
|
||||
|
||||
The agent runs in a Cloudflare Worker and sends commands to the container over
|
||||
HTTP. The container exposes a simple exec endpoint.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Agent["Worker<br/>(agent logic)"] -->|"containerFetch('/exec')"| Container
|
||||
subgraph Container ["Cloudflare Container"]
|
||||
Mount["/memory<br/>(SMFS mount)"]
|
||||
end
|
||||
Mount -->|sync| SM["Supermemory"]
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A [Supermemory API key](https://supermemory.ai)
|
||||
- An [Anthropic API key](https://console.anthropic.com)
|
||||
- A [Cloudflare account](https://dash.cloudflare.com) with Containers enabled (Workers Paid plan)
|
||||
- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/)
|
||||
- The [`@cloudflare/containers`](https://www.npmjs.com/package/@cloudflare/containers) package: `npm install @cloudflare/containers`
|
||||
|
||||
<Note>
|
||||
Cloudflare Containers are implemented as container-enabled Durable Objects.
|
||||
You declare a `Container` subclass, bind it as a Durable Object, and
|
||||
reference its image in the `containers` array. Worker secrets are **not**
|
||||
automatically visible inside the container — you have to pass them through
|
||||
`envVars` when starting the container (see below).
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Pattern A: Agent inside the container
|
||||
|
||||
SMFS and the Claude Agent SDK are baked into the container image. On startup,
|
||||
the entrypoint mounts memory and runs the agent.
|
||||
|
||||
### Dockerfile
|
||||
|
||||
```dockerfile Dockerfile
|
||||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y fuse3 curl bash && rm -rf /var/lib/apt/lists/*
|
||||
RUN echo 'user_allow_other' >> /etc/fuse.conf
|
||||
|
||||
RUN curl -fsSL https://smfs.ai/install | bash -s -- 0.0.1-rc2
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
RUN pip install claude-agent-sdk
|
||||
|
||||
COPY agent.py /app/agent.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
```
|
||||
|
||||
### Entrypoint
|
||||
|
||||
```bash entrypoint.sh
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
smfs login --key "$SUPERMEMORY_API_KEY"
|
||||
smfs mount my_agent --ephemeral --path /memory --foreground &
|
||||
sleep 3
|
||||
|
||||
exec python3 /app/agent.py
|
||||
```
|
||||
|
||||
### Agent code
|
||||
|
||||
```python agent.py
|
||||
import asyncio
|
||||
from claude_agent_sdk import query, ClaudeAgentOptions
|
||||
|
||||
MEMORY = "/memory"
|
||||
|
||||
async def main():
|
||||
async for message in query(
|
||||
prompt=f"You have a persistent memory filesystem at {MEMORY}. "
|
||||
"Read profile.md to learn about the user, then create "
|
||||
"session_notes.md summarizing what you found.",
|
||||
options=ClaudeAgentOptions(
|
||||
allowed_tools=["Bash", "Read", "Write"],
|
||||
cwd=MEMORY,
|
||||
),
|
||||
):
|
||||
print(message)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Worker
|
||||
|
||||
The Worker defines the `Container` subclass and forwards Worker secrets into
|
||||
the container via `envVars`:
|
||||
|
||||
```typescript worker.ts
|
||||
import { Container, getContainer } from "@cloudflare/containers";
|
||||
|
||||
export class MyAgentContainer extends Container {
|
||||
defaultPort = 8080;
|
||||
// Forward Worker secrets into the container at start time.
|
||||
// `this.env` is the Worker env object, populated from wrangler secrets.
|
||||
envVars = {
|
||||
SUPERMEMORY_API_KEY: this.env.SUPERMEMORY_API_KEY,
|
||||
ANTHROPIC_API_KEY: this.env.ANTHROPIC_API_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env) {
|
||||
// The container runs the agent and exits; this Worker route just kicks
|
||||
// it off (e.g. on a queue message or scheduled trigger).
|
||||
const container = getContainer(env.MY_CONTAINER, "agent-singleton");
|
||||
return container.fetch(request);
|
||||
},
|
||||
};
|
||||
|
||||
interface Env {
|
||||
MY_CONTAINER: DurableObjectNamespace<MyAgentContainer>;
|
||||
SUPERMEMORY_API_KEY: string;
|
||||
ANTHROPIC_API_KEY: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
```jsonc wrangler.jsonc
|
||||
{
|
||||
"name": "memory-agent",
|
||||
"main": "worker.ts",
|
||||
"compatibility_date": "2025-04-03",
|
||||
"containers": [
|
||||
{
|
||||
"class_name": "MyAgentContainer",
|
||||
"image": "./Dockerfile",
|
||||
"max_instances": 5
|
||||
}
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{ "name": "MY_CONTAINER", "class_name": "MyAgentContainer" }
|
||||
]
|
||||
},
|
||||
"migrations": [
|
||||
{ "tag": "v1", "new_sqlite_classes": ["MyAgentContainer"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
wrangler secret put SUPERMEMORY_API_KEY
|
||||
wrangler secret put ANTHROPIC_API_KEY
|
||||
wrangler deploy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern B: Agent outside the container
|
||||
|
||||
The agent logic lives in a Worker. The container just runs SMFS and exposes an
|
||||
HTTP endpoint for executing commands against the mount.
|
||||
|
||||
<Warning>
|
||||
The `/exec` endpoint below runs arbitrary shell commands inside the
|
||||
container. **Only call it from your Worker** — never expose it publicly,
|
||||
and never pass user input straight into `command` without validation.
|
||||
Cloudflare Containers are addressable only through their Worker by default,
|
||||
so this is safe as long as you don't add a public route that proxies to
|
||||
`/exec`.
|
||||
</Warning>
|
||||
|
||||
### Container (exec server)
|
||||
|
||||
The Dockerfile and entrypoint are nearly identical to Pattern A — the only
|
||||
differences are the Python deps (`flask` instead of `claude-agent-sdk`) and
|
||||
the file we exec at the end.
|
||||
|
||||
```dockerfile Dockerfile
|
||||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y fuse3 curl bash && rm -rf /var/lib/apt/lists/*
|
||||
RUN echo 'user_allow_other' >> /etc/fuse.conf
|
||||
|
||||
RUN curl -fsSL https://smfs.ai/install | bash -s -- 0.0.1-rc2
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
RUN pip install flask gunicorn
|
||||
|
||||
COPY server.py /app/server.py
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
```
|
||||
|
||||
The entrypoint differs from Pattern A only in the final `exec` line — we run
|
||||
gunicorn against the Flask app instead of `python3 agent.py`:
|
||||
|
||||
```bash entrypoint.sh
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
smfs login --key "$SUPERMEMORY_API_KEY"
|
||||
smfs mount my_agent --ephemeral --path /memory --foreground &
|
||||
sleep 3
|
||||
|
||||
exec gunicorn -b 0.0.0.0:8080 --chdir /app server:app
|
||||
```
|
||||
|
||||
```python server.py
|
||||
import subprocess
|
||||
from flask import Flask, request, jsonify
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/exec", methods=["POST"])
|
||||
def exec_command():
|
||||
cmd = request.json["command"]
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, capture_output=True, text=True, cwd="/memory", timeout=10
|
||||
)
|
||||
return jsonify(stdout=result.stdout, stderr=result.stderr, code=result.returncode)
|
||||
```
|
||||
|
||||
<Note>
|
||||
We use gunicorn instead of `app.run(...)` because Flask's built-in dev
|
||||
server isn't meant for production traffic. If you'd rather just see it
|
||||
work, you can replace the `exec` line with
|
||||
`exec python3 /app/server.py` and add `app.run(host="0.0.0.0", port=8080)`
|
||||
to `server.py` — but switch back to gunicorn before you ship.
|
||||
</Note>
|
||||
|
||||
### Worker (agent logic)
|
||||
|
||||
```typescript worker.ts
|
||||
import { Container, getContainer } from "@cloudflare/containers";
|
||||
|
||||
export class ExecContainer extends Container {
|
||||
defaultPort = 8080;
|
||||
envVars = {
|
||||
SUPERMEMORY_API_KEY: this.env.SUPERMEMORY_API_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(_request: Request, env: Env) {
|
||||
const container = getContainer(env.MY_CONTAINER, "agent-singleton");
|
||||
|
||||
const profile = await container
|
||||
.fetch(new Request("http://container/exec", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ command: "cat /memory/profile.md" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}))
|
||||
.then((r) => r.json<{ stdout: string }>());
|
||||
|
||||
return Response.json({ profile: profile.stdout });
|
||||
},
|
||||
};
|
||||
|
||||
interface Env {
|
||||
MY_CONTAINER: DurableObjectNamespace<ExecContainer>;
|
||||
SUPERMEMORY_API_KEY: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Config
|
||||
|
||||
```jsonc wrangler.jsonc
|
||||
{
|
||||
"name": "memory-exec",
|
||||
"main": "worker.ts",
|
||||
"compatibility_date": "2025-04-03",
|
||||
"containers": [
|
||||
{
|
||||
"class_name": "ExecContainer",
|
||||
"image": "./Dockerfile",
|
||||
"max_instances": 5
|
||||
}
|
||||
],
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{ "name": "MY_CONTAINER", "class_name": "ExecContainer" }
|
||||
]
|
||||
},
|
||||
"migrations": [
|
||||
{ "tag": "v1", "new_sqlite_classes": ["ExecContainer"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
wrangler secret put SUPERMEMORY_API_KEY
|
||||
wrangler deploy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `--ephemeral` for container mounts — keeps the cache in memory only, but
|
||||
writes still push to Supermemory
|
||||
- Use `smfs grep 'query'` for semantic search across all files
|
||||
- Worker secrets aren't automatically visible inside the container. Pass each
|
||||
one through the `envVars` field on your `Container` subclass (see the Worker
|
||||
snippets above)
|
||||
- Use `containerFetch` from within a Container class method (e.g., lifecycle
|
||||
hooks) to call the container's own HTTP server. From the Worker, use the
|
||||
stub's `.fetch()` method instead
|
||||
282
apps/docs/smfs/providers/daytona.mdx
Normal file
282
apps/docs/smfs/providers/daytona.mdx
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
---
|
||||
title: "Daytona"
|
||||
description: "Give your AI agent persistent memory inside a Daytona sandbox using SMFS"
|
||||
---
|
||||
|
||||
Mount a Supermemory container inside a [Daytona](https://daytona.io) sandbox so
|
||||
your agent can read and write memory using standard filesystem commands.
|
||||
|
||||
<Warning>
|
||||
Daytona sandboxes currently cannot reach `api.supermemory.ai` from their
|
||||
datacenter IPs. The SMFS binary still installs (we download it directly from
|
||||
GitHub Releases), the FUSE mount still starts, and `pip install
|
||||
claude-agent-sdk` still works — but the runtime sync to Supermemory fails. We're
|
||||
working with Daytona to resolve this. In the meantime, use
|
||||
[E2B](/smfs/providers/e2b) or a [self-hosted mount](/smfs/providers/vercel).
|
||||
</Warning>
|
||||
|
||||
## How it works
|
||||
|
||||
There are two ways to wire SMFS into a Daytona sandbox — pick the one that fits
|
||||
your architecture.
|
||||
|
||||
### Agent inside the sandbox
|
||||
|
||||
The agent process runs inside the sandbox and accesses the SMFS mount directly.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Daytona Sandbox
|
||||
Agent["Claude Agent"] -->|"cat, ls, echo"| Mount["/home/daytona/memory<br/>(SMFS mount)"]
|
||||
end
|
||||
Mount -->|sync| SM["Supermemory"]
|
||||
```
|
||||
|
||||
### Agent outside the sandbox
|
||||
|
||||
The agent runs in your orchestrating code and executes commands inside the
|
||||
sandbox remotely.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Agent["Claude Agent<br/>(your server)"] -->|"sandbox.process.exec()"| Sandbox
|
||||
subgraph Sandbox ["Daytona Sandbox"]
|
||||
Mount["/home/daytona/memory<br/>(SMFS mount)"]
|
||||
end
|
||||
Mount -->|sync| SM["Supermemory"]
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A [Supermemory API key](https://supermemory.ai)
|
||||
- A [Daytona API key](https://app.daytona.io) — go to **API Keys** in the sidebar
|
||||
- An [Anthropic API key](https://console.anthropic.com)
|
||||
|
||||
---
|
||||
|
||||
## Install SMFS in a Daytona sandbox
|
||||
|
||||
Both patterns below run the same setup snippet inside the sandbox before
|
||||
mounting. Daytona can't reach `smfs.ai`, so we download the binary directly
|
||||
from GitHub Releases and add `~/.local/bin` to PATH.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
```python
|
||||
SMFS_INSTALL = (
|
||||
"mkdir -p $HOME/.local/bin && "
|
||||
"curl -sL https://github.com/supermemoryai/smfs/releases/download/"
|
||||
"v0.0.1-rc2/smfs-linux-x64 -o $HOME/.local/bin/smfs && "
|
||||
"chmod +x $HOME/.local/bin/smfs && "
|
||||
"echo 'user_allow_other' | sudo tee -a /etc/fuse.conf > /dev/null && "
|
||||
"pip install claude-agent-sdk"
|
||||
)
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="TypeScript">
|
||||
```typescript
|
||||
const SMFS_INSTALL =
|
||||
"mkdir -p $HOME/.local/bin && " +
|
||||
"curl -sL https://github.com/supermemoryai/smfs/releases/download/" +
|
||||
"v0.0.1-rc2/smfs-linux-x64 -o $HOME/.local/bin/smfs && " +
|
||||
"chmod +x $HOME/.local/bin/smfs && " +
|
||||
"echo 'user_allow_other' | sudo tee -a /etc/fuse.conf > /dev/null && " +
|
||||
"pip install claude-agent-sdk";
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
---
|
||||
|
||||
## Pattern A: Agent inside the sandbox
|
||||
|
||||
### Agent code
|
||||
|
||||
```python agent.py
|
||||
import asyncio
|
||||
from claude_agent_sdk import query, ClaudeAgentOptions
|
||||
|
||||
MEMORY = "/home/daytona/memory"
|
||||
|
||||
async def main():
|
||||
async for message in query(
|
||||
prompt=f"You have a persistent memory filesystem at {MEMORY}. "
|
||||
"Read profile.md to learn about the user, then create "
|
||||
"session_notes.md summarizing what you found.",
|
||||
options=ClaudeAgentOptions(
|
||||
allowed_tools=["Bash", "Read", "Write"],
|
||||
cwd=MEMORY,
|
||||
),
|
||||
):
|
||||
print(message)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Orchestration
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
```python run.py
|
||||
import os
|
||||
from pathlib import Path
|
||||
from daytona_sdk import Daytona, DaytonaConfig
|
||||
|
||||
daytona = Daytona(DaytonaConfig(
|
||||
api_key=os.environ["DAYTONA_API_KEY"],
|
||||
))
|
||||
sandbox = daytona.create(
|
||||
env_vars={
|
||||
"SUPERMEMORY_API_KEY": os.environ["SUPERMEMORY_API_KEY"],
|
||||
"ANTHROPIC_API_KEY": os.environ["ANTHROPIC_API_KEY"],
|
||||
},
|
||||
)
|
||||
|
||||
# See "Install SMFS in a Daytona sandbox" above
|
||||
sandbox.process.exec(SMFS_INSTALL)
|
||||
|
||||
# Mount memory
|
||||
sandbox.process.exec("$HOME/.local/bin/smfs login --key $SUPERMEMORY_API_KEY")
|
||||
sandbox.process.exec(
|
||||
"bash -c '$HOME/.local/bin/smfs mount my_agent --ephemeral"
|
||||
" --path /home/daytona/memory --foreground &' && sleep 3"
|
||||
)
|
||||
|
||||
# Upload and run the agent
|
||||
sandbox.fs.upload_file(Path("agent.py").read_bytes(), "agent.py")
|
||||
result = sandbox.process.exec("python3 agent.py")
|
||||
print(result.result)
|
||||
|
||||
daytona.delete(sandbox)
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="TypeScript">
|
||||
```typescript run.ts
|
||||
import { Daytona } from "@daytonaio/sdk";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const daytona = new Daytona({
|
||||
apiKey: process.env.DAYTONA_API_KEY!,
|
||||
});
|
||||
const sandbox = await daytona.create({
|
||||
envVars: {
|
||||
SUPERMEMORY_API_KEY: process.env.SUPERMEMORY_API_KEY!,
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
// See "Install SMFS in a Daytona sandbox" above
|
||||
await sandbox.process.exec(SMFS_INSTALL);
|
||||
|
||||
// Mount memory
|
||||
await sandbox.process.exec(
|
||||
"$HOME/.local/bin/smfs login --key $SUPERMEMORY_API_KEY"
|
||||
);
|
||||
await sandbox.process.exec(
|
||||
"bash -c '$HOME/.local/bin/smfs mount my_agent --ephemeral " +
|
||||
"--path /home/daytona/memory --foreground &' && sleep 3"
|
||||
);
|
||||
|
||||
// Upload and run the agent
|
||||
await sandbox.fs.uploadFile(readFileSync("agent.py"), "agent.py");
|
||||
const result = await sandbox.process.exec("python3 agent.py");
|
||||
console.log(result.result);
|
||||
|
||||
await daytona.delete(sandbox);
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
---
|
||||
|
||||
## Pattern B: Agent outside the sandbox
|
||||
|
||||
The agent runs in your server process and executes commands inside the sandbox
|
||||
remotely via `sandbox.process.exec()`.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
```python run.py
|
||||
import os
|
||||
from daytona_sdk import Daytona, DaytonaConfig
|
||||
|
||||
daytona = Daytona(DaytonaConfig(
|
||||
api_key=os.environ["DAYTONA_API_KEY"],
|
||||
))
|
||||
sandbox = daytona.create(
|
||||
env_vars={
|
||||
"SUPERMEMORY_API_KEY": os.environ["SUPERMEMORY_API_KEY"],
|
||||
},
|
||||
)
|
||||
|
||||
# See "Install SMFS in a Daytona sandbox" above
|
||||
sandbox.process.exec(SMFS_INSTALL)
|
||||
sandbox.process.exec("$HOME/.local/bin/smfs login --key $SUPERMEMORY_API_KEY")
|
||||
sandbox.process.exec(
|
||||
"bash -c '$HOME/.local/bin/smfs mount my_agent --ephemeral"
|
||||
" --path /home/daytona/memory --foreground &' && sleep 3"
|
||||
)
|
||||
|
||||
# Agent runs here — executes commands in the sandbox
|
||||
profile = sandbox.process.exec("cat /home/daytona/memory/profile.md")
|
||||
print("Profile:", profile.result)
|
||||
|
||||
sandbox.process.exec(
|
||||
"bash -c 'echo \"Session started at $(date)\" > /home/daytona/memory/session_notes.md'"
|
||||
)
|
||||
|
||||
files = sandbox.process.exec("ls /home/daytona/memory")
|
||||
print("Files:", files.result)
|
||||
|
||||
daytona.delete(sandbox)
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="TypeScript">
|
||||
```typescript run.ts
|
||||
import { Daytona } from "@daytonaio/sdk";
|
||||
|
||||
const daytona = new Daytona({
|
||||
apiKey: process.env.DAYTONA_API_KEY!,
|
||||
});
|
||||
const sandbox = await daytona.create({
|
||||
envVars: {
|
||||
SUPERMEMORY_API_KEY: process.env.SUPERMEMORY_API_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
// See "Install SMFS in a Daytona sandbox" above
|
||||
await sandbox.process.exec(SMFS_INSTALL);
|
||||
await sandbox.process.exec(
|
||||
"$HOME/.local/bin/smfs login --key $SUPERMEMORY_API_KEY"
|
||||
);
|
||||
await sandbox.process.exec(
|
||||
"bash -c '$HOME/.local/bin/smfs mount my_agent --ephemeral " +
|
||||
"--path /home/daytona/memory --foreground &' && sleep 3"
|
||||
);
|
||||
|
||||
// Agent runs here — executes commands in the sandbox
|
||||
const profile = await sandbox.process.exec("cat /home/daytona/memory/profile.md");
|
||||
console.log("Profile:", profile.result);
|
||||
|
||||
await sandbox.process.exec(
|
||||
`bash -c 'echo "Session started at $(date)" > /home/daytona/memory/session_notes.md'`
|
||||
);
|
||||
|
||||
const files = await sandbox.process.exec("ls /home/daytona/memory");
|
||||
console.log("Files:", files.result);
|
||||
|
||||
await daytona.delete(sandbox);
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- FUSE is available in Daytona sandboxes but `user_allow_other` needs to be
|
||||
added to `/etc/fuse.conf`
|
||||
- We invoke SMFS as `$HOME/.local/bin/smfs` in the examples because Daytona's
|
||||
default zsh PATH doesn't include `~/.local/bin`. Alternatively, prepend it
|
||||
once with `export PATH=$HOME/.local/bin:$PATH`
|
||||
- Use `pip install claude-agent-sdk` to install the agent SDK (PyPI is reachable)
|
||||
265
apps/docs/smfs/providers/e2b.mdx
Normal file
265
apps/docs/smfs/providers/e2b.mdx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
---
|
||||
title: "E2B"
|
||||
description: "Give your AI agent persistent memory inside an E2B sandbox using SMFS"
|
||||
---
|
||||
|
||||
Mount a Supermemory container inside an [E2B](https://e2b.dev) sandbox so your
|
||||
agent can read and write memory using standard filesystem commands.
|
||||
|
||||
## How it works
|
||||
|
||||
There are two ways to wire SMFS into an E2B sandbox — pick the one that fits
|
||||
your architecture.
|
||||
|
||||
### Agent inside the sandbox
|
||||
|
||||
The agent process runs inside the sandbox and accesses the SMFS mount directly.
|
||||
Your orchestrating code just boots the sandbox and kicks off the agent.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph E2B Sandbox
|
||||
Agent["Claude Agent"] -->|"cat, ls, echo"| Mount["/home/user/memory<br/>(SMFS mount)"]
|
||||
end
|
||||
Mount -->|sync| SM["Supermemory"]
|
||||
```
|
||||
|
||||
### Agent outside the sandbox
|
||||
|
||||
The agent runs in your orchestrating code and executes commands inside the
|
||||
sandbox remotely. Useful when you want to keep the agent loop in your own
|
||||
infra.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Agent["Claude Agent<br/>(your server)"] -->|"sbx.commands.run()"| Sandbox
|
||||
subgraph Sandbox ["E2B Sandbox"]
|
||||
Mount["/home/user/memory<br/>(SMFS mount)"]
|
||||
end
|
||||
Mount -->|sync| SM["Supermemory"]
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A [Supermemory API key](https://supermemory.ai)
|
||||
- An [E2B API key](https://e2b.dev)
|
||||
- An [Anthropic API key](https://console.anthropic.com)
|
||||
|
||||
## 1. Create a custom template
|
||||
|
||||
Bake SMFS and the Claude Agent SDK into a template so sandboxes start ready:
|
||||
|
||||
```dockerfile e2b.Dockerfile
|
||||
FROM e2b/code-interpreter:latest
|
||||
|
||||
RUN apt-get update && apt-get install -y fuse3 && rm -rf /var/lib/apt/lists/*
|
||||
RUN echo 'user_allow_other' >> /etc/fuse.conf
|
||||
RUN curl -fsSL https://smfs.ai/install | bash -s -- 0.0.1-rc2
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
RUN pip install claude-agent-sdk
|
||||
```
|
||||
|
||||
```bash
|
||||
e2b template build -d e2b.Dockerfile
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern A: Agent inside the sandbox
|
||||
|
||||
The agent runs inside the sandbox as a Python script. Your orchestrating code
|
||||
just sets up the mount and starts it.
|
||||
|
||||
### Agent code
|
||||
|
||||
```python agent.py
|
||||
import asyncio
|
||||
from claude_agent_sdk import query, ClaudeAgentOptions
|
||||
|
||||
MEMORY = "/home/user/memory"
|
||||
|
||||
async def main():
|
||||
async for message in query(
|
||||
prompt=f"You have a persistent memory filesystem at {MEMORY}. "
|
||||
"Read profile.md to learn about the user, then create "
|
||||
"session_notes.md summarizing what you found.",
|
||||
options=ClaudeAgentOptions(
|
||||
allowed_tools=["Bash", "Read", "Write"],
|
||||
cwd=MEMORY,
|
||||
),
|
||||
):
|
||||
print(message)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Orchestration
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
```python run.py
|
||||
import os
|
||||
from pathlib import Path
|
||||
from e2b_code_interpreter import Sandbox
|
||||
|
||||
sbx = Sandbox.create(
|
||||
template="your-template-id",
|
||||
timeout=300,
|
||||
envs={
|
||||
"SUPERMEMORY_API_KEY": os.environ["SUPERMEMORY_API_KEY"],
|
||||
"ANTHROPIC_API_KEY": os.environ["ANTHROPIC_API_KEY"],
|
||||
},
|
||||
)
|
||||
|
||||
# /dev/fuse exists in E2B but is root-only by default. chmod once per sandbox.
|
||||
sbx.commands.run("sudo chmod 666 /dev/fuse")
|
||||
|
||||
# Mount memory. We background the foreground daemon so this command returns,
|
||||
# then sleep briefly to let the FUSE mount come up before the agent reads it.
|
||||
sbx.commands.run("smfs login --key $SUPERMEMORY_API_KEY")
|
||||
sbx.commands.run(
|
||||
"bash -c 'smfs mount my_agent --ephemeral"
|
||||
" --path /home/user/memory --foreground &' && sleep 3"
|
||||
)
|
||||
|
||||
# Upload and run the agent
|
||||
sbx.files.write("/home/user/agent.py", Path("agent.py").read_text())
|
||||
result = sbx.commands.run("python3 /home/user/agent.py", timeout=120)
|
||||
print(result.stdout)
|
||||
|
||||
sbx.kill()
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="TypeScript">
|
||||
```typescript run.ts
|
||||
import { Sandbox } from "@e2b/code-interpreter";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const sbx = await Sandbox.create({
|
||||
template: "your-template-id",
|
||||
timeoutMs: 300_000,
|
||||
envs: {
|
||||
SUPERMEMORY_API_KEY: process.env.SUPERMEMORY_API_KEY!,
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
// /dev/fuse exists in E2B but is root-only by default. chmod once per sandbox.
|
||||
await sbx.commands.run("sudo chmod 666 /dev/fuse");
|
||||
|
||||
// Mount memory. We background the foreground daemon so this command returns,
|
||||
// then sleep briefly to let the FUSE mount come up before the agent reads it.
|
||||
await sbx.commands.run("smfs login --key $SUPERMEMORY_API_KEY");
|
||||
await sbx.commands.run(
|
||||
"bash -c 'smfs mount my_agent --ephemeral --path /home/user/memory --foreground &' && sleep 3"
|
||||
);
|
||||
|
||||
// Upload and run the agent
|
||||
await sbx.files.write("/home/user/agent.py", readFileSync("agent.py", "utf-8"));
|
||||
const result = await sbx.commands.run("python3 /home/user/agent.py", {
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
console.log(result.stdout);
|
||||
|
||||
await sbx.kill();
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
---
|
||||
|
||||
## Pattern B: Agent outside the sandbox
|
||||
|
||||
The agent runs in your server process and executes commands inside the sandbox
|
||||
remotely via `sbx.commands.run()`. The SMFS mount lives inside the sandbox —
|
||||
the agent never touches the filesystem directly.
|
||||
|
||||
<Note>
|
||||
The FUSE mount is owned by root inside the sandbox. When writing to it from
|
||||
outside the agent, wrap the command in `sudo bash -c '…'` so the redirect
|
||||
runs with the right permissions. You'll see this in the write examples below.
|
||||
</Note>
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Python">
|
||||
```python run.py
|
||||
import os
|
||||
from e2b_code_interpreter import Sandbox
|
||||
|
||||
sbx = Sandbox.create(
|
||||
template="your-template-id",
|
||||
timeout=300,
|
||||
envs={
|
||||
"SUPERMEMORY_API_KEY": os.environ["SUPERMEMORY_API_KEY"],
|
||||
},
|
||||
)
|
||||
|
||||
# Set up SMFS inside the sandbox
|
||||
sbx.commands.run("sudo chmod 666 /dev/fuse")
|
||||
sbx.commands.run("smfs login --key $SUPERMEMORY_API_KEY")
|
||||
sbx.commands.run(
|
||||
"bash -c 'smfs mount my_agent --ephemeral"
|
||||
" --path /home/user/memory --foreground &' && sleep 3"
|
||||
)
|
||||
|
||||
# Agent runs here — executes commands in the sandbox
|
||||
profile = sbx.commands.run("cat /home/user/memory/profile.md").stdout
|
||||
print("Profile:", profile)
|
||||
|
||||
sbx.commands.run(
|
||||
"sudo bash -c 'echo \"Session started at $(date)\" > /home/user/memory/session_notes.md'"
|
||||
)
|
||||
|
||||
files = sbx.commands.run("ls /home/user/memory").stdout
|
||||
print("Files:", files)
|
||||
|
||||
sbx.kill()
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="TypeScript">
|
||||
```typescript run.ts
|
||||
import { Sandbox } from "@e2b/code-interpreter";
|
||||
|
||||
const sbx = await Sandbox.create({
|
||||
template: "your-template-id",
|
||||
timeoutMs: 300_000,
|
||||
envs: {
|
||||
SUPERMEMORY_API_KEY: process.env.SUPERMEMORY_API_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
// Set up SMFS inside the sandbox
|
||||
await sbx.commands.run("sudo chmod 666 /dev/fuse");
|
||||
await sbx.commands.run("smfs login --key $SUPERMEMORY_API_KEY");
|
||||
await sbx.commands.run(
|
||||
"bash -c 'smfs mount my_agent --ephemeral --path /home/user/memory --foreground &' && sleep 3"
|
||||
);
|
||||
|
||||
// Agent runs here — executes commands in the sandbox
|
||||
const profile = await sbx.commands.run("cat /home/user/memory/profile.md");
|
||||
console.log("Profile:", profile.stdout);
|
||||
|
||||
await sbx.commands.run(
|
||||
`sudo bash -c 'echo "Session started at $(date)" > /home/user/memory/session_notes.md'`
|
||||
);
|
||||
|
||||
const files = await sbx.commands.run("ls /home/user/memory");
|
||||
console.log("Files:", files.stdout);
|
||||
|
||||
await sbx.kill();
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `--ephemeral` for sandbox mounts — keeps the cache in memory only, but
|
||||
writes still push to Supermemory
|
||||
- Use `smfs grep 'query'` for semantic search across all files in the container
|
||||
- Without a custom template, add the install steps to your run script:
|
||||
```python
|
||||
sbx.commands.run("curl -fsSL https://smfs.ai/install | bash -s -- 0.0.1-rc2", timeout=60)
|
||||
sbx.commands.run("pip install claude-agent-sdk", timeout=60)
|
||||
```
|
||||
169
apps/docs/smfs/providers/vercel.mdx
Normal file
169
apps/docs/smfs/providers/vercel.mdx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
---
|
||||
title: "Vercel AI SDK"
|
||||
description: "Give your AI agent persistent memory using SMFS with the Vercel AI SDK"
|
||||
---
|
||||
|
||||
This guide is about the [Vercel AI SDK](https://ai-sdk.dev) — the TypeScript
|
||||
agent framework — not Vercel hosting. The choice of pattern depends on where
|
||||
your code actually runs:
|
||||
|
||||
- **Self-hosted Node** (your own VM, ECS, Fly.io, Railway, a Vercel Sandbox,
|
||||
etc.): you can mount SMFS as a real filesystem on the server.
|
||||
- **Vercel Functions / serverless / edge**: there's no long-lived process to
|
||||
hold a FUSE mount, so use the [Bash Tool](/smfs/bash-tool)
|
||||
(`@supermemory/bash`) instead. The container becomes the filesystem; no mount
|
||||
needed.
|
||||
|
||||
## How it works
|
||||
|
||||
### Self-hosted Node (real mount)
|
||||
|
||||
The agent runs as a separate process with direct access to the SMFS mount.
|
||||
Best when you want full bash, read, and write capabilities and your server is
|
||||
long-lived.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Your Server
|
||||
Agent["Claude Agent"] -->|"cat, ls, echo"| Mount["./memory<br/>(SMFS mount)"]
|
||||
end
|
||||
Mount -->|sync| SM["Supermemory"]
|
||||
```
|
||||
|
||||
### Vercel Functions / serverless (Bash Tool)
|
||||
|
||||
The agent runs inside `generateText` and accesses memory through `@supermemory/bash`,
|
||||
which proxies bash commands to your Supermemory container over HTTP. No mount,
|
||||
no FUSE, no long-lived process required.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Vercel Function
|
||||
AI["generateText()"] -->|"bash tool"| Bash["@supermemory/bash"]
|
||||
end
|
||||
Bash -->|HTTPS| SM["Supermemory"]
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A [Supermemory API key](https://supermemory.ai)
|
||||
- An [Anthropic API key](https://console.anthropic.com)
|
||||
- For Pattern A only: SMFS installed on your server (`curl -fsSL https://smfs.ai/install | bash`)
|
||||
|
||||
---
|
||||
|
||||
## Pattern A: Claude Agent SDK on self-hosted Node
|
||||
|
||||
Use this when the Vercel AI SDK is just the orchestrator and your real workload
|
||||
is a Claude agent running on a long-lived server you control.
|
||||
|
||||
Start the mount once when your server boots — not per-request:
|
||||
|
||||
```bash
|
||||
smfs login --key $SUPERMEMORY_API_KEY
|
||||
smfs mount my_agent --path ./memory
|
||||
```
|
||||
|
||||
<Note>
|
||||
This won't work on Vercel Functions or any serverless runtime: there's no
|
||||
process between requests to hold the mount, and FUSE isn't available. For
|
||||
those targets, jump to Pattern B.
|
||||
</Note>
|
||||
|
||||
Write a standalone agent script. Nothing server-specific — just Python that
|
||||
reads and writes files:
|
||||
|
||||
```python agent.py
|
||||
import asyncio
|
||||
from claude_agent_sdk import query, ClaudeAgentOptions
|
||||
|
||||
MEMORY = "./memory"
|
||||
|
||||
async def main():
|
||||
async for message in query(
|
||||
prompt=f"You have a persistent memory filesystem at {MEMORY}. "
|
||||
"Read profile.md to learn about the user, then create "
|
||||
"session_notes.md summarizing what you found.",
|
||||
options=ClaudeAgentOptions(
|
||||
allowed_tools=["Bash", "Read", "Write"],
|
||||
cwd=MEMORY,
|
||||
),
|
||||
):
|
||||
print(message)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
```bash
|
||||
python3 agent.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern B: Vercel AI SDK + Bash Tool (serverless-friendly)
|
||||
|
||||
`@supermemory/bash` exposes your Supermemory container as a single agent tool
|
||||
— `run_bash(command)` — without mounting anything. It runs anywhere TypeScript
|
||||
runs, including Vercel Functions, edge runtimes, and Lambda.
|
||||
|
||||
```bash
|
||||
npm install @supermemory/bash ai @ai-sdk/anthropic zod
|
||||
```
|
||||
|
||||
```typescript api/agent.ts
|
||||
import { generateText, tool } from "ai";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
import { createBash } from "@supermemory/bash";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { prompt } = await req.json();
|
||||
|
||||
const { bash, toolDescription } = await createBash({
|
||||
apiKey: process.env.SUPERMEMORY_API_KEY!,
|
||||
containerTag: "my_agent",
|
||||
});
|
||||
|
||||
const result = await generateText({
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
tools: {
|
||||
bash: tool({
|
||||
description: toolDescription,
|
||||
inputSchema: z.object({ cmd: z.string() }),
|
||||
execute: async ({ cmd }) => bash.exec(cmd),
|
||||
}),
|
||||
},
|
||||
maxSteps: 10,
|
||||
prompt,
|
||||
});
|
||||
|
||||
return Response.json({ text: result.text });
|
||||
}
|
||||
```
|
||||
|
||||
A few things worth calling out:
|
||||
|
||||
- **`maxSteps: 10`** lets the agent chain multiple bash calls per request
|
||||
(read `profile.md`, then `cat` a few notes, then write a summary). Bump it
|
||||
if your agent needs deeper chains; lower it to cap cost per request.
|
||||
- **`toolDescription`** is a pre-written description of the available bash
|
||||
surface (semantic `sgrep`, `cat`, `ls`, redirects, etc.). Hand it straight
|
||||
to the model — don't roll your own.
|
||||
- **No timeout/abort plumbing.** `bash.exec` already runs against the
|
||||
container over HTTPS, so it returns when the command returns. No event-loop
|
||||
blocking and no FUSE.
|
||||
|
||||
See the [Bash Tool reference](/smfs/bash-tool) for the full command surface,
|
||||
memory path configuration, and other framework integrations.
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- **Pattern A**: mount SMFS once when your server starts, not per-request.
|
||||
Use `--ephemeral` if you don't need a local cache on the server.
|
||||
- **Pattern B**: configure memory paths once at startup with
|
||||
`configureMemoryPaths(["/notes/", "/journal.md"])` to control which files
|
||||
get distilled into Supermemory memories.
|
||||
- Both: use `smfs grep 'query'` (Pattern A) or `sgrep 'query'` inside the
|
||||
bash tool (Pattern B) for semantic search across all files.
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
export type MemoryFormData = {
|
||||
twitter: string
|
||||
linkedin: string
|
||||
description: string
|
||||
otherLinks: string[]
|
||||
} | null
|
||||
|
||||
interface OnboardingContextValue {
|
||||
name: string
|
||||
setName: (name: string) => void
|
||||
memoryFormData: MemoryFormData
|
||||
setMemoryFormData: (data: MemoryFormData) => void
|
||||
resetOnboarding: () => void
|
||||
}
|
||||
|
||||
const OnboardingContext = createContext<OnboardingContextValue | null>(null)
|
||||
|
||||
export function useOnboardingContext() {
|
||||
const ctx = useContext(OnboardingContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useOnboardingContext must be used within OnboardingLayout")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export default function OnboardingLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { user } = useAuth()
|
||||
|
||||
const [name, setNameState] = useState<string>("")
|
||||
const [memoryFormData, setMemoryFormDataState] =
|
||||
useState<MemoryFormData>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const storedName = localStorage.getItem("onboarding_name")
|
||||
const storedMemoryFormData = localStorage.getItem(
|
||||
"onboarding_memoryFormData",
|
||||
)
|
||||
|
||||
if (storedName) {
|
||||
setNameState(storedName)
|
||||
} else if (user?.displayUsername) {
|
||||
setNameState(user.displayUsername)
|
||||
localStorage.setItem("onboarding_name", user.displayUsername)
|
||||
} else if (user?.name) {
|
||||
setNameState(user.name)
|
||||
localStorage.setItem("onboarding_name", user.name)
|
||||
}
|
||||
|
||||
if (storedMemoryFormData) {
|
||||
try {
|
||||
setMemoryFormDataState(JSON.parse(storedMemoryFormData))
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}, [user?.displayUsername, user?.name])
|
||||
|
||||
const setName = useCallback((newName: string) => {
|
||||
setNameState(newName)
|
||||
localStorage.setItem("onboarding_name", newName)
|
||||
localStorage.setItem("username", newName)
|
||||
}, [])
|
||||
|
||||
const setMemoryFormData = useCallback((data: MemoryFormData) => {
|
||||
setMemoryFormDataState(data)
|
||||
if (data) {
|
||||
localStorage.setItem("onboarding_memoryFormData", JSON.stringify(data))
|
||||
} else {
|
||||
localStorage.removeItem("onboarding_memoryFormData")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetOnboarding = useCallback(() => {
|
||||
localStorage.removeItem("onboarding_name")
|
||||
localStorage.removeItem("onboarding_memoryFormData")
|
||||
setNameState("")
|
||||
setMemoryFormDataState(null)
|
||||
}, [])
|
||||
|
||||
const contextValue: OnboardingContextValue = {
|
||||
name,
|
||||
setName,
|
||||
memoryFormData,
|
||||
setMemoryFormData,
|
||||
resetOnboarding,
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</OnboardingContext.Provider>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,87 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useOnboardingContext, type MemoryFormData } from "../layout"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
export const SETUP_STEPS = ["relatable", "integrations"] as const
|
||||
export type SetupStep = (typeof SETUP_STEPS)[number]
|
||||
|
||||
interface SetupContextValue {
|
||||
memoryFormData: MemoryFormData
|
||||
currentStep: SetupStep
|
||||
goToStep: (step: SetupStep) => void
|
||||
goToWelcome: (step?: string) => void
|
||||
finishOnboarding: () => void
|
||||
}
|
||||
|
||||
const SetupContext = createContext<SetupContextValue | null>(null)
|
||||
|
||||
export function useSetupContext() {
|
||||
const ctx = useContext(SetupContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useSetupContext must be used within SetupLayout")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export default function SetupLayout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { memoryFormData, resetOnboarding } = useOnboardingContext()
|
||||
|
||||
const stepParam = searchParams.get("step")
|
||||
const currentStep: SetupStep = SETUP_STEPS.includes(stepParam as SetupStep)
|
||||
? (stepParam as SetupStep)
|
||||
: "relatable"
|
||||
const hasTrackedInitialStep = useRef(false)
|
||||
|
||||
const goToStep = useCallback(
|
||||
(step: SetupStep) => {
|
||||
analytics.onboardingStepViewed({ step, trigger: "user" })
|
||||
router.push(`/onboarding/setup?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const goToWelcome = useCallback(
|
||||
(step = "input") => {
|
||||
router.push(`/onboarding/welcome?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const finishOnboarding = useCallback(() => {
|
||||
resetOnboarding()
|
||||
router.push("/")
|
||||
}, [router, resetOnboarding])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasTrackedInitialStep.current) {
|
||||
analytics.onboardingStepViewed({ step: currentStep, trigger: "user" })
|
||||
hasTrackedInitialStep.current = true
|
||||
}
|
||||
}, [currentStep])
|
||||
|
||||
const contextValue: SetupContextValue = {
|
||||
memoryFormData,
|
||||
currentStep,
|
||||
goToStep,
|
||||
goToWelcome,
|
||||
finishOnboarding,
|
||||
}
|
||||
|
||||
return (
|
||||
<SetupContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SetupContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
|
||||
import { RelatableQuestion } from "@/components/onboarding/setup/relatable-question"
|
||||
import { IntegrationsStep } from "@/components/onboarding/setup/integrations-step"
|
||||
|
||||
import { SetupHeader } from "@/components/onboarding/setup/header"
|
||||
import { ChatSidebar } from "@/components/onboarding/setup/chat-sidebar"
|
||||
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
|
||||
import { useSetupContext, type SetupStep } from "./layout"
|
||||
|
||||
function StepNotFound({ goToStep }: { goToStep: (step: SetupStep) => void }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<h2 className="text-white text-2xl mb-4">Unknown step</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToStep("relatable")}
|
||||
className="text-blue-400 underline"
|
||||
>
|
||||
Go to first step
|
||||
</button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SetupPage() {
|
||||
const { memoryFormData, currentStep, goToStep } = useSetupContext()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case "relatable":
|
||||
return <RelatableQuestion key="relatable" />
|
||||
case "integrations":
|
||||
return <IntegrationsStep key="integrations" />
|
||||
default:
|
||||
return <StepNotFound key="not-found" goToStep={goToStep} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden bg-black">
|
||||
<SetupHeader />
|
||||
|
||||
<AnimatedGradientBackground animateFromBottom={false} />
|
||||
|
||||
<main className="relative min-h-screen">
|
||||
<div className="relative z-10">
|
||||
<div className="flex flex-col lg:flex-row h-[calc(100vh-90px)] relative">
|
||||
<div className="flex-1 flex flex-col items-center justify-start p-4 md:p-8">
|
||||
<AnimatePresence mode="wait">{renderStep()}</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<AnimatePresence mode="popLayout">
|
||||
<ChatSidebar formData={memoryFormData} />
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{isMobile && <ChatSidebar formData={memoryFormData} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useOnboardingContext, type MemoryFormData } from "../layout"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
export const WELCOME_STEPS = [
|
||||
"input",
|
||||
"greeting",
|
||||
"welcome",
|
||||
"username",
|
||||
"features",
|
||||
"memories",
|
||||
] as const
|
||||
export type WelcomeStep = (typeof WELCOME_STEPS)[number]
|
||||
|
||||
interface WelcomeContextValue {
|
||||
name: string
|
||||
setName: (name: string) => void
|
||||
isSubmitting: boolean
|
||||
setIsSubmitting: (value: boolean) => void
|
||||
showWelcomeContent: boolean
|
||||
memoryFormData: MemoryFormData
|
||||
setMemoryFormData: (data: MemoryFormData) => void
|
||||
currentStep: WelcomeStep
|
||||
goToStep: (step: WelcomeStep) => void
|
||||
goToSetup: (step?: string) => void
|
||||
}
|
||||
|
||||
const WelcomeContext = createContext<WelcomeContextValue | null>(null)
|
||||
|
||||
export function useWelcomeContext() {
|
||||
const ctx = useContext(WelcomeContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useWelcomeContext must be used within WelcomeLayout")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export default function WelcomeLayout({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { name, setName, memoryFormData, setMemoryFormData } =
|
||||
useOnboardingContext()
|
||||
const { organizations } = useAuth()
|
||||
const hasOrgs = Array.isArray(organizations) && organizations.length > 0
|
||||
|
||||
const stepParam = searchParams.get("step")
|
||||
const resolvedStep: WelcomeStep = WELCOME_STEPS.includes(
|
||||
stepParam as WelcomeStep,
|
||||
)
|
||||
? (stepParam as WelcomeStep)
|
||||
: "input"
|
||||
const currentStep: WelcomeStep =
|
||||
resolvedStep === "input" && hasOrgs ? "greeting" : resolvedStep
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showWelcomeContent, setShowWelcomeContent] = useState(false)
|
||||
const isMountedRef = useRef(true)
|
||||
const hasTrackedInitialStep = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep === "input") {
|
||||
setShowWelcomeContent(false)
|
||||
const timer = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setShowWelcomeContent(true)
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
setShowWelcomeContent(true)
|
||||
}, [currentStep])
|
||||
|
||||
useEffect(() => {
|
||||
const timers: NodeJS.Timeout[] = []
|
||||
|
||||
if (currentStep === "greeting") {
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
analytics.onboardingStepViewed({ step: "welcome", trigger: "auto" })
|
||||
router.replace("/onboarding/welcome?step=welcome")
|
||||
}
|
||||
}, 2000),
|
||||
)
|
||||
} else if (currentStep === "welcome") {
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
analytics.onboardingStepViewed({
|
||||
step: "username",
|
||||
trigger: "auto",
|
||||
})
|
||||
router.replace("/onboarding/welcome?step=username")
|
||||
}
|
||||
}, 2000),
|
||||
)
|
||||
}
|
||||
|
||||
return () => {
|
||||
timers.forEach(clearTimeout)
|
||||
}
|
||||
}, [currentStep, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasTrackedInitialStep.current) {
|
||||
analytics.onboardingStepViewed({
|
||||
step: currentStep,
|
||||
trigger: "user",
|
||||
})
|
||||
hasTrackedInitialStep.current = true
|
||||
}
|
||||
}, [currentStep])
|
||||
|
||||
const goToStep = useCallback(
|
||||
(step: WelcomeStep) => {
|
||||
analytics.onboardingStepViewed({ step, trigger: "user" })
|
||||
router.push(`/onboarding/welcome?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const goToSetup = useCallback(
|
||||
(step = "relatable") => {
|
||||
router.push(`/onboarding/setup?step=${step}`)
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const contextValue: WelcomeContextValue = {
|
||||
name,
|
||||
setName,
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
showWelcomeContent,
|
||||
memoryFormData,
|
||||
setMemoryFormData,
|
||||
currentStep,
|
||||
goToStep,
|
||||
goToSetup,
|
||||
}
|
||||
|
||||
return (
|
||||
<WelcomeContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</WelcomeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useRef } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { cn } from "@lib/utils"
|
||||
|
||||
import { InputStep } from "@/components/onboarding/welcome/input-step"
|
||||
import { GreetingStep } from "@/components/onboarding/welcome/greeting-step"
|
||||
import { WelcomeStep } from "@/components/onboarding/welcome/welcome-step"
|
||||
import { OnboardingContentStep } from "@/components/onboarding/welcome/continue-step"
|
||||
|
||||
import { InitialHeader } from "@/components/initial-header"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
|
||||
|
||||
import {
|
||||
useWelcomeContext,
|
||||
type WelcomeStep as WelcomeStepType,
|
||||
} from "./layout"
|
||||
import { gapVariants, orbVariants } from "@/lib/variants"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { toast } from "sonner"
|
||||
|
||||
function generateSlugFromName(value: string) {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "") || "org"
|
||||
)
|
||||
}
|
||||
|
||||
function generateOrgSlug(name: string) {
|
||||
const base = generateSlugFromName(name.trim())
|
||||
const randomNum = Math.floor(100000 + Math.random() * 900000)
|
||||
return `${base}-${randomNum}`
|
||||
}
|
||||
|
||||
function generateUsername(name: string) {
|
||||
const base = generateSlugFromName(name.trim()).replace(/-/g, "_")
|
||||
const randomNum = Math.floor(100000 + Math.random() * 900000)
|
||||
return `${base}${randomNum}`
|
||||
}
|
||||
|
||||
function UserSupermemory({ name }: { name: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
className="absolute inset-0 top-[-34px] flex items-center justify-center z-10"
|
||||
initial={{ opacity: 0, y: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 0 }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
>
|
||||
<Logo className="h-14 text-white" />
|
||||
<div className="flex flex-col items-start justify-center ml-4">
|
||||
<p className="text-white text-[25px] font-medium leading-none">
|
||||
{name.split(" ")[0]}'s
|
||||
</p>
|
||||
<p className="text-white font-bold text-4xl leading-none -mt-2">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepNotFound({
|
||||
goToStep,
|
||||
}: {
|
||||
goToStep: (step: WelcomeStepType) => void
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<h2 className="text-white text-2xl mb-4">Unknown step</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToStep("input")}
|
||||
className="text-blue-400 underline"
|
||||
>
|
||||
Start from beginning
|
||||
</button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WelcomePage() {
|
||||
const {
|
||||
name,
|
||||
setName,
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
showWelcomeContent,
|
||||
setMemoryFormData,
|
||||
currentStep,
|
||||
goToStep,
|
||||
} = useWelcomeContext()
|
||||
|
||||
const { refetchOrganizations, setActiveOrg } = useAuth()
|
||||
const submitLockRef = useRef(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
if (submitLockRef.current) return
|
||||
submitLockRef.current = true
|
||||
localStorage.setItem("username", trimmed)
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await authClient.updateUser({
|
||||
displayUsername: trimmed,
|
||||
username: generateUsername(trimmed),
|
||||
})
|
||||
|
||||
const refetchResult = await refetchOrganizations()
|
||||
const refetchData = (
|
||||
refetchResult as { data?: unknown[] | null | undefined }
|
||||
)?.data
|
||||
const existingOrgs = Array.isArray(refetchData) ? refetchData : []
|
||||
|
||||
if (existingOrgs.length > 0) {
|
||||
analytics.onboardingNameSubmitted({
|
||||
name_length: trimmed.length,
|
||||
})
|
||||
goToStep("greeting")
|
||||
return
|
||||
}
|
||||
|
||||
const uniqueSlug = generateOrgSlug(trimmed)
|
||||
const completedAt = new Date().toISOString()
|
||||
const newOrg = await authClient.organization.create({
|
||||
name: trimmed,
|
||||
slug: uniqueSlug,
|
||||
metadata: {
|
||||
signupSource: "consumer",
|
||||
webOnboarding: {
|
||||
completedAt: null,
|
||||
steps: {
|
||||
welcomeInput: {
|
||||
startedAt: completedAt,
|
||||
completedAt,
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await setActiveOrg(newOrg.slug)
|
||||
|
||||
analytics.onboardingNameSubmitted({ name_length: trimmed.length })
|
||||
goToStep("greeting")
|
||||
} catch (error) {
|
||||
console.error("Onboarding submit failed:", error)
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Could not set up your workspace. Please try again.",
|
||||
)
|
||||
} finally {
|
||||
submitLockRef.current = false
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case "input":
|
||||
return (
|
||||
<InputStep
|
||||
key="input"
|
||||
name={name}
|
||||
setName={setName}
|
||||
handleSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
)
|
||||
case "greeting":
|
||||
return <GreetingStep key="greeting" name={name} />
|
||||
case "welcome":
|
||||
return <WelcomeStep key="welcome" />
|
||||
case "username":
|
||||
case "features":
|
||||
case "memories":
|
||||
return (
|
||||
<OnboardingContentStep
|
||||
key="onboarding-content"
|
||||
currentView={
|
||||
currentStep === "username"
|
||||
? "continue"
|
||||
: currentStep === "features"
|
||||
? "features"
|
||||
: "memories"
|
||||
}
|
||||
onSubmit={setMemoryFormData}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return <StepNotFound key="not-found" goToStep={goToStep} />
|
||||
}
|
||||
}
|
||||
|
||||
const minimizeNovaOrb = ["features", "memories"].includes(currentStep)
|
||||
const novaSize = currentStep === "memories" ? 150 : 300
|
||||
const showUserSupermemory = currentStep === "username"
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden bg-black">
|
||||
<InitialHeader
|
||||
showUserSupermemory={
|
||||
currentStep === "features" || currentStep === "memories"
|
||||
}
|
||||
showSkipOnboarding={currentStep !== "input"}
|
||||
name={name}
|
||||
/>
|
||||
|
||||
{currentStep === "input" && (
|
||||
<AnimatedGradientBackground animateFromBottom={true} />
|
||||
)}
|
||||
|
||||
{showWelcomeContent && (
|
||||
<div className="fixed inset-0 flex flex-col items-center justify-center overflow-y-auto">
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat pointer-events-none"
|
||||
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
|
||||
style={{
|
||||
mixBlendMode: "soft-light",
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10 flex flex-col items-center justify-center",
|
||||
)}
|
||||
variants={gapVariants}
|
||||
animate={minimizeNovaOrb ? "minimized" : "default"}
|
||||
>
|
||||
<motion.div
|
||||
variants={orbVariants}
|
||||
animate={
|
||||
currentStep === "features"
|
||||
? "features"
|
||||
: currentStep === "memories"
|
||||
? "memories"
|
||||
: "default"
|
||||
}
|
||||
initial={{
|
||||
padding: 0,
|
||||
paddingTop: 0,
|
||||
y: 60,
|
||||
}}
|
||||
className="relative"
|
||||
>
|
||||
<NovaOrb size={novaSize} />
|
||||
{showUserSupermemory && <UserSupermemory name={name} />}
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">{renderStep()}</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,12 +1,27 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useSyncExternalStore,
|
||||
} from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { Header } from "@/components/header"
|
||||
import { ChatSidebar } from "@/components/chat"
|
||||
import { Header, PublicHeader } from "@/components/header"
|
||||
import { ChatSidebar, HomeChatComposer } from "@/components/chat"
|
||||
import { DashboardView } from "@/components/dashboard-view"
|
||||
import { MemoriesGrid } from "@/components/memories-grid"
|
||||
import { GraphLayoutView } from "@/components/graph-layout-view"
|
||||
import { IntegrationsView } from "@/components/integrations-view"
|
||||
import { IntegrationsView, DetailWrapper } from "@/components/integrations-view"
|
||||
import { MCPDetailView } from "@/components/mcp-modal/mcp-detail-view"
|
||||
import { XBookmarksDetailView } from "@/components/onboarding/x-bookmarks-detail-view"
|
||||
import { ChromeDetail } from "@/components/integrations/chrome-detail"
|
||||
import { ShortcutsDetail } from "@/components/integrations/shortcuts-detail"
|
||||
import { RaycastDetail } from "@/components/integrations/raycast-detail"
|
||||
import { PluginsDetail } from "@/components/integrations/plugins-detail"
|
||||
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
|
||||
import { AddDocumentModal } from "@/components/add-document"
|
||||
import { DocumentModal } from "@/components/document-modal"
|
||||
|
|
@ -15,7 +30,6 @@ import { FullscreenNoteModal } from "@/components/fullscreen-note-modal"
|
|||
import type { HighlightItem } from "@/components/highlights-card"
|
||||
import { HotkeysProvider } from "react-hotkeys-hook"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useProject } from "@/stores"
|
||||
|
|
@ -26,11 +40,14 @@ import {
|
|||
useQuickNoteDraft,
|
||||
} from "@/stores/quick-note-draft"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import type { ModelId } from "@/lib/models"
|
||||
import { useDocumentMutations } from "@/hooks/use-document-mutations"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import type { MemoryOfDay } from "@/components/dashboard-view"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
import { cn } from "@lib/utils"
|
||||
import {
|
||||
|
|
@ -39,15 +56,35 @@ import {
|
|||
qParam,
|
||||
docParam,
|
||||
fullscreenParam,
|
||||
chatParam,
|
||||
integrationParam,
|
||||
pluginsPanelParam,
|
||||
threadParam,
|
||||
type IntegrationParamValue,
|
||||
} from "@/lib/search-params"
|
||||
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
function subscribeViewportWidth(cb: () => void) {
|
||||
window.addEventListener("resize", cb)
|
||||
return () => window.removeEventListener("resize", cb)
|
||||
}
|
||||
|
||||
function getViewportWidth() {
|
||||
return window.innerWidth
|
||||
}
|
||||
|
||||
const GRADIENT_TOP_WIDTH_MAX = 1440
|
||||
|
||||
function gradientTopPositionForWidth(width: number) {
|
||||
const minW = 320
|
||||
const pctWide = 15
|
||||
const pctNarrow = 70
|
||||
const w = Math.min(GRADIENT_TOP_WIDTH_MAX, Math.max(minW, width))
|
||||
const t = (w - minW) / (GRADIENT_TOP_WIDTH_MAX - minW)
|
||||
const eased = t * t
|
||||
return `${Math.round(pctNarrow + eased * (pctWide - pctNarrow))}%`
|
||||
}
|
||||
|
||||
function ViewErrorFallback() {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
|
|
@ -68,34 +105,28 @@ function ViewErrorFallback() {
|
|||
export default function NewPage() {
|
||||
const isMobile = useIsMobile()
|
||||
const { user, session } = useAuth()
|
||||
const {
|
||||
selectedProject,
|
||||
isNovaSpaces,
|
||||
novaContainerTags,
|
||||
selectedProjects,
|
||||
setSelectedProjects,
|
||||
} = useProject()
|
||||
const selectedProjectTag = selectedProjects[0]
|
||||
const isNovaContext =
|
||||
isNovaSpaces ||
|
||||
(selectedProjectTag !== undefined &&
|
||||
novaContainerTags.includes(selectedProjectTag))
|
||||
const { allProjects } = useContainerTags()
|
||||
const emptyStateSpaceName =
|
||||
!isNovaSpaces && selectedProjectTag
|
||||
? selectedProjectTag === DEFAULT_PROJECT_ID
|
||||
? "My Space"
|
||||
: (allProjects.find((p) => p.containerTag === selectedProjectTag)
|
||||
?.name ?? selectedProjectTag)
|
||||
: undefined
|
||||
|
||||
const handleSwitchToAllSpacesFromEmptyState = useCallback(() => {
|
||||
analytics.spaceSwitched({ space_id: "nova_spaces" })
|
||||
setSelectedProjects([])
|
||||
}, [setSelectedProjects])
|
||||
const { selectedProject, selectedProjects } = useProject()
|
||||
const selectedProjectTag = selectedProjects[0]
|
||||
const { allProjects } = useContainerTags()
|
||||
const dashboardSpaceLabel = useMemo(
|
||||
() =>
|
||||
getChatSpaceDisplayLabel({
|
||||
selectedProject,
|
||||
allProjects,
|
||||
}),
|
||||
[selectedProject, allProjects],
|
||||
)
|
||||
const emptyStateSpaceName = selectedProjectTag
|
||||
? selectedProjectTag === DEFAULT_PROJECT_ID
|
||||
? "My Space"
|
||||
: (allProjects.find((p) => p.containerTag === selectedProjectTag)?.name ??
|
||||
selectedProjectTag)
|
||||
: undefined
|
||||
|
||||
const { viewMode, setViewMode } = useViewMode()
|
||||
const queryClient = useQueryClient()
|
||||
const [highlightsForceAt, setHighlightsForceAt] = useState(0)
|
||||
|
||||
// Chrome extension auth: send session token via postMessage so the content script can store it
|
||||
useEffect(() => {
|
||||
|
|
@ -122,22 +153,15 @@ export default function NewPage() {
|
|||
"fullscreen",
|
||||
fullscreenParam,
|
||||
)
|
||||
const [isChatOpen, setIsChatOpen] = useQueryState("chat", chatParam)
|
||||
const [integrationFromUrl, setIntegration] = useQueryState(
|
||||
"integration",
|
||||
integrationParam,
|
||||
)
|
||||
const [pluginsPanelFromUrl] = useQueryState("plugins", pluginsPanelParam)
|
||||
|
||||
useEffect(() => {
|
||||
if (integrationFromUrl || pluginsPanelFromUrl === true) {
|
||||
void setViewMode("integrations")
|
||||
}
|
||||
}, [integrationFromUrl, pluginsPanelFromUrl, setViewMode])
|
||||
const [, setThreadIdUrl] = useQueryState("thread", threadParam)
|
||||
|
||||
// Ephemeral local state (not worth URL-encoding)
|
||||
const [fullscreenInitialContent, setFullscreenInitialContent] = useState("")
|
||||
const [queuedChatSeed, setQueuedChatSeed] = useState<string | null>(null)
|
||||
const [queuedChatModel, setQueuedChatModel] = useState<ModelId | null>(null)
|
||||
const [queuedMessageSource, setQueuedMessageSource] = useState<
|
||||
"highlight" | "home"
|
||||
>("highlight")
|
||||
const [selectedDocument, setSelectedDocument] =
|
||||
useState<DocumentWithMemories | null>(null)
|
||||
|
||||
|
|
@ -146,6 +170,10 @@ export default function NewPage() {
|
|||
if (!docId) setSelectedDocument(null)
|
||||
}, [docId])
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === "dashboard") void setThreadIdUrl(null)
|
||||
}, [viewMode, setThreadIdUrl])
|
||||
|
||||
// Resolve document from cache when loading with ?doc=<id> (deep link / refresh)
|
||||
useEffect(() => {
|
||||
if (!docId || selectedDocument) return
|
||||
|
|
@ -177,6 +205,8 @@ export default function NewPage() {
|
|||
|
||||
const resetDraft = useQuickNoteDraftReset(selectedProject)
|
||||
const { draft: quickNoteDraft } = useQuickNoteDraft(selectedProject || "")
|
||||
const quickNoteDraftRef = useRef(quickNoteDraft)
|
||||
quickNoteDraftRef.current = quickNoteDraft
|
||||
|
||||
const { noteMutation, bulkDeleteMutation } = useDocumentMutations({
|
||||
onClose: () => {
|
||||
|
|
@ -247,20 +277,31 @@ export default function NewPage() {
|
|||
const HIGHLIGHTS_CACHE_NAME = "space-highlights-v1"
|
||||
const HIGHLIGHTS_MAX_AGE = 4 * 60 * 60 * 1000 // 4 hours
|
||||
|
||||
const handleResetHighlights = useCallback(async () => {
|
||||
toast.success("Refreshing daily brief…")
|
||||
try {
|
||||
await caches.delete(HIGHLIGHTS_CACHE_NAME)
|
||||
} catch {}
|
||||
setHighlightsForceAt(Date.now())
|
||||
}, [])
|
||||
|
||||
const { data: highlightsData, isLoading: isLoadingHighlights } =
|
||||
useQuery<SpaceHighlightsResponse>({
|
||||
queryKey: ["space-highlights", selectedProject],
|
||||
queryKey: ["space-highlights", selectedProject, highlightsForceAt],
|
||||
queryFn: async (): Promise<SpaceHighlightsResponse> => {
|
||||
const spaceId = selectedProject || "sm_project_default"
|
||||
const forceRefresh = highlightsForceAt > 0
|
||||
const cacheKey = `${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/space-highlights?spaceId=${spaceId}`
|
||||
|
||||
const cache = await caches.open(HIGHLIGHTS_CACHE_NAME)
|
||||
const cached = await cache.match(cacheKey)
|
||||
if (cached) {
|
||||
const age =
|
||||
Date.now() - Number(cached.headers.get("x-cached-at") || 0)
|
||||
if (age < HIGHLIGHTS_MAX_AGE) {
|
||||
return cached.json()
|
||||
if (!forceRefresh) {
|
||||
const cache = await caches.open(HIGHLIGHTS_CACHE_NAME)
|
||||
const cached = await cache.match(cacheKey)
|
||||
if (cached) {
|
||||
const age =
|
||||
Date.now() - Number(cached.headers.get("x-cached-at") || 0)
|
||||
if (age < HIGHLIGHTS_MAX_AGE) {
|
||||
return cached.json()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -276,6 +317,7 @@ export default function NewPage() {
|
|||
questionsCount: 4,
|
||||
includeHighlights: true,
|
||||
includeQuestions: true,
|
||||
forceRefresh,
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
|
@ -286,13 +328,21 @@ export default function NewPage() {
|
|||
|
||||
const data = await response.json()
|
||||
|
||||
const cacheResponse = new Response(JSON.stringify(data), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-cached-at": String(Date.now()),
|
||||
},
|
||||
})
|
||||
await cache.put(cacheKey, cacheResponse)
|
||||
// Update browser cache with fresh data (works for both normal and forced refresh)
|
||||
try {
|
||||
const freshCache = await caches.open(HIGHLIGHTS_CACHE_NAME)
|
||||
const cacheResponse = new Response(JSON.stringify(data), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-cached-at": String(Date.now()),
|
||||
},
|
||||
})
|
||||
await freshCache.put(cacheKey, cacheResponse)
|
||||
} catch {}
|
||||
|
||||
// Reset force flag after the forced fetch completes so future project-switches
|
||||
// use the normal cache path instead of always bypassing it.
|
||||
if (forceRefresh) setHighlightsForceAt(0)
|
||||
|
||||
return data
|
||||
},
|
||||
|
|
@ -300,6 +350,37 @@ export default function NewPage() {
|
|||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const { data: memoryOfDay = null } = useQuery<MemoryOfDay | null>({
|
||||
queryKey: [
|
||||
"memory-of-day",
|
||||
user?.id,
|
||||
new Date().toISOString().slice(0, 10),
|
||||
],
|
||||
queryFn: async (): Promise<MemoryOfDay | null> => {
|
||||
const cacheKey = `memory-of-day:${user?.id}:${new Date().toISOString().slice(0, 10)}`
|
||||
try {
|
||||
const stored = localStorage.getItem(cacheKey)
|
||||
if (stored) return JSON.parse(stored) as MemoryOfDay
|
||||
} catch {}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BACKEND_URL}/v3/memory-of-day`,
|
||||
{ credentials: "include" },
|
||||
)
|
||||
if (!response.ok) return null
|
||||
const data = (await response.json()) as MemoryOfDay | null
|
||||
if (data) {
|
||||
try {
|
||||
localStorage.setItem(cacheKey, JSON.stringify(data))
|
||||
} catch {}
|
||||
}
|
||||
return data
|
||||
},
|
||||
staleTime: 24 * 60 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!user,
|
||||
})
|
||||
|
||||
useHotkeys("c", () => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setAddDoc("note")
|
||||
|
|
@ -324,7 +405,7 @@ export default function NewPage() {
|
|||
const handleQuickNoteSave = useCallback(
|
||||
(content: string) => {
|
||||
if (content.trim()) {
|
||||
const hadPreviousContent = quickNoteDraft.trim().length > 0
|
||||
const hadPreviousContent = quickNoteDraftRef.current.trim().length > 0
|
||||
noteMutation.mutate(
|
||||
{ content, project: selectedProject },
|
||||
{
|
||||
|
|
@ -339,7 +420,7 @@ export default function NewPage() {
|
|||
)
|
||||
}
|
||||
},
|
||||
[selectedProject, noteMutation, quickNoteDraft],
|
||||
[selectedProject, noteMutation],
|
||||
)
|
||||
|
||||
const handleFullScreenSave = useCallback(
|
||||
|
|
@ -375,11 +456,29 @@ export default function NewPage() {
|
|||
const handleHighlightsChat = useCallback(
|
||||
(seed: string) => {
|
||||
setQueuedChatSeed(seed)
|
||||
setIsChatOpen(true)
|
||||
setQueuedChatModel(null)
|
||||
setQueuedMessageSource("highlight")
|
||||
void setViewMode("chat")
|
||||
},
|
||||
[setIsChatOpen],
|
||||
[setViewMode],
|
||||
)
|
||||
|
||||
const handleHomeChatStart = useCallback(
|
||||
(message: string, model: ModelId) => {
|
||||
setQueuedChatSeed(message)
|
||||
setQueuedChatModel(model)
|
||||
setQueuedMessageSource("home")
|
||||
void setViewMode("chat")
|
||||
},
|
||||
[setViewMode],
|
||||
)
|
||||
|
||||
const consumeQueuedChat = useCallback(() => {
|
||||
setQueuedChatSeed(null)
|
||||
setQueuedChatModel(null)
|
||||
setQueuedMessageSource("highlight")
|
||||
}, [])
|
||||
|
||||
const handleHighlightsShowRelated = useCallback(
|
||||
(query: string) => {
|
||||
analytics.searchOpened({ source: "highlight_related" })
|
||||
|
|
@ -391,16 +490,15 @@ export default function NewPage() {
|
|||
|
||||
const handleOpenIntegrations = useCallback(
|
||||
(integration?: IntegrationParamValue) => {
|
||||
setViewMode("integrations")
|
||||
if (integration) {
|
||||
setIntegration(integration)
|
||||
} else {
|
||||
setIntegration(null)
|
||||
}
|
||||
void setViewMode(integration ?? "integrations")
|
||||
},
|
||||
[setViewMode, setIntegration],
|
||||
[setViewMode],
|
||||
)
|
||||
|
||||
const handleOpenPlugins = useCallback(() => {
|
||||
void setViewMode("plugins")
|
||||
}, [setViewMode])
|
||||
|
||||
const handleAddMemory = useCallback(
|
||||
(tab: "note" | "link") => {
|
||||
analytics.addDocumentModalOpened()
|
||||
|
|
@ -409,120 +507,221 @@ export default function NewPage() {
|
|||
[setAddDoc],
|
||||
)
|
||||
|
||||
const chatOpen = isChatOpen !== null ? isChatOpen : !isMobile
|
||||
const viewportWidth = useSyncExternalStore(
|
||||
subscribeViewportWidth,
|
||||
getViewportWidth,
|
||||
() => GRADIENT_TOP_WIDTH_MAX,
|
||||
)
|
||||
const gradientTopPosition = gradientTopPositionForWidth(viewportWidth)
|
||||
|
||||
const isChatView = viewMode === "chat"
|
||||
const isGraphMode = viewMode === "graph" && !isMobile
|
||||
const isMemoriesDesktop = viewMode === "list" && !isMobile
|
||||
const isHomeDesktop = viewMode === "dashboard" && !isMobile
|
||||
const showNovaBackdrop = isGraphMode || isMemoriesDesktop || isHomeDesktop
|
||||
const isDashboardShell =
|
||||
viewMode === "dashboard" || (viewMode === "graph" && isMobile)
|
||||
|
||||
return (
|
||||
<HotkeysProvider>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-black min-h-screen",
|
||||
isGraphMode && "h-screen overflow-hidden",
|
||||
"relative flex min-h-dvh flex-col bg-[#05080D]",
|
||||
isGraphMode && "h-dvh overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<AnimatedGradientBackground
|
||||
topPosition="15%"
|
||||
animateFromBottom={false}
|
||||
/>
|
||||
{isGraphMode && (
|
||||
<div
|
||||
id="graph-dotted-grid"
|
||||
className="absolute inset-0 pointer-events-none bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.25)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_60%,transparent_100%)]"
|
||||
{showNovaBackdrop && (
|
||||
<>
|
||||
<AnimatedGradientBackground
|
||||
animateFromBottom={false}
|
||||
topPosition={gradientTopPosition}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-0 bg-[#05080D]/50"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
id="graph-dotted-grid"
|
||||
className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.25)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_60%,transparent_100%)]"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!session && viewMode === "mcp" ? (
|
||||
<PublicHeader />
|
||||
) : (
|
||||
<Header
|
||||
onAddMemory={() => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setAddDoc("note")
|
||||
}}
|
||||
onOpenSearch={() => {
|
||||
analytics.searchOpened({ source: "header" })
|
||||
setIsSearchOpen(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Header
|
||||
onAddMemory={() => {
|
||||
analytics.addDocumentModalOpened()
|
||||
setAddDoc("note")
|
||||
}}
|
||||
onOpenChat={() => setIsChatOpen(true)}
|
||||
onOpenSearch={() => {
|
||||
analytics.searchOpened({ source: "header" })
|
||||
setIsSearchOpen(true)
|
||||
}}
|
||||
/>
|
||||
<main
|
||||
key={`main-container-${chatOpen}-${viewMode}`}
|
||||
className={cn(
|
||||
"z-10 relative",
|
||||
isGraphMode && "h-[calc(100vh-86px)] overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div className={cn("relative z-10 flex flex-col md:flex-row h-full")}>
|
||||
<ErrorBoundary fallback={<ViewErrorFallback />}>
|
||||
{viewMode === "integrations" ? (
|
||||
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
|
||||
<IntegrationsView />
|
||||
</div>
|
||||
) : viewMode === "graph" && !isMobile ? (
|
||||
<div className="flex-1">
|
||||
<GraphLayoutView isChatOpen={chatOpen} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 p-4 md:p-6 md:pr-0 pt-2!">
|
||||
<MemoriesGrid
|
||||
isChatOpen={chatOpen}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
isSelectionMode={isSelectionMode}
|
||||
selectedDocumentIds={selectedDocumentIds}
|
||||
onEnterSelectionMode={handleEnterSelectionMode}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onClearSelection={handleClearSelection}
|
||||
onSelectAllVisible={handleSelectAllVisible}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
isBulkDeleting={bulkDeleteMutation.isPending}
|
||||
quickNoteProps={{
|
||||
onSave: handleQuickNoteSave,
|
||||
onMaximize: handleMaximize,
|
||||
isSaving: noteMutation.isPending,
|
||||
}}
|
||||
highlightsProps={{
|
||||
items: highlightsData?.highlights || [],
|
||||
onChat: handleHighlightsChat,
|
||||
onShowRelated: handleHighlightsShowRelated,
|
||||
isLoading: isLoadingHighlights,
|
||||
}}
|
||||
emptyStateProps={
|
||||
isNovaContext
|
||||
? {
|
||||
onAddMemory: handleAddMemory,
|
||||
onOpenIntegrations: handleOpenIntegrations,
|
||||
isAllSpaces: isNovaSpaces,
|
||||
spaceName: emptyStateSpaceName,
|
||||
onSwitchToAllSpaces: isNovaSpaces
|
||||
? undefined
|
||||
: handleSwitchToAllSpacesFromEmptyState,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.main
|
||||
key={`main-container-${viewMode}`}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22, ease: [0.4, 0, 0.2, 1] }}
|
||||
className={cn(
|
||||
"relative z-10 flex min-h-0 flex-1 flex-col",
|
||||
(isGraphMode || isChatView) && "overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex min-h-0 flex-1 flex-col md:flex-row",
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
<div className="hidden md:block md:sticky md:top-0 md:h-screen">
|
||||
<AnimatePresence mode="popLayout">
|
||||
<ErrorBoundary>
|
||||
<ChatSidebar
|
||||
isChatOpen={chatOpen}
|
||||
setIsChatOpen={(open) => setIsChatOpen(open)}
|
||||
queuedMessage={queuedChatSeed}
|
||||
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
|
||||
emptyStateSuggestions={highlightsData?.questions}
|
||||
>
|
||||
<ErrorBoundary fallback={<ViewErrorFallback />}>
|
||||
{isChatView ? (
|
||||
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col md:self-stretch">
|
||||
<ChatSidebar
|
||||
layout="page"
|
||||
isChatOpen
|
||||
setIsChatOpen={(open) => {
|
||||
if (!open) void setViewMode("dashboard")
|
||||
}}
|
||||
queuedMessage={queuedChatSeed}
|
||||
onConsumeQueuedMessage={consumeQueuedChat}
|
||||
queuedMessageSource={queuedMessageSource}
|
||||
initialSelectedModel={queuedChatModel}
|
||||
emptyStateSuggestions={highlightsData?.questions}
|
||||
/>
|
||||
</div>
|
||||
) : viewMode === "integrations" ? (
|
||||
<div className="min-h-0 min-w-0 flex-1 p-4 pt-2! md:p-6 md:pr-0">
|
||||
<IntegrationsView />
|
||||
</div>
|
||||
) : viewMode === "mcp" ? (
|
||||
<MCPDetailView
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</AnimatePresence>
|
||||
) : viewMode === "plugins" ? (
|
||||
<DetailWrapper
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
>
|
||||
<PluginsDetail />
|
||||
</DetailWrapper>
|
||||
) : viewMode === "chrome" ? (
|
||||
<DetailWrapper
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
>
|
||||
<ChromeDetail />
|
||||
</DetailWrapper>
|
||||
) : viewMode === "shortcuts" ? (
|
||||
<DetailWrapper
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
>
|
||||
<ShortcutsDetail />
|
||||
</DetailWrapper>
|
||||
) : viewMode === "raycast" ? (
|
||||
<DetailWrapper
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
>
|
||||
<RaycastDetail />
|
||||
</DetailWrapper>
|
||||
) : viewMode === "import" ? (
|
||||
<XBookmarksDetailView
|
||||
onBack={() => void setViewMode("integrations")}
|
||||
/>
|
||||
) : viewMode === "graph" && !isMobile ? (
|
||||
<div className="min-h-0 min-w-0 flex-1">
|
||||
<GraphLayoutView />
|
||||
</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={
|
||||
viewMode === "graph" && isMobile ? (
|
||||
<div
|
||||
id="graph-mobile-notice"
|
||||
className="rounded-lg border border-[#2261CA33] bg-[#041127] px-3 py-2.5 text-sm text-[#8B8B8B]"
|
||||
>
|
||||
<span className="font-medium text-white">
|
||||
Graph view is available on desktop.
|
||||
</span>{" "}
|
||||
Use a larger screen for the full graph, or keep
|
||||
working from this home view.
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
highlights={highlightsData?.highlights ?? []}
|
||||
isLoadingHighlights={isLoadingHighlights}
|
||||
onAddMemory={handleAddMemory}
|
||||
onOpenSearch={() => {
|
||||
analytics.searchOpened({ source: "header" })
|
||||
setIsSearchOpen(true)
|
||||
}}
|
||||
onOpenIntegrations={handleOpenIntegrations}
|
||||
onOpenPlugins={handleOpenPlugins}
|
||||
onNavigateToMemories={() => void setViewMode("list")}
|
||||
onNavigateToGraph={() => void setViewMode("graph")}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onHighlightsChat={handleHighlightsChat}
|
||||
onHighlightsShowRelated={handleHighlightsShowRelated}
|
||||
onResetHighlights={handleResetHighlights}
|
||||
memoryOfDay={memoryOfDay}
|
||||
/>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</motion.main>
|
||||
</AnimatePresence>
|
||||
|
||||
{isDashboardShell && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none fixed inset-x-0 z-30 bg-gradient-to-t from-black via-black/40 to-transparent pt-12",
|
||||
isMobile ? "bottom-[4.5rem]" : "bottom-0",
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-auto">
|
||||
<HomeChatComposer onStartChat={handleHomeChatStart} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{isMobile && (
|
||||
<ChatSidebar
|
||||
isChatOpen={chatOpen}
|
||||
setIsChatOpen={(open) => setIsChatOpen(open)}
|
||||
queuedMessage={queuedChatSeed}
|
||||
onConsumeQueuedMessage={() => setQueuedChatSeed(null)}
|
||||
emptyStateSuggestions={highlightsData?.questions}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddDocumentModal
|
||||
|
|
@ -536,7 +735,6 @@ export default function NewPage() {
|
|||
if (!open) setSearchPrefill("")
|
||||
}}
|
||||
projectId={selectedProject}
|
||||
novaContainerTags={isNovaSpaces ? novaContainerTags : undefined}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onAddMemory={() => {
|
||||
analytics.addDocumentModalOpened()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { motion } from "motion/react"
|
|||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { dmSansClassName, dmSans125ClassName } from "@/lib/fonts"
|
||||
import Account from "@/components/settings/account"
|
||||
import Integrations from "@/components/settings/integrations"
|
||||
import ConnectionsMCP from "@/components/settings/connections-mcp"
|
||||
|
|
@ -16,7 +16,11 @@ import { useRouter } from "next/navigation"
|
|||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { Sun } from "lucide-react"
|
||||
import { LogOut, RotateCcw, Trash2, Sun, LoaderIcon } from "lucide-react"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { Dialog, DialogContent, DialogClose } from "@ui/components/dialog"
|
||||
import { useResetOrganization } from "@/hooks/use-reset-organization"
|
||||
import { useDeleteUserAccount } from "@/hooks/use-account-settings"
|
||||
|
||||
const TABS = ["account", "integrations", "connections", "support"] as const
|
||||
type SettingsTab = (typeof TABS)[number]
|
||||
|
|
@ -28,6 +32,14 @@ type NavItem = {
|
|||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
type DangerItem = {
|
||||
id: "logout" | "reset" | "delete"
|
||||
label: string
|
||||
description: string
|
||||
icon: React.ReactNode
|
||||
color: "neutral" | "amber" | "red"
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
id: "account",
|
||||
|
|
@ -103,6 +115,51 @@ const NAV_ITEMS: NavItem[] = [
|
|||
},
|
||||
]
|
||||
|
||||
const DANGER_ITEMS: DangerItem[] = [
|
||||
{
|
||||
id: "logout",
|
||||
label: "Log out",
|
||||
description: "Sign out of your account on this device",
|
||||
icon: <LogOut className="size-5" />,
|
||||
color: "neutral",
|
||||
},
|
||||
{
|
||||
id: "reset",
|
||||
label: "Reset data",
|
||||
description: "Erase all memories, connections and spaces",
|
||||
icon: <RotateCcw className="size-5" />,
|
||||
color: "amber",
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete account",
|
||||
description: "Permanently delete your account and all data",
|
||||
icon: <Trash2 className="size-5" />,
|
||||
color: "red",
|
||||
},
|
||||
]
|
||||
|
||||
const DANGER_COLORS: Record<
|
||||
DangerItem["color"],
|
||||
{ idle: string; hover: string; icon: string }
|
||||
> = {
|
||||
neutral: {
|
||||
idle: "text-white/50",
|
||||
hover: "hover:text-white",
|
||||
icon: "text-white/40",
|
||||
},
|
||||
amber: {
|
||||
idle: "text-[#7A6030]",
|
||||
hover: "hover:text-[#C7991B]",
|
||||
icon: "text-[#7A6030]",
|
||||
},
|
||||
red: {
|
||||
idle: "text-[#6B2A2A]",
|
||||
hover: "hover:text-[#C73B1B]",
|
||||
icon: "text-[#6B2A2A]",
|
||||
},
|
||||
}
|
||||
|
||||
function parseHashToTab(hash: string): SettingsTab {
|
||||
const cleaned = hash.replace("#", "").toLowerCase()
|
||||
return TABS.includes(cleaned as SettingsTab)
|
||||
|
|
@ -133,13 +190,40 @@ export function UserSupermemory({ name }: { name: string }) {
|
|||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const { user, org } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>("account")
|
||||
const hasInitialized = useRef(false)
|
||||
const router = useRouter()
|
||||
const isMobile = useIsMobile()
|
||||
const localStorageUsername = useLocalStorageUsername()
|
||||
|
||||
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false)
|
||||
const [resetConfirmation, setResetConfirmation] = useState("")
|
||||
const resetOrganization = useResetOrganization()
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [deleteEmailConfirm, setDeleteEmailConfirm] = useState("")
|
||||
const deleteUserAccount = useDeleteUserAccount()
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authClient.signOut()
|
||||
router.push("/login")
|
||||
}
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (deleteEmailConfirm !== user?.email) return
|
||||
deleteUserAccount.mutate(
|
||||
{ confirmation: deleteEmailConfirm },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsDeleteDialogOpen(false)
|
||||
setDeleteEmailConfirm("")
|
||||
router.push("/login")
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInitialized.current) return
|
||||
hasInitialized.current = true
|
||||
|
|
@ -277,6 +361,55 @@ export default function SettingsPage() {
|
|||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Divider */}
|
||||
{!isMobile && <div className="my-1 h-px bg-[#0F1621]" />}
|
||||
|
||||
{DANGER_ITEMS.map((item) => {
|
||||
const colors = DANGER_COLORS[item.color]
|
||||
const handleClick = () => {
|
||||
if (item.id === "logout") handleLogout()
|
||||
else if (item.id === "reset") setIsResetDialogOpen(true)
|
||||
else if (item.id === "delete") setIsDeleteDialogOpen(true)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"rounded-xl transition-colors flex items-start gap-3 shrink-0 group",
|
||||
isMobile ? "px-3 py-2 text-sm" : "text-left p-4",
|
||||
"hover:bg-[#14161A] hover:shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
colors.idle,
|
||||
colors.hover,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
!isMobile && "mt-0.5",
|
||||
colors.icon,
|
||||
`group-hover:${colors.hover.replace("hover:", "")}`,
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
{isMobile ? (
|
||||
<span className="font-medium whitespace-nowrap">
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="text-sm opacity-60">
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-4 md:overflow-y-auto md:max-w-2xl [scrollbar-gutter:stable] md:pr-[17px]">
|
||||
|
|
@ -303,6 +436,169 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Reset data dialog */}
|
||||
{(() => {
|
||||
const confirmText = org?.name || user?.name || ""
|
||||
return (
|
||||
<Dialog
|
||||
open={isResetDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsResetDialogOpen(open)
|
||||
if (!open) setResetConfirmation("")
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div
|
||||
className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
|
||||
Reset all data?
|
||||
</h2>
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
This permanently removes:
|
||||
</p>
|
||||
<ul className="text-sm text-[#8B8B8B] list-disc pl-5 space-y-0.5 mt-1">
|
||||
<li>All documents and memories</li>
|
||||
<li>All connections (Google Drive, Notion, etc.)</li>
|
||||
<li>All custom spaces (default space stays)</li>
|
||||
<li>Organization settings and filters</li>
|
||||
</ul>
|
||||
<p className="text-sm text-[#8B8B8B] mt-1">
|
||||
Your account and billing plan stay intact.{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
This cannot be undone.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Type{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
{confirmText || "your name"}
|
||||
</strong>{" "}
|
||||
to confirm:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={resetConfirmation}
|
||||
onChange={(e) => setResetConfirmation(e.target.value)}
|
||||
placeholder={confirmText || "Your name"}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C7991B]/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!confirmText ||
|
||||
resetConfirmation !== confirmText ||
|
||||
resetOrganization.isPending
|
||||
}
|
||||
onClick={() =>
|
||||
resetOrganization.mutate(
|
||||
{ confirmation: confirmText },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsResetDialogOpen(false)
|
||||
setResetConfirmation("")
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#1A1200] text-[#C7991B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
{resetOrganization.isPending ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-[15px]" />
|
||||
)}
|
||||
{resetOrganization.isPending
|
||||
? "Resetting…"
|
||||
: "Reset organization"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Delete account dialog */}
|
||||
<Dialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeleteDialogOpen(open)
|
||||
if (!open) setDeleteEmailConfirm("")
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<div className={cn("flex flex-col gap-5 p-1", dmSans125ClassName())}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-[18px] font-semibold text-[#FAFAFA]">
|
||||
Delete your account?
|
||||
</h2>
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Permanently deletes all your data and cancels any active
|
||||
subscriptions.{" "}
|
||||
<strong className="text-[#FAFAFA]">
|
||||
This cannot be undone.
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-[#8B8B8B]">
|
||||
Type your email{" "}
|
||||
<strong className="text-[#FAFAFA]">{user?.email}</strong> to
|
||||
confirm:
|
||||
</p>
|
||||
<input
|
||||
type="email"
|
||||
value={deleteEmailConfirm}
|
||||
onChange={(e) => setDeleteEmailConfirm(e.target.value)}
|
||||
placeholder={user?.email ?? "your@email.com"}
|
||||
className="w-full rounded-xl border border-[#2A2D35] bg-[#0D0F14] px-4 py-2.5 text-sm text-white placeholder:text-[#525D6E] focus:outline-none focus:border-[#C73B1B]/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<DialogClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-2 rounded-full border border-[#2A2D35] text-sm text-[#8B8B8B] hover:text-white hover:border-[#3A3D45] transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
deleteEmailConfirm !== user?.email ||
|
||||
deleteUserAccount.isPending
|
||||
}
|
||||
onClick={handleDeleteAccount}
|
||||
className="relative flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium cursor-pointer transition-opacity bg-[#290F0A] text-[#C73B1B] disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90"
|
||||
>
|
||||
{deleteUserAccount.isPending ? (
|
||||
<LoaderIcon className="size-[15px] animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-[15px]" />
|
||||
)}
|
||||
{deleteUserAccount.isPending ? "Deleting…" : "Delete account"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,18 +204,12 @@ export default function LoginPage() {
|
|||
email_domain: email.split("@")[1] || "unknown",
|
||||
})
|
||||
|
||||
try {
|
||||
await signIn.magicLink({
|
||||
callbackURL: getCallbackURL(),
|
||||
email,
|
||||
})
|
||||
setSubmittedEmail(email)
|
||||
setPendingLoginMethod("magic_link")
|
||||
// Track successful magic link send
|
||||
posthog.capture("login_magic_link_sent", {
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
})
|
||||
} catch (error) {
|
||||
const { error } = await signIn.magicLink({
|
||||
callbackURL: getCallbackURL(),
|
||||
email,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
|
||||
// Track login failure
|
||||
|
|
@ -232,6 +226,12 @@ export default function LoginPage() {
|
|||
return
|
||||
}
|
||||
|
||||
setSubmittedEmail(email)
|
||||
setPendingLoginMethod("magic_link")
|
||||
posthog.capture("login_magic_link_sent", {
|
||||
email_domain: email.split("@")[1] || "unknown",
|
||||
})
|
||||
|
||||
setIsLoading(false)
|
||||
setIsLoadingEmail(false)
|
||||
}
|
||||
|
|
@ -355,6 +355,9 @@ export default function LoginPage() {
|
|||
callbackURL: getCallbackURL(),
|
||||
provider: "google",
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err))
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
|
|
@ -419,6 +422,9 @@ export default function LoginPage() {
|
|||
callbackURL: getCallbackURL(),
|
||||
provider: "github",
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(getErrorMessage(err))
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -349,7 +349,7 @@ function AuthConnectContent() {
|
|||
</button>
|
||||
|
||||
<a
|
||||
href="https://app.supermemory.ai/?plugins=true"
|
||||
href="https://app.supermemory.ai/?view=plugins"
|
||||
className={dmSans125ClassName(
|
||||
"text-[12px] text-[#737373] hover:text-[#FAFAFA] transition-colors",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,15 +6,38 @@ import type { ConnectionResponseSchema } from "@repo/validation/api"
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { Check, Loader, Trash2, Zap } from "lucide-react"
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
FolderOpen,
|
||||
Loader,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import type { z } from "zod"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import type { Project } from "@lib/types"
|
||||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import { RemoveConnectionDialog } from "@/components/remove-connection-dialog"
|
||||
|
||||
type GDriveSyncScope = "scoped" | "full"
|
||||
|
||||
const GDRIVE_SCOPE_LABELS: Record<GDriveSyncScope, string> = {
|
||||
scoped: "Files & Folders",
|
||||
full: "Whole Drive",
|
||||
}
|
||||
|
||||
type Connection = z.infer<typeof ConnectionResponseSchema>
|
||||
|
||||
type ConnectorProvider = "google-drive" | "notion" | "onedrive"
|
||||
|
|
@ -24,26 +47,178 @@ const CONNECTORS: Record<
|
|||
{
|
||||
title: string
|
||||
description: string
|
||||
documentLabel: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
> = {
|
||||
"google-drive": {
|
||||
title: "Google Drive",
|
||||
description: "Connect your Google docs, sheets and slides",
|
||||
documentLabel: "documents",
|
||||
icon: GoogleDrive,
|
||||
},
|
||||
notion: {
|
||||
title: "Notion",
|
||||
description: "Import your Notion pages and databases",
|
||||
documentLabel: "pages",
|
||||
icon: Notion,
|
||||
},
|
||||
onedrive: {
|
||||
title: "OneDrive",
|
||||
description: "Access your Microsoft Office documents",
|
||||
documentLabel: "documents",
|
||||
icon: OneDrive,
|
||||
},
|
||||
} as const
|
||||
|
||||
function formatRelativeTime(date: string | null | undefined): string {
|
||||
if (!date) return "Never"
|
||||
const d = new Date(date)
|
||||
const diffMs = Date.now() - d.getTime()
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
if (diffHours < 1) return "Just now"
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays === 1) return "Yesterday"
|
||||
if (diffDays < 7) return `${diffDays} days ago`
|
||||
return d.toLocaleDateString()
|
||||
}
|
||||
|
||||
function ConnectionRow({
|
||||
connection,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
projects,
|
||||
}: {
|
||||
connection: Connection
|
||||
onDelete: () => void
|
||||
isDeleting: boolean
|
||||
projects: Project[]
|
||||
}) {
|
||||
const config = CONNECTORS[connection.provider as ConnectorProvider]
|
||||
if (!config) return null
|
||||
|
||||
const Icon = config.icon
|
||||
const isConnected =
|
||||
!connection.expiresAt || new Date(connection.expiresAt) > new Date()
|
||||
|
||||
const getProjectName = (tag: string): string => {
|
||||
if (tag === DEFAULT_PROJECT_ID) return "Default"
|
||||
return (
|
||||
projects.find((p) => p.containerTag === tag)?.name ??
|
||||
tag.replace(/^sm_project_/, "").replace(/_/g, " ")
|
||||
)
|
||||
}
|
||||
|
||||
const documentCount = (connection.metadata?.documentCount as number) ?? 0
|
||||
const containerTags = (
|
||||
connection as Connection & { containerTags?: string[] }
|
||||
).containerTags
|
||||
const projectName = containerTags?.[0]
|
||||
? getProjectName(containerTags[0])
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] px-4 py-3",
|
||||
"shadow-[0px_1px_2px_0px_rgba(0,43,87,0.1)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon className="size-6 shrink-0" />
|
||||
<div className="flex-1 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{config.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"size-[7px] rounded-full",
|
||||
isConnected ? "bg-[#00AC3F]" : "bg-[#737373]",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px]",
|
||||
isConnected ? "text-[#00AC3F]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{isConnected ? "Connected" : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}
|
||||
>
|
||||
{connection.email || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="text-[#737373] hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="size-[22px]" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-2.5 border-t border-[rgba(82,89,102,0.12)]">
|
||||
<div className="flex items-center gap-2 flex-1 flex-wrap">
|
||||
{projectName && (
|
||||
<div className="flex items-center gap-1">
|
||||
<FolderOpen className="size-3 text-[#4B5563]" />
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] text-[#737373] capitalize",
|
||||
)}
|
||||
>
|
||||
{projectName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="size-3 text-[#4B5563]" />
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{formatRelativeTime(connection.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 shrink-0">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] font-semibold text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{documentCount}
|
||||
</span>
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[12px] text-[#737373]")}
|
||||
>
|
||||
{config.documentLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ConnectContentProps {
|
||||
selectedProject: string
|
||||
}
|
||||
|
|
@ -54,12 +229,17 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
const isProUser = hasActivePlan(autumn.customer?.products, "api_pro")
|
||||
const [connectingProvider, setConnectingProvider] =
|
||||
useState<ConnectorProvider | null>(null)
|
||||
const [gdriveSyncScope, setGdriveSyncScope] =
|
||||
useState<GDriveSyncScope>("scoped")
|
||||
const [isUpgrading, setIsUpgrading] = useState(false)
|
||||
const [removeDialog, setRemoveDialog] = useState<{
|
||||
open: boolean
|
||||
connection: Connection | null
|
||||
}>({ open: false, connection: null })
|
||||
|
||||
const projects = (queryClient.getQueryData<Project[]>(["projects"]) ||
|
||||
[]) as Project[]
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
setIsUpgrading(true)
|
||||
try {
|
||||
|
|
@ -114,7 +294,13 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
|
||||
// Connect mutation
|
||||
const addConnectionMutation = useMutation({
|
||||
mutationFn: async (provider: ConnectorProvider) => {
|
||||
mutationFn: async ({
|
||||
provider,
|
||||
syncScope,
|
||||
}: {
|
||||
provider: ConnectorProvider
|
||||
syncScope?: GDriveSyncScope
|
||||
}) => {
|
||||
if (!canAddConnection && !isProUser) {
|
||||
throw new Error(
|
||||
"Free plan doesn't include connections. Upgrade to Pro for unlimited connections.",
|
||||
|
|
@ -126,6 +312,10 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
body: {
|
||||
redirectUrl: window.location.href,
|
||||
containerTags: [selectedProject],
|
||||
metadata:
|
||||
provider === "google-drive" && syncScope === "full"
|
||||
? { syncScope: "full" }
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -165,7 +355,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
onSuccess: (_data, variables) => {
|
||||
toast.success(
|
||||
variables.deleteDocuments
|
||||
? "Connection removal has started. supermemory will permanently delete all documents related to the connection in the next few minutes."
|
||||
? "Connection removal has started. Documents will be permanently deleted in the next few minutes."
|
||||
: "Connection removed. Your memories have been kept.",
|
||||
)
|
||||
setRemoveDialog({ open: false, connection: null })
|
||||
|
|
@ -180,76 +370,38 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
|
||||
const handleConnect = (provider: ConnectorProvider) => {
|
||||
setConnectingProvider(provider)
|
||||
addConnectionMutation.mutate(provider)
|
||||
}
|
||||
|
||||
const handleDisconnect = (connection: Connection) => {
|
||||
setRemoveDialog({ open: true, connection })
|
||||
addConnectionMutation.mutate({
|
||||
provider,
|
||||
syncScope: provider === "google-drive" ? gdriveSyncScope : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const hasConnections = connections.length > 0
|
||||
|
||||
// Helper function to format connection subtext safely
|
||||
const getConnectionSubtext = (connection: Connection): string => {
|
||||
if (connection.email) {
|
||||
return connection.email
|
||||
}
|
||||
|
||||
return "Connected"
|
||||
}
|
||||
const isAnyConnecting =
|
||||
connectingProvider !== null || addConnectionMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col pt-4 space-y-4">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<p className="text-[16px] font-semibold">Supermemory Connections</p>
|
||||
<span className="bg-[#4BA0FA] text-black text-[12px] font-bold px-1 py-[3px] rounded-[3px]">
|
||||
PRO
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connector section - conditional layout based on hasConnections */}
|
||||
{hasConnections ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{Object.entries(CONNECTORS).map(([provider, config]) => {
|
||||
const Icon = config.icon
|
||||
const isConnecting =
|
||||
connectingProvider === provider ||
|
||||
(addConnectionMutation.isPending &&
|
||||
addConnectionMutation.variables === provider)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
onClick={() => handleConnect(provider as ConnectorProvider)}
|
||||
disabled={
|
||||
!isProUser || isConnecting || addConnectionMutation.isPending
|
||||
}
|
||||
className="bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] px-4 py-3 flex items-center justify-center gap-2 hover:bg-[#1B1F24] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Icon className="w-6 h-6 text-[#737373]" />
|
||||
<p className="text-[14px] font-medium text-center">
|
||||
{config.title}
|
||||
</p>
|
||||
{isConnecting && (
|
||||
<Loader className="h-4 w-4 animate-spin text-[#4BA0FA]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{/* Top header — only when empty; once connected, the Add CTA moves into the list header below */}
|
||||
{!hasConnections && (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<p className="text-[16px] font-semibold">Add a connection</p>
|
||||
<span className="bg-[#4BA0FA] text-black text-[12px] font-bold px-1 py-[3px] rounded-[3px]">
|
||||
PRO
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{/* Provider rows — only on empty state. Each is a labelled, descriptive CTA. */}
|
||||
{!hasConnections && (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(CONNECTORS).map(([provider, config]) => {
|
||||
const Icon = config.icon
|
||||
const connection = connections.find(
|
||||
(conn) => conn.provider === provider,
|
||||
)
|
||||
const isConnected = !!connection
|
||||
const isConnecting =
|
||||
connectingProvider === provider ||
|
||||
(addConnectionMutation.isPending &&
|
||||
addConnectionMutation.variables === provider)
|
||||
addConnectionMutation.variables?.provider === provider)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -259,32 +411,65 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
<div className="flex items-center gap-3 flex-1">
|
||||
<Icon className="w-6 h-6 text-[#737373]" />
|
||||
<div className="space-y-[6px] flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[16px] font-medium">{config.title}</p>
|
||||
{isConnected && (
|
||||
<span className="text-[12px] text-[#4BA0FA] font-medium">
|
||||
{connection.metadata?.syncInProgress
|
||||
? "Syncing..."
|
||||
: "Connected"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[16px] font-medium">{config.title}</p>
|
||||
<p className="text-[16px] text-[#737373]">
|
||||
{config.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDisconnect(connection)}
|
||||
disabled={deleteConnectionMutation.isPending}
|
||||
className="text-[#737373] hover:text-white hover:bg-[#1B1F24] h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{provider === "google-drive" ? (
|
||||
<div className="flex items-center rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleConnect("google-drive")}
|
||||
disabled={
|
||||
!isProUser ||
|
||||
isConnecting ||
|
||||
addConnectionMutation.isPending
|
||||
}
|
||||
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 text-[14px] font-medium px-3 h-8 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</button>
|
||||
<div className="w-px h-5 bg-black/20" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 px-1.5 h-8 flex items-center transition-colors"
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{(
|
||||
Object.entries(GDRIVE_SCOPE_LABELS) as [
|
||||
GDriveSyncScope,
|
||||
string,
|
||||
][]
|
||||
).map(([scope, label]) => (
|
||||
<DropdownMenuItem
|
||||
key={scope}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setGdriveSyncScope(scope)
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
{label}
|
||||
{gdriveSyncScope === scope && (
|
||||
<Check className="w-3 h-3 text-[#4BA0FA]" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() =>
|
||||
|
|
@ -311,56 +496,150 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Connected list panel - only when hasConnections */}
|
||||
{/* Connected list - rich rows with status / project / last sync / doc count */}
|
||||
{hasConnections && (
|
||||
<div className="bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] shadow-inside-out px-4 py-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[16px] font-semibold">
|
||||
Connected to Supermemory
|
||||
</p>
|
||||
{connectionsLimit > 0 && (
|
||||
<p className="text-[12px] text-[#737373]">
|
||||
{connections.length}/{connectionsLimit} connections used
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{connections.map((connection) => {
|
||||
const config =
|
||||
CONNECTORS[connection.provider as ConnectorProvider]
|
||||
if (!config) return null
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3 px-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[16px] font-semibold">
|
||||
Connected to Supermemory
|
||||
</p>
|
||||
<span className="bg-[#4BA0FA] text-black text-[10px] font-bold px-1 py-[2px] rounded-[3px]">
|
||||
PRO
|
||||
</span>
|
||||
</div>
|
||||
{connectionsLimit > 0 && (
|
||||
<p className="text-[12px] text-[#737373]">
|
||||
{connections.length}/{connectionsLimit} connections used
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
const Icon = config.icon
|
||||
const subtext = getConnectionSubtext(connection)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={connection.id}
|
||||
className="flex items-center justify-between gap-3"
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isProUser || isAnyConnecting}
|
||||
className="flex items-center gap-1.5 bg-[#4BA0FA] text-black hover:bg-[#4BA0FA]/90 disabled:opacity-50 disabled:cursor-not-allowed text-[13px] font-medium rounded-full h-8 px-3 transition-colors shrink-0"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Icon className="w-6 h-6 text-[#737373]" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[16px] font-medium truncate">
|
||||
{config.title}
|
||||
</p>
|
||||
<p className="text-[14px] text-[#737373] truncate">
|
||||
{subtext}
|
||||
</p>
|
||||
</div>
|
||||
{isAnyConnecting ? (
|
||||
<Loader className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<span>+ Add a connection</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={cn(
|
||||
"min-w-[260px] p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="px-3 py-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[#737373] font-medium">
|
||||
Choose a service
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setConnectingProvider("google-drive")
|
||||
addConnectionMutation.mutate({
|
||||
provider: "google-drive",
|
||||
syncScope: "scoped",
|
||||
})
|
||||
}}
|
||||
className="flex items-start gap-2.5 px-3 py-2.5 rounded-md cursor-pointer text-white opacity-60 hover:opacity-100 hover:bg-[#293952]/40 focus:bg-[#293952]/40 focus:opacity-100"
|
||||
>
|
||||
<GoogleDrive className="size-5 mt-0.5 shrink-0" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-[14px] font-medium text-[#FAFAFA] leading-tight">
|
||||
Google Drive
|
||||
</span>
|
||||
<span className="text-[11px] text-[#737373] leading-tight">
|
||||
Pick specific files & folders
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setConnectingProvider("google-drive")
|
||||
addConnectionMutation.mutate({
|
||||
provider: "google-drive",
|
||||
syncScope: "full",
|
||||
})
|
||||
}}
|
||||
className="flex items-start gap-2.5 px-3 py-2.5 rounded-md cursor-pointer text-white opacity-60 hover:opacity-100 hover:bg-[#293952]/40 focus:bg-[#293952]/40 focus:opacity-100"
|
||||
>
|
||||
<GoogleDrive className="size-5 mt-0.5 shrink-0" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-[14px] font-medium text-[#FAFAFA] leading-tight">
|
||||
Google Drive
|
||||
</span>
|
||||
<span className="text-[11px] text-[#737373] leading-tight">
|
||||
Sync entire drive
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setConnectingProvider("notion")
|
||||
addConnectionMutation.mutate({ provider: "notion" })
|
||||
}}
|
||||
className="flex items-start gap-2.5 px-3 py-2.5 rounded-md cursor-pointer text-white opacity-60 hover:opacity-100 hover:bg-[#293952]/40 focus:bg-[#293952]/40 focus:opacity-100"
|
||||
>
|
||||
<Notion className="size-5 mt-0.5 shrink-0" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-[14px] font-medium text-[#FAFAFA] leading-tight">
|
||||
Notion
|
||||
</span>
|
||||
<span className="text-[11px] text-[#737373] leading-tight">
|
||||
Pages and databases
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setConnectingProvider("onedrive")
|
||||
addConnectionMutation.mutate({ provider: "onedrive" })
|
||||
}}
|
||||
className="flex items-start gap-2.5 px-3 py-2.5 rounded-md cursor-pointer text-white opacity-60 hover:opacity-100 hover:bg-[#293952]/40 focus:bg-[#293952]/40 focus:opacity-100"
|
||||
>
|
||||
<OneDrive className="size-5 mt-0.5 shrink-0" />
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-[14px] font-medium text-[#FAFAFA] leading-tight">
|
||||
OneDrive
|
||||
</span>
|
||||
<span className="text-[11px] text-[#737373] leading-tight">
|
||||
Office documents
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDisconnect(connection)}
|
||||
disabled={deleteConnectionMutation.isPending}
|
||||
className="text-[#737373] hover:text-white hover:bg-[#1B1F24] h-8 w-8 p-0 shrink-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{connections.map((connection) => (
|
||||
<ConnectionRow
|
||||
key={connection.id}
|
||||
connection={connection}
|
||||
projects={projects}
|
||||
onDelete={() => setRemoveDialog({ open: true, connection })}
|
||||
isDeleting={deleteConnectionMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -429,6 +708,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RemoveConnectionDialog
|
||||
open={removeDialog.open}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
|||
|
|
@ -8,55 +8,51 @@ export function AnimatedGradientBackground({
|
|||
animateFromBottom?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-0 overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-0.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
|
||||
style={{ top: animateFromBottom ? undefined : topPosition }}
|
||||
initial={{ y: "100%" }}
|
||||
animate={{
|
||||
y: 0,
|
||||
opacity: animateFromBottom ? 0 : [1, 0, 1],
|
||||
top: animateFromBottom ? "0%" : topPosition,
|
||||
}}
|
||||
transition={{
|
||||
y: { duration: 0.75, ease: "easeOut" },
|
||||
opacity: animateFromBottom
|
||||
? { duration: 2, ease: "easeOut" }
|
||||
initial={{ opacity: 0 }}
|
||||
animate={
|
||||
animateFromBottom
|
||||
? { opacity: 1 }
|
||||
: { opacity: [1, 0, 1], top: topPosition }
|
||||
}
|
||||
transition={
|
||||
animateFromBottom
|
||||
? { duration: 1, ease: "easeOut" }
|
||||
: {
|
||||
duration: 8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
top: animateFromBottom
|
||||
? { duration: 0.75, ease: "easeOut" }
|
||||
: undefined,
|
||||
}}
|
||||
opacity: {
|
||||
duration: 8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/onboarding/bg-gradient-1.png')] bg-size-[150%_auto] bg-top bg-no-repeat"
|
||||
style={{ top: animateFromBottom ? undefined : topPosition }}
|
||||
initial={{ y: "100%" }}
|
||||
animate={{
|
||||
y: 0,
|
||||
opacity: animateFromBottom ? 0 : [0, 1, 0],
|
||||
top: animateFromBottom ? "0%" : topPosition,
|
||||
}}
|
||||
transition={{
|
||||
y: { duration: 0.75, ease: "easeOut" },
|
||||
opacity: animateFromBottom
|
||||
? { duration: 2, ease: "easeOut" }
|
||||
initial={{ opacity: 0 }}
|
||||
animate={
|
||||
animateFromBottom
|
||||
? { opacity: 1 }
|
||||
: { opacity: [0, 1, 0], top: topPosition }
|
||||
}
|
||||
transition={
|
||||
animateFromBottom
|
||||
? { duration: 1, ease: "easeOut", delay: 0.2 }
|
||||
: {
|
||||
duration: 8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
top: animateFromBottom
|
||||
? { duration: 0.75, ease: "easeOut" }
|
||||
: undefined,
|
||||
}}
|
||||
opacity: {
|
||||
duration: 8,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 bottom-0 bg-[url('/bg-rectangle.png')] bg-cover bg-center bg-no-repeat"
|
||||
className="absolute inset-0 bg-[url('/bg-rectangle.png')] bg-cover bg-bottom bg-no-repeat"
|
||||
transition={{ duration: 0.75, ease: "easeOut", bounce: 0 }}
|
||||
style={{
|
||||
mixBlendMode: "soft-light",
|
||||
|
|
|
|||
57
apps/web/components/chat/chat-graph-context-rail.tsx
Normal file
57
apps/web/components/chat/chat-graph-context-rail.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import type { UIMessage } from "@ai-sdk/react"
|
||||
import { MemoryGraph } from "@/components/memory-graph"
|
||||
import { AnimatedGradientBackground } from "@/components/animated-gradient-background"
|
||||
import { useProject } from "@/stores"
|
||||
import { extractHighlightDocumentIdsFromMessages } from "@/lib/chat-highlight-documents"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
|
||||
export function ChatGraphContextRail({
|
||||
messages,
|
||||
className,
|
||||
}: {
|
||||
messages: UIMessage[]
|
||||
className?: string
|
||||
}) {
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const highlightIds = useMemo(
|
||||
() => extractHighlightDocumentIdsFromMessages(messages),
|
||||
[messages],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
id="chat-graph-context-rail"
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1 flex-col bg-[#05080D] overflow-hidden",
|
||||
dmSansClassName(),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AnimatedGradientBackground animateFromBottom={false} topPosition="55%" />
|
||||
<div className="pointer-events-none absolute inset-0 z-0 bg-[#05080D]/50" />
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(circle_at_center,rgba(105,167,240,0.25)_1px,transparent_1px)] bg-size-[32px_32px] mask-[radial-gradient(ellipse_at_center,black_60%,transparent_100%)]" />
|
||||
<div className="pointer-events-none absolute top-3 left-4 z-20">
|
||||
<p className="text-xs font-medium text-white/70">Memory map</p>
|
||||
<p className="mt-0.5 max-w-[14rem] text-[10px] leading-snug text-white/35">
|
||||
{highlightIds.length > 0
|
||||
? `${highlightIds.length} memor${highlightIds.length === 1 ? "y" : "ies"} used by Nova`
|
||||
: "Memories used by Nova will be highlighted here"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-24 bg-gradient-to-r from-transparent to-[#05080D]" />
|
||||
<div className="relative z-[2] min-h-0 flex-1 pt-10">
|
||||
<MemoryGraph
|
||||
containerTags={effectiveContainerTags}
|
||||
variant="consumer"
|
||||
highlightDocumentIds={highlightIds}
|
||||
highlightsVisible={highlightIds.length > 0}
|
||||
maxNodes={160}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
apps/web/components/chat/home-chat-composer.tsx
Normal file
82
apps/web/components/chat/home-chat-composer.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import ChatInput from "./input"
|
||||
import ChatModelSelector from "./model-selector"
|
||||
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
|
||||
import { useProject } from "@/stores"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import type { ModelId } from "@/lib/models"
|
||||
|
||||
export function HomeChatComposer({
|
||||
onStartChat,
|
||||
className,
|
||||
}: {
|
||||
onStartChat: (message: string, model: ModelId) => void
|
||||
className?: string
|
||||
}) {
|
||||
const [input, setInput] = useState("")
|
||||
const [selectedModel, setSelectedModel] = useState<ModelId>("gemini-2.5-pro")
|
||||
const { selectedProject } = useProject()
|
||||
const { allProjects } = useContainerTags()
|
||||
const chatSpaceLabel = useMemo(
|
||||
() =>
|
||||
getChatSpaceDisplayLabel({
|
||||
selectedProject,
|
||||
allProjects,
|
||||
}),
|
||||
[selectedProject, allProjects],
|
||||
)
|
||||
|
||||
const send = useCallback(() => {
|
||||
const t = input.trim()
|
||||
if (!t) return
|
||||
onStartChat(t, selectedModel)
|
||||
setInput("")
|
||||
}, [input, onStartChat, selectedModel])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<div className="mx-auto w-full max-w-[720px] px-4 pt-1 pb-3 md:pb-4">
|
||||
<ChatInput
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onSend={send}
|
||||
onStop={() => {}}
|
||||
onKeyDown={handleKeyDown}
|
||||
isResponding={false}
|
||||
showStatusStrip={false}
|
||||
stackedToolbar={
|
||||
<>
|
||||
<ChatModelSelector
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={setSelectedModel}
|
||||
minimal
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex max-w-[min(160px,35vw)] min-w-0 shrink items-center rounded-full bg-fg-primary/5 px-3 py-1.5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
title={chatSpaceLabel}
|
||||
>
|
||||
<span className="truncate text-sm text-fg-primary">
|
||||
{chatSpaceLabel}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -14,10 +14,10 @@ export function SendButton({
|
|||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"bg-[#000000] border-[#161F2C] border p-2 rounded-lg shrink-0 transition-opacity",
|
||||
"bg-surface-card border-surface-border border p-2 rounded-lg shrink-0 transition-opacity",
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-[#161F2C]",
|
||||
: "cursor-pointer hover:bg-surface-hover",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
|
|
@ -42,7 +42,7 @@ export function StopButton({ onClick }: { onClick: () => void }) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="bg-[#000000] border-[#161F2C] border p-2 rounded-lg shrink-0 cursor-pointer hover:bg-[#161F2C] transition-opacity"
|
||||
className="bg-surface-card border-surface-border border p-2 rounded-lg shrink-0 cursor-pointer hover:bg-surface-hover transition-opacity"
|
||||
>
|
||||
<SquareIcon className="size-4 text-white fill-white" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { ChevronUpIcon } from "lucide-react"
|
|||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useRef, useState } from "react"
|
||||
import { type ReactNode, useRef, useState } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { SendButton, StopButton } from "./actions"
|
||||
|
||||
|
|
@ -18,6 +18,10 @@ interface ChatInputProps {
|
|||
activeStatus?: string
|
||||
chainOfThoughtComponent?: React.ReactNode
|
||||
onExpandedChange?: (expanded: boolean) => void
|
||||
/** Model + space controls on one row with send; textarea full-width above */
|
||||
stackedToolbar?: ReactNode
|
||||
/** Nova status row + chain-of-thought toggle (off for e.g. home composer) */
|
||||
showStatusStrip?: boolean
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
|
|
@ -30,6 +34,8 @@ export default function ChatInput({
|
|||
activeStatus,
|
||||
chainOfThoughtComponent,
|
||||
onExpandedChange,
|
||||
stackedToolbar,
|
||||
showStatusStrip = true,
|
||||
}: ChatInputProps) {
|
||||
const [isMultiline, setIsMultiline] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
|
@ -52,82 +58,122 @@ export default function ChatInput({
|
|||
<motion.div
|
||||
className={cn("relative z-20!")}
|
||||
animate={{
|
||||
padding: isExpanded ? "16px" : "0",
|
||||
margin: isExpanded ? "0" : "16px",
|
||||
borderRadius: isExpanded ? "0 0 12px 12px" : "12px",
|
||||
backgroundColor: isExpanded ? "#000B1B" : "#01173C",
|
||||
padding: showStatusStrip ? (isExpanded ? "16px" : "0") : "0",
|
||||
margin: showStatusStrip ? (isExpanded ? "0" : "16px") : "0",
|
||||
borderRadius: showStatusStrip
|
||||
? isExpanded
|
||||
? "0 0 12px 12px"
|
||||
: "12px"
|
||||
: "0",
|
||||
backgroundColor: showStatusStrip
|
||||
? isExpanded
|
||||
? "#000B1B"
|
||||
: "#01173C"
|
||||
: "transparent",
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-full left-0 right-0 overflow-hidden transition-all duration-300 ease-out bg-[#000B1B]",
|
||||
isExpanded
|
||||
? "max-h-[60vh] opacity-100 overflow-y-auto pt-1.5 pb-2 rounded-t-xl px-4"
|
||||
: "max-h-0 opacity-0",
|
||||
)}
|
||||
style={{
|
||||
zIndex: isExpanded ? 50 : 0,
|
||||
}}
|
||||
>
|
||||
{chainOfThoughtComponent}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left",
|
||||
!chainOfThoughtComponent && "disabled:cursor-not-allowed",
|
||||
)}
|
||||
onClick={() => {
|
||||
const newExpanded = !isExpanded
|
||||
setIsExpanded(newExpanded)
|
||||
onExpandedChange?.(newExpanded)
|
||||
}}
|
||||
disabled={!chainOfThoughtComponent}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<NovaOrb size={24} className="blur-[1px]! z-10" />
|
||||
<p className={cn("text-[#525D6E]", dmSansClassName())}>
|
||||
{activeStatus || "Waiting for input..."}
|
||||
</p>
|
||||
</div>
|
||||
{chainOfThoughtComponent && (
|
||||
<ChevronUpIcon
|
||||
{showStatusStrip ? (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"size-4 text-[#525D6E] transition-transform duration-300",
|
||||
isExpanded && "rotate-180",
|
||||
"absolute bottom-full left-0 right-0 overflow-hidden transition-all duration-300 ease-out bg-[#000B1B]",
|
||||
isExpanded
|
||||
? "max-h-[60vh] opacity-100 overflow-y-auto pt-1.5 pb-2 rounded-t-xl px-4"
|
||||
: "max-h-0 opacity-0",
|
||||
)}
|
||||
style={{
|
||||
zIndex: isExpanded ? 50 : 0,
|
||||
}}
|
||||
>
|
||||
{chainOfThoughtComponent}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left",
|
||||
!chainOfThoughtComponent && "disabled:cursor-not-allowed",
|
||||
)}
|
||||
onClick={() => {
|
||||
const newExpanded = !isExpanded
|
||||
setIsExpanded(newExpanded)
|
||||
onExpandedChange?.(newExpanded)
|
||||
}}
|
||||
disabled={!chainOfThoughtComponent}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<NovaOrb size={24} className="blur-[1px]! z-10" />
|
||||
<p className={cn("text-[#525D6E]", dmSansClassName())}>
|
||||
{activeStatus || "Waiting for input..."}
|
||||
</p>
|
||||
</div>
|
||||
{chainOfThoughtComponent && (
|
||||
<ChevronUpIcon
|
||||
className={cn(
|
||||
"size-4 text-[#525D6E] transition-transform duration-300",
|
||||
isExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{stackedToolbar ? (
|
||||
<div className="flex flex-col gap-2 rounded-xl bg-surface-card/60 backdrop-blur-md p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask your supermemory..."
|
||||
className="w-full resize-none overflow-y-auto bg-transparent p-2 text-fg-primary transition-all duration-200 placeholder:text-fg-faint focus:outline-none"
|
||||
style={{ minHeight: "36px" }}
|
||||
rows={1}
|
||||
disabled={isResponding}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-end gap-2 bg-[#070E1B] rounded-xl p-2 border-[#52596633] border focus-within:outline-[#525D6EB2] focus-within:outline-1 transition-all duration-200",
|
||||
isMultiline && "flex-col",
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask your supermemory..."
|
||||
className="bg-transparent w-full p-2 placeholder:text-[#525D6E] focus:outline-none resize-none overflow-y-auto transition-all duration-200"
|
||||
style={{ minHeight: "36px" }}
|
||||
rows={1}
|
||||
disabled={isResponding}
|
||||
/>
|
||||
<div className="transition-all duration-200">
|
||||
{isResponding ? (
|
||||
<StopButton onClick={onStop} />
|
||||
) : (
|
||||
<SendButton onClick={onSend} disabled={!value.trim()} />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{stackedToolbar}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{isResponding ? (
|
||||
<StopButton onClick={onStop} />
|
||||
) : (
|
||||
<SendButton onClick={onSend} disabled={!value.trim()} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-end gap-2 rounded-xl bg-surface-card/60 backdrop-blur-md p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10",
|
||||
isMultiline && "flex-col",
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Ask your supermemory..."
|
||||
className="w-full resize-none overflow-y-auto bg-transparent p-2 text-fg-primary transition-all duration-200 placeholder:text-fg-faint focus:outline-none"
|
||||
style={{ minHeight: "36px" }}
|
||||
rows={1}
|
||||
disabled={isResponding}
|
||||
/>
|
||||
<div className="transition-all duration-200">
|
||||
{isResponding ? (
|
||||
<StopButton onClick={onStop} />
|
||||
) : (
|
||||
<SendButton onClick={onSend} disabled={!value.trim()} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,17 +4,18 @@ import { useState } from "react"
|
|||
import type { UIMessage } from "@ai-sdk/react"
|
||||
import { Streamdown } from "streamdown"
|
||||
import {
|
||||
BookOpenIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
Loader2,
|
||||
SearchIcon,
|
||||
GlobeIcon,
|
||||
PlusIcon,
|
||||
BookOpenIcon,
|
||||
ClockIcon,
|
||||
GlobeIcon,
|
||||
ListIcon,
|
||||
XCircleIcon,
|
||||
Loader2,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
TerminalIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { isWebSearchToolName } from "@/lib/chat-web-search-tools"
|
||||
|
|
@ -22,9 +23,11 @@ import { RelatedMemories } from "./related-memories"
|
|||
import { MessageActions } from "./message-actions"
|
||||
|
||||
const TOOL_META: Record<string, { label: string; icon: typeof SearchIcon }> = {
|
||||
searchMemories: { label: "Search Memories", icon: SearchIcon },
|
||||
bash: { label: "Memory", icon: TerminalIcon },
|
||||
web_search: { label: "Web search", icon: GlobeIcon },
|
||||
google_search: { label: "Google search", icon: GlobeIcon },
|
||||
// legacy tool names kept for existing persisted messages
|
||||
searchMemories: { label: "Search Memories", icon: SearchIcon },
|
||||
addMemory: { label: "Add Memory", icon: PlusIcon },
|
||||
fetchMemory: { label: "Fetch Memory", icon: BookOpenIcon },
|
||||
scheduleTask: { label: "Schedule Task", icon: ClockIcon },
|
||||
|
|
@ -95,9 +98,119 @@ function WebSourcesGroup({ sources }: { sources: SourceUrlPart[] }) {
|
|||
)
|
||||
}
|
||||
|
||||
function BashToolDisplay({ part }: { part: ToolCallDisplayPart }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const isLoading =
|
||||
part.state === "input-streaming" || part.state === "input-available"
|
||||
const isDone = part.state === "output-available"
|
||||
const isError = part.state === "error" || part.state === "output-error"
|
||||
|
||||
const cmd =
|
||||
part.input && typeof part.input === "object" && "cmd" in part.input
|
||||
? String((part.input as { cmd: string }).cmd)
|
||||
: undefined
|
||||
|
||||
const output =
|
||||
isDone && part.output && typeof part.output === "object"
|
||||
? (part.output as { stdout?: string; stderr?: string; exitCode?: number })
|
||||
: undefined
|
||||
|
||||
const hasOutput =
|
||||
output &&
|
||||
((output.stdout && output.stdout.length > 0) ||
|
||||
(output.stderr && output.stderr.length > 0))
|
||||
const errorText = part.errorText
|
||||
const hasExpandable = hasOutput || (isError && errorText)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[#1E2128] bg-[#0D121A] text-xs my-1 overflow-hidden font-mono">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-3 py-2 cursor-pointer hover:bg-[#141922] transition-colors",
|
||||
expanded && hasExpandable && "border-b border-[#1E2128]",
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-3 animate-spin text-blue-400 shrink-0" />
|
||||
) : (
|
||||
<TerminalIcon
|
||||
className={cn(
|
||||
"size-3 shrink-0",
|
||||
isDone
|
||||
? output?.exitCode === 0
|
||||
? "text-emerald-400"
|
||||
: "text-amber-400"
|
||||
: isError
|
||||
? "text-red-400"
|
||||
: "text-white/50",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className={cn("text-white/50", isLoading && "text-blue-400/60")}>
|
||||
$
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 text-left truncate",
|
||||
isDone
|
||||
? output?.exitCode === 0
|
||||
? "text-emerald-300"
|
||||
: "text-amber-300"
|
||||
: isLoading
|
||||
? "text-blue-300"
|
||||
: isError
|
||||
? "text-red-300"
|
||||
: "text-white/70",
|
||||
)}
|
||||
>
|
||||
{cmd ?? "..."}
|
||||
</span>
|
||||
{isLoading && (
|
||||
<span className="text-white/30 shrink-0">running...</span>
|
||||
)}
|
||||
{isDone && !hasOutput && (
|
||||
<span className="text-white/30 shrink-0">done</span>
|
||||
)}
|
||||
{isError && <span className="text-red-400/60 shrink-0">error</span>}
|
||||
{hasExpandable &&
|
||||
(expanded ? (
|
||||
<ChevronDownIcon className="size-3 text-white/30 shrink-0" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-3 text-white/30 shrink-0" />
|
||||
))}
|
||||
</button>
|
||||
|
||||
{expanded && (hasOutput || (isError && errorText)) && (
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
{output?.stdout && output.stdout.length > 0 && (
|
||||
<pre className="text-white/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-48 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
|
||||
{output.stdout}
|
||||
</pre>
|
||||
)}
|
||||
{output?.stderr && output.stderr.length > 0 && (
|
||||
<pre className="text-amber-300/70 bg-[#080B10] rounded p-2 overflow-x-auto max-h-24 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
|
||||
{output.stderr}
|
||||
</pre>
|
||||
)}
|
||||
{isError && errorText && (
|
||||
<pre className="text-red-300/90 bg-[#080B10] rounded p-2 overflow-x-auto max-h-24 overflow-y-auto whitespace-pre-wrap break-all text-[11px]">
|
||||
{errorText}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolCallDisplay({ part }: { part: ToolCallDisplayPart }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const toolName = part.type.replace("tool-", "")
|
||||
if (toolName === "bash") {
|
||||
return <BashToolDisplay part={part} />
|
||||
}
|
||||
const meta =
|
||||
TOOL_META[toolName] ??
|
||||
(isWebSearchToolName(toolName)
|
||||
|
|
|
|||
|
|
@ -11,11 +11,14 @@ import { analytics } from "@/lib/analytics"
|
|||
interface ChatModelSelectorProps {
|
||||
selectedModel?: ModelId
|
||||
onModelChange?: (model: ModelId) => void
|
||||
/** Compact pill matching inline send control. */
|
||||
minimal?: boolean
|
||||
}
|
||||
|
||||
export default function ChatModelSelector({
|
||||
selectedModel: selectedModelProp,
|
||||
onModelChange,
|
||||
minimal = false,
|
||||
}: ChatModelSelectorProps = {}) {
|
||||
const [internalModel, setInternalModel] =
|
||||
useState<ModelId>("claude-sonnet-4.6")
|
||||
|
|
@ -34,25 +37,44 @@ export default function ChatModelSelector({
|
|||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const trigger = minimal ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex max-w-[min(100%,220px)] min-w-0 shrink cursor-pointer items-center gap-1.5 rounded-full bg-fg-primary/5 px-3 py-1.5 text-sm transition-colors hover:bg-fg-primary/10",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<p className="min-w-0 truncate text-left text-fg-primary">
|
||||
{currentModelData.name}{" "}
|
||||
<span className="text-fg-subtle">{currentModelData.version}</span>
|
||||
</p>
|
||||
<ChevronDownIcon className="size-3.5 shrink-0 text-fg-subtle" />
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"h-10! max-w-[min(100%,220px)] shrink gap-1 rounded-full border-[#73737333] bg-surface-base text-base",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
||||
}}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<p className="truncate text-sm">
|
||||
{currentModelData.name}{" "}
|
||||
<span className="text-[#737373]">{currentModelData.version}</span>
|
||||
</p>
|
||||
<ChevronDownIcon className="size-4 text-[#737373]" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Button
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"rounded-full text-base gap-1 h-10! border-[#73737333] bg-[#0D121A]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
||||
}}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<p className="text-sm">
|
||||
{currentModelData.name}{" "}
|
||||
<span className="text-[#737373]">{currentModelData.version}</span>
|
||||
</p>
|
||||
<ChevronDownIcon className="size-4 text-[#737373]" />
|
||||
</Button>
|
||||
<div className="relative flex min-w-0 shrink items-center gap-2">
|
||||
{trigger}
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
|
|
@ -64,7 +86,7 @@ export default function ChatModelSelector({
|
|||
aria-label="Close model selector"
|
||||
/>
|
||||
|
||||
<div className="absolute top-full left-0 mt-2 w-64 bg-[#0D121A] backdrop-blur-xl border border-[#73737333] rounded-lg shadow-xl z-50 overflow-hidden">
|
||||
<div className="absolute bottom-full left-0 mb-2 w-64 bg-surface-card backdrop-blur-xl border border-surface-border rounded-lg shadow-xl z-50 overflow-hidden">
|
||||
<div className="p-2 space-y-1">
|
||||
{models.map((model) => {
|
||||
const modelData = modelNames[model.id]
|
||||
|
|
@ -85,11 +107,11 @@ export default function ChatModelSelector({
|
|||
>
|
||||
<div className="text-sm font-medium text-white">
|
||||
{modelData.name}{" "}
|
||||
<span className="text-[#737373]">
|
||||
<span className="text-fg-subtle">
|
||||
{modelData.version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-[#737373] truncate w-full">
|
||||
<div className="text-xs text-fg-muted truncate w-full">
|
||||
{model.description}
|
||||
</div>
|
||||
</button>
|
||||
|
|
|
|||
873
apps/web/components/dashboard-view.tsx
Normal file
873
apps/web/components/dashboard-view.tsx
Normal file
|
|
@ -0,0 +1,873 @@
|
|||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { useMemo, useState, useEffect } from "react"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { $fetch } from "@lib/api"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Lightbulb,
|
||||
Link2,
|
||||
RotateCcw,
|
||||
SearchIcon,
|
||||
Terminal,
|
||||
} from "lucide-react"
|
||||
import type { z } from "zod"
|
||||
import { CHROME_EXTENSION_URL, RAYCAST_EXTENSION_URL } from "@lib/constants"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useProject } from "@/stores"
|
||||
import {
|
||||
HighlightsCard,
|
||||
type HighlightItem,
|
||||
} from "@/components/highlights-card"
|
||||
import { StaticGraphPreview } from "@/components/memory-graph/graph-card"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
||||
import { ChromeIcon, RaycastIcon } from "@/components/integration-icons"
|
||||
import { GoogleDrive, Notion, MCPIcon } from "@ui/assets/icons"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import type { IntegrationParamValue } from "@/lib/search-params"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import {
|
||||
usePersonalization,
|
||||
type Profession,
|
||||
} from "@/hooks/use-personalization"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
const fadeUp = {
|
||||
initial: { opacity: 0, y: 8 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.4, 0, 0.2, 1] as [number, number, number, number],
|
||||
},
|
||||
}
|
||||
|
||||
const CYCLE_INTERVAL_MS = 8_000
|
||||
|
||||
const PLUGIN_TAGLINES: Record<Profession, Partial<Record<string, string>>> = {
|
||||
developer: {
|
||||
mcp: "Ask Claude about your saved docs and specs from any IDE",
|
||||
chrome: "Save Stack Overflow answers, docs and repos in one click",
|
||||
raycast: "Search your tech docs and snippets without context switching",
|
||||
notion: "Make your engineering specs and RFCs instantly findable",
|
||||
"google-drive": "Query your design docs, code specs and shared files",
|
||||
},
|
||||
research: {
|
||||
mcp: "Ask Claude across your entire reading list and notes",
|
||||
chrome: "Clip papers and articles directly while you read",
|
||||
raycast: "Pull up citations and notes without breaking your focus",
|
||||
notion: "Keep your literature review alongside your saved papers",
|
||||
"google-drive": "Index datasets, papers and research docs in one place",
|
||||
},
|
||||
finance: {
|
||||
mcp: "Ask Claude about your saved thesis notes and research",
|
||||
chrome: "Save earnings calls, market reports and articles instantly",
|
||||
raycast: "Surface your research and models without breaking flow",
|
||||
notion: "Make your investment thesis and portfolio notes searchable",
|
||||
"google-drive": "Query your financial models, decks and reports instantly",
|
||||
},
|
||||
design: {
|
||||
mcp: "Ask Claude about your saved briefs and design research",
|
||||
chrome: "Save inspiration and references as you browse",
|
||||
raycast: "Find your saved references and briefs from anywhere",
|
||||
notion: "Make your design system docs and briefs searchable",
|
||||
"google-drive": "Index your briefs, feedback docs and creative assets",
|
||||
},
|
||||
legal: {
|
||||
mcp: "Ask Claude across your saved contracts and case notes",
|
||||
chrome: "Clip case law, statutes and legal articles in one click",
|
||||
raycast: "Surface contracts and precedents without leaving your workflow",
|
||||
notion: "Keep memos, briefs and case notes instantly searchable",
|
||||
"google-drive": "Index contracts, filings and legal research docs",
|
||||
},
|
||||
marketing: {
|
||||
mcp: "Ask Claude across your saved campaigns and research",
|
||||
chrome: "Save competitor pages and inspiration as you browse",
|
||||
raycast: "Pull up campaign briefs and notes without context switching",
|
||||
notion: "Make your content calendar and campaign briefs searchable",
|
||||
"google-drive": "Query campaign reports, briefs and creative assets",
|
||||
},
|
||||
medical: {
|
||||
mcp: "Ask Claude across your medical literature and clinical notes",
|
||||
chrome: "Save studies and clinical resources while you read",
|
||||
raycast: "Surface guidelines and notes without breaking your flow",
|
||||
notion: "Keep clinical notes and research in one searchable place",
|
||||
"google-drive": "Index guidelines, studies and patient education docs",
|
||||
},
|
||||
default: {
|
||||
mcp: "Ask Claude using your own saved knowledge",
|
||||
chrome: "Save any page in one click while you browse",
|
||||
raycast: "Search your memory without leaving the keyboard",
|
||||
notion: "Make every note and doc instantly searchable",
|
||||
"google-drive": "Ask questions across your docs, slides and sheets",
|
||||
},
|
||||
}
|
||||
|
||||
export type MemoryOfDay = {
|
||||
memories: string[]
|
||||
timeLabel: string
|
||||
sourceDocumentId: string | null
|
||||
}
|
||||
|
||||
const TIPS: Record<Profession, string[]> = {
|
||||
developer: [
|
||||
"Use ⌘K to search code snippets and docs by intent, not just keywords",
|
||||
"Connect Claude MCP to query your saved knowledge from any IDE",
|
||||
"Save GitHub repos and READMEs — ask questions across all of them",
|
||||
"Use 'Related' on highlights to find connected technical concepts",
|
||||
],
|
||||
research: [
|
||||
"Save papers and ask questions across your entire reading list",
|
||||
"Use 'Related' on highlights to surface connected research",
|
||||
"Connect Notion to index your notes alongside your papers",
|
||||
"Semantic search means you can ask questions, not just search titles",
|
||||
],
|
||||
finance: [
|
||||
"Save articles and ask follow-up questions across your research",
|
||||
"Connect Notion to keep your investment thesis searchable",
|
||||
"Use ⌘K to find specific data points across all your saves",
|
||||
"Daily Brief surfaces connections you may have missed",
|
||||
],
|
||||
design: [
|
||||
"Save inspiration and search by concept — 'minimalist UI' finds the right ones",
|
||||
"Use ⌘K to rediscover references by meaning, not filename",
|
||||
"Connect Notion to make your briefs and moodboards searchable",
|
||||
"Chrome extension saves any page in one click while you browse",
|
||||
],
|
||||
legal: [
|
||||
"Save documents and search across them semantically in seconds",
|
||||
"Connect Notion to index your memos and case notes together",
|
||||
"Use Daily Brief to resurface relevant precedents automatically",
|
||||
"Google Drive sync keeps your contracts indexed and queryable",
|
||||
],
|
||||
marketing: [
|
||||
"Save campaigns and resources — ask what worked across all of them",
|
||||
"Chrome extension captures competitor pages in one click",
|
||||
"Use 'Related' to find similar campaigns in your archive",
|
||||
"Connect Notion to make your campaign briefs instantly searchable",
|
||||
],
|
||||
medical: [
|
||||
"Save studies and query across your entire reading list",
|
||||
"Connect Notion to keep clinical notes alongside research",
|
||||
"Use ⌘K to find specific findings across hundreds of papers",
|
||||
"Daily Brief surfaces relevant research from your saves automatically",
|
||||
],
|
||||
default: [
|
||||
"Use ⌘K to search by meaning — ask questions, not just keywords",
|
||||
"Daily Brief surfaces insights from your saves each morning",
|
||||
"Chrome extension saves any page in one click while you browse",
|
||||
"Connect integrations to make all your knowledge searchable here",
|
||||
],
|
||||
}
|
||||
|
||||
const PROFESSION_PLUGIN_ORDER: Record<Profession, string[]> = {
|
||||
developer: ["mcp", "chrome", "raycast", "notion", "google-drive"],
|
||||
research: ["notion", "chrome", "google-drive", "mcp", "raycast"],
|
||||
finance: ["notion", "google-drive", "chrome", "mcp", "raycast"],
|
||||
design: ["chrome", "notion", "raycast", "mcp", "google-drive"],
|
||||
legal: ["notion", "google-drive", "chrome", "mcp", "raycast"],
|
||||
marketing: ["chrome", "notion", "raycast", "google-drive", "mcp"],
|
||||
medical: ["notion", "chrome", "google-drive", "mcp", "raycast"],
|
||||
default: ["mcp", "chrome", "notion", "raycast", "google-drive"],
|
||||
}
|
||||
|
||||
const PROFESSION_LABELS: {
|
||||
value: Exclude<Profession, "default">
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: "developer", label: "Developer" },
|
||||
{ value: "research", label: "Researcher" },
|
||||
{ value: "finance", label: "Finance" },
|
||||
{ value: "design", label: "Designer" },
|
||||
{ value: "legal", label: "Legal" },
|
||||
{ value: "marketing", label: "Marketing" },
|
||||
{ value: "medical", label: "Medical" },
|
||||
]
|
||||
|
||||
// Static plugin metadata — shared between PluginPromoCard and RecommendedPluginsCard
|
||||
const PLUGIN_STATIC = [
|
||||
{
|
||||
id: "mcp",
|
||||
name: "Claude MCP",
|
||||
Icon: MCPIcon,
|
||||
accentColor: "#D4A853",
|
||||
tagline: "Ask Claude from your own saved knowledge, not just training data",
|
||||
cta: "Set up",
|
||||
},
|
||||
{
|
||||
id: "chrome",
|
||||
name: "Chrome Extension",
|
||||
Icon: ChromeIcon,
|
||||
accentColor: "#4BA0FA",
|
||||
tagline: "Save any page in one click — findable by meaning, forever",
|
||||
cta: "Install",
|
||||
},
|
||||
{
|
||||
id: "raycast",
|
||||
name: "Raycast",
|
||||
Icon: RaycastIcon,
|
||||
accentColor: "#FF6363",
|
||||
tagline: "Search your entire memory without leaving your keyboard",
|
||||
cta: "Install",
|
||||
},
|
||||
{
|
||||
id: "notion",
|
||||
name: "Notion",
|
||||
Icon: Notion,
|
||||
accentColor: "#FAFAFA",
|
||||
tagline: "Sync your workspace and make every note searchable everywhere",
|
||||
cta: "Connect",
|
||||
},
|
||||
{
|
||||
id: "google-drive",
|
||||
name: "Google Drive",
|
||||
Icon: GoogleDrive,
|
||||
accentColor: "#4BA0FA",
|
||||
tagline:
|
||||
"Index your Drive files — ask questions across docs, slides, sheets",
|
||||
cta: "Connect",
|
||||
},
|
||||
] as const
|
||||
|
||||
function RecommendedPluginsCard({
|
||||
profession,
|
||||
setProfession,
|
||||
connectedProviders,
|
||||
hasMcp,
|
||||
onOpenPlugins,
|
||||
onOpenIntegrations,
|
||||
}: {
|
||||
profession: Profession
|
||||
setProfession: (p: Profession) => void
|
||||
connectedProviders: Set<string>
|
||||
hasMcp: boolean
|
||||
onOpenPlugins: () => void
|
||||
onOpenIntegrations: (integration?: IntegrationParamValue) => void
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
useEffect(() => {
|
||||
setIsEditing(false)
|
||||
}, [])
|
||||
const showPicker = profession === "default" || isEditing
|
||||
const allPlugins = useMemo(() => {
|
||||
const onClicks: Record<string, () => void> = {
|
||||
mcp: onOpenPlugins,
|
||||
chrome: () =>
|
||||
window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer"),
|
||||
raycast: () =>
|
||||
window.open(RAYCAST_EXTENSION_URL, "_blank", "noopener,noreferrer"),
|
||||
notion: () => onOpenIntegrations("notion"),
|
||||
"google-drive": () => onOpenIntegrations("google-drive"),
|
||||
}
|
||||
const connected: Record<string, boolean> = {
|
||||
mcp: hasMcp,
|
||||
chrome: false,
|
||||
raycast: false,
|
||||
notion: connectedProviders.has("notion"),
|
||||
"google-drive": connectedProviders.has("google-drive"),
|
||||
}
|
||||
return PLUGIN_STATIC.map((p) => ({
|
||||
...p,
|
||||
connected: connected[p.id] ?? false,
|
||||
onClick: onClicks[p.id]!,
|
||||
}))
|
||||
}, [hasMcp, connectedProviders, onOpenPlugins, onOpenIntegrations])
|
||||
|
||||
const order = PROFESSION_PLUGIN_ORDER[profession]
|
||||
const suggestions = useMemo(
|
||||
() =>
|
||||
order
|
||||
.map((id) => allPlugins.find((p) => p.id === id))
|
||||
.filter((p): p is NonNullable<typeof p> => !!p && !p.connected)
|
||||
.slice(0, 3),
|
||||
[order, allPlugins],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-surface-card/60 backdrop-blur-md rounded-xl px-3 py-2 flex flex-col gap-1 shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{showPicker ? (
|
||||
<div className="px-1 py-2 flex flex-col gap-2.5">
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
{isEditing ? "Change your field:" : "What's your field?"}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PROFESSION_LABELS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProfession(value)
|
||||
setIsEditing(false)
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all cursor-pointer",
|
||||
profession === value
|
||||
? "border-[#4BA0FA]/55 bg-[#3374FF]/15 text-[#8BC6FF]"
|
||||
: "border-surface-border text-fg-subtle hover:border-[#4BA0FA]/40 hover:text-[#6BB0FF]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(false)}
|
||||
className="text-[10px] text-fg-faint hover:text-fg-muted transition-colors text-left cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : suggestions.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<p className="text-[11px] text-fg-subtle text-center">
|
||||
You're all set ✓
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul>
|
||||
{suggestions.map((plugin) => (
|
||||
<li key={plugin.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={plugin.onClick}
|
||||
className="group w-full flex items-center gap-2.5 rounded-lg px-2 py-2 hover:bg-surface-hover transition-colors cursor-pointer"
|
||||
>
|
||||
<plugin.Icon className="size-4 shrink-0 text-fg-subtle" />
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="text-[12px] text-fg-secondary group-hover:text-white transition-colors leading-tight">
|
||||
{plugin.name}
|
||||
</p>
|
||||
<p className="text-[11px] text-fg-subtle leading-tight mt-0.5">
|
||||
{PLUGIN_TAGLINES[profession][plugin.id] ?? plugin.tagline}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] font-medium text-[#5EA8FF] group-hover:text-[#8BC6FF] transition-colors">
|
||||
{plugin.cta} →
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="text-left px-2 pb-1 text-[10px] text-fg-faint hover:text-fg-muted transition-colors cursor-pointer"
|
||||
>
|
||||
Not a{" "}
|
||||
{PROFESSION_LABELS.find(
|
||||
(p) => p.value === profession,
|
||||
)?.label.toLowerCase()}
|
||||
? Change →
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemoryOfDayCard({ data }: { data: MemoryOfDay }) {
|
||||
const router = useRouter()
|
||||
|
||||
const memory = data.memories[0]
|
||||
|
||||
if (!memory) return null
|
||||
|
||||
const href = data.sourceDocumentId
|
||||
? `/?view=list&doc=${encodeURIComponent(data.sourceDocumentId)}`
|
||||
: "/?view=list"
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(href)}
|
||||
className={cn(
|
||||
"group w-full h-full text-left bg-surface-card/60 backdrop-blur-md rounded-[18px] p-3 flex flex-col justify-between transition-colors cursor-pointer shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<span className="self-start text-[9px] font-semibold tracking-[0.12em] uppercase text-[#8BC6FF] bg-[#4BA0FA]/16 rounded-full px-2 py-0.5">
|
||||
{data.timeLabel}
|
||||
</span>
|
||||
<p className="text-[12px] text-fg-secondary leading-relaxed line-clamp-4">
|
||||
{memory}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-fg-faint group-hover:text-fg-muted transition-colors">
|
||||
View memories →
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PluginPromoCard({
|
||||
hasMcp,
|
||||
connectedProviders,
|
||||
onOpenPlugins,
|
||||
onOpenIntegrations,
|
||||
}: {
|
||||
hasMcp: boolean
|
||||
connectedProviders: Set<string>
|
||||
onOpenPlugins: () => void
|
||||
onOpenIntegrations: (integration?: IntegrationParamValue) => void
|
||||
}) {
|
||||
const plugins = useMemo(() => {
|
||||
const onClicks: Record<string, () => void> = {
|
||||
mcp: onOpenPlugins,
|
||||
chrome: () =>
|
||||
window.open(CHROME_EXTENSION_URL, "_blank", "noopener,noreferrer"),
|
||||
raycast: () =>
|
||||
window.open(RAYCAST_EXTENSION_URL, "_blank", "noopener,noreferrer"),
|
||||
notion: () => onOpenIntegrations("notion"),
|
||||
"google-drive": () => onOpenIntegrations("google-drive"),
|
||||
}
|
||||
const connected: Record<string, boolean> = {
|
||||
mcp: hasMcp,
|
||||
chrome: false,
|
||||
raycast: false,
|
||||
notion: connectedProviders.has("notion"),
|
||||
"google-drive": connectedProviders.has("google-drive"),
|
||||
}
|
||||
return PLUGIN_STATIC.map((p) => ({
|
||||
...p,
|
||||
connected: connected[p.id] ?? false,
|
||||
onClick: onClicks[p.id]!,
|
||||
})).filter((p) => !p.connected)
|
||||
}, [hasMcp, connectedProviders, onOpenPlugins, onOpenIntegrations])
|
||||
|
||||
const [index, setIndex] = useState(0)
|
||||
|
||||
// Reset when the plugins list changes length (e.g., user connects one)
|
||||
useEffect(() => {
|
||||
setIndex(0)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (plugins.length <= 1) return
|
||||
const id = setInterval(
|
||||
() => setIndex((i) => (i + 1) % plugins.length),
|
||||
CYCLE_INTERVAL_MS,
|
||||
)
|
||||
return () => clearInterval(id)
|
||||
}, [plugins.length])
|
||||
|
||||
const safeIndex = Math.min(index, plugins.length - 1)
|
||||
const plugin = plugins[safeIndex]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-surface-card/60 backdrop-blur-md rounded-[18px] p-3 flex flex-col justify-between gap-3 h-full shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{plugin ? (
|
||||
<>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={plugin.id}
|
||||
initial={{ opacity: 0, x: 16 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -16 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex flex-col gap-3 flex-1"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<plugin.Icon className="size-7 shrink-0" />
|
||||
{plugins.length > 1 && (
|
||||
<div className="flex gap-1">
|
||||
{plugins.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setIndex(i)}
|
||||
className={cn(
|
||||
"rounded-full transition-all cursor-pointer",
|
||||
i === safeIndex
|
||||
? "w-3 h-1 bg-[#4BA0FA]"
|
||||
: "size-1 bg-[#2A3040] hover:bg-[#3A4455]",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[11px] font-semibold text-fg-primary leading-tight">
|
||||
{plugin.name}
|
||||
</p>
|
||||
<p className="text-[10px] text-fg-muted leading-normal">
|
||||
{plugin.tagline}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={plugin.onClick}
|
||||
className="w-full bg-surface-card border border-surface-border rounded-lg px-3 py-1.5 text-[11px] font-medium text-[#6BB0FF] hover:text-white hover:bg-surface-hover transition-colors cursor-pointer text-left flex items-center justify-between group"
|
||||
style={{ boxShadow: "inset 1px 1px 2px rgba(0,0,0,0.5)" }}
|
||||
>
|
||||
<span>{plugin.cta}</span>
|
||||
<ArrowRight className="size-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-1.5 text-center">
|
||||
<Terminal className="size-4 text-fg-faint" />
|
||||
<p className="text-[10px] text-fg-subtle">
|
||||
All integrations connected
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardView({
|
||||
spaceLabel,
|
||||
headerNotice,
|
||||
highlights,
|
||||
isLoadingHighlights,
|
||||
onAddMemory,
|
||||
onOpenSearch,
|
||||
onOpenIntegrations,
|
||||
onOpenPlugins,
|
||||
onNavigateToMemories,
|
||||
onNavigateToGraph,
|
||||
onOpenDocument,
|
||||
onHighlightsChat,
|
||||
onHighlightsShowRelated,
|
||||
onResetHighlights,
|
||||
memoryOfDay,
|
||||
}: {
|
||||
spaceLabel: string
|
||||
headerNotice?: ReactNode
|
||||
highlights: HighlightItem[]
|
||||
isLoadingHighlights: boolean
|
||||
onAddMemory: (tab: "note" | "link") => void
|
||||
onOpenSearch: () => void
|
||||
onOpenIntegrations: (integration?: IntegrationParamValue) => void
|
||||
onOpenPlugins: () => void
|
||||
onNavigateToMemories: () => void
|
||||
onNavigateToGraph: () => void
|
||||
onOpenDocument: (document: DocumentWithMemories) => void
|
||||
onHighlightsChat: (seed: string) => void
|
||||
onHighlightsShowRelated: (query: string) => void
|
||||
onResetHighlights: () => void
|
||||
memoryOfDay: MemoryOfDay | null
|
||||
}) {
|
||||
const { user } = useAuth()
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const _router = useRouter()
|
||||
const { data: recentsData } = useQuery({
|
||||
queryKey: ["dashboard-recents", effectiveContainerTags],
|
||||
queryFn: async (): Promise<DocumentsResponse> => {
|
||||
const response = await $fetch("@post/documents/documents", {
|
||||
body: {
|
||||
page: 1,
|
||||
limit: 5,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
containerTags: effectiveContainerTags,
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
if (response.error) throw new Error(response.error?.message)
|
||||
return response.data as DocumentsResponse
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
enabled: !!user,
|
||||
})
|
||||
|
||||
const { data: connections = [] } = useQuery({
|
||||
queryKey: ["connections-list", effectiveContainerTags],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@post/connections/list", {
|
||||
body: { containerTags: effectiveContainerTags },
|
||||
})
|
||||
if (response.error) return []
|
||||
return response.data ?? []
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: !!user,
|
||||
})
|
||||
|
||||
const { data: mcpData } = useQuery({
|
||||
queryKey: ["mcp-status"],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@get/mcp/has-login")
|
||||
return response.data ?? { previousLogin: false }
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: !!user,
|
||||
})
|
||||
|
||||
const {
|
||||
copy: personalizedCopy,
|
||||
profession,
|
||||
setProfession,
|
||||
} = usePersonalization()
|
||||
|
||||
const recents = recentsData?.documents ?? []
|
||||
const totalMemories = recentsData?.pagination?.totalItems ?? 0
|
||||
const hasMcp = mcpData?.previousLogin ?? false
|
||||
const connectedProviders = new Set(connections.map((c) => c.provider))
|
||||
|
||||
const dayOfYear = Math.round(
|
||||
(Date.now() - new Date(new Date().getFullYear(), 0, 1).getTime()) /
|
||||
86_400_000,
|
||||
)
|
||||
const tips = TIPS[profession]
|
||||
const tip = tips[dayOfYear % tips.length]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto p-4 pt-2! pb-32 md:p-6 md:pb-36 md:pr-0",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4 md:space-y-5">
|
||||
{headerNotice ? <div className="space-y-2">{headerNotice}</div> : null}
|
||||
|
||||
{/* Header */}
|
||||
<motion.header
|
||||
{...fadeUp}
|
||||
transition={{ ...fadeUp.transition, delay: 0 }}
|
||||
className="flex items-end justify-between gap-4 border-b border-surface-border pb-4"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
|
||||
Home
|
||||
</p>
|
||||
<h1 className="text-xl font-medium tracking-tight text-white md:text-2xl">
|
||||
{spaceLabel}
|
||||
</h1>
|
||||
</div>
|
||||
{totalMemories > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateToGraph}
|
||||
className="group relative shrink-0 w-[140px] h-[56px] rounded-xl overflow-hidden border border-surface-border hover:border-[#3A4A63] transition-all bg-surface-card hover:scale-[1.02]"
|
||||
aria-label="Open graph view"
|
||||
>
|
||||
<StaticGraphPreview
|
||||
documentCount={totalMemories}
|
||||
memoryCount={totalMemories * 6}
|
||||
width={140}
|
||||
height={56}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
View graph
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</motion.header>
|
||||
|
||||
{/* Daily Brief — hero */}
|
||||
<motion.section
|
||||
{...fadeUp}
|
||||
transition={{ ...fadeUp.transition, delay: 0.05 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
|
||||
Daily brief
|
||||
</p>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResetHighlights}
|
||||
className="text-fg-faint hover:text-fg-muted transition-colors cursor-pointer"
|
||||
aria-label="Refresh daily brief"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Refresh daily brief
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex gap-3 items-stretch">
|
||||
<div className="flex-[4] min-w-0">
|
||||
<HighlightsCard
|
||||
items={highlights}
|
||||
onChat={onHighlightsChat}
|
||||
onShowRelated={onHighlightsShowRelated}
|
||||
isLoading={isLoadingHighlights}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-[2] hidden sm:block min-w-0">
|
||||
{memoryOfDay ? (
|
||||
<MemoryOfDayCard data={memoryOfDay} />
|
||||
) : (
|
||||
<PluginPromoCard
|
||||
hasMcp={hasMcp}
|
||||
connectedProviders={connectedProviders}
|
||||
onOpenPlugins={onOpenPlugins}
|
||||
onOpenIntegrations={onOpenIntegrations}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.section>
|
||||
|
||||
{/* Actions + connection status — single unified row */}
|
||||
<motion.section
|
||||
{...fadeUp}
|
||||
transition={{ ...fadeUp.transition, delay: 0.1 }}
|
||||
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
{/* Quick actions */}
|
||||
<div className="flex items-center gap-0.5 -mx-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddMemory("link")}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-fg-subtle hover:bg-surface-hover hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<Link2 className="size-3.5 shrink-0" />
|
||||
{personalizedCopy.saveLink}
|
||||
</button>
|
||||
<span className="text-[#3A4455] select-none">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddMemory("note")}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-fg-subtle hover:bg-surface-hover hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<FileText className="size-3.5 shrink-0" />
|
||||
{personalizedCopy.writeNote}
|
||||
</button>
|
||||
<span className="text-[#3A4455] select-none">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
analytics.searchOpened({ source: "header" })
|
||||
onOpenSearch()
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-fg-subtle hover:bg-surface-hover hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
<SearchIcon className="size-3.5 shrink-0" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tip of the day */}
|
||||
<p className="hidden sm:flex items-center gap-1.5 text-[11px] text-fg-subtle min-w-0 overflow-hidden">
|
||||
<Lightbulb className="size-3 shrink-0 text-[#3374FF]" />
|
||||
<span className="truncate">{tip}</span>
|
||||
</p>
|
||||
</motion.section>
|
||||
|
||||
{/* Recently saved + Suggested for you */}
|
||||
<motion.section
|
||||
{...fadeUp}
|
||||
transition={{ ...fadeUp.transition, delay: 0.15 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
{recents.length > 0 ? (
|
||||
<>
|
||||
{/* Shared header row — both labels aligned */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-[3] min-w-0">
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
|
||||
Recently saved
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-[2] min-w-0 hidden sm:block">
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
|
||||
Suggested for you
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content row */}
|
||||
<div className="flex gap-4 items-start">
|
||||
<ul className="flex-[3] min-w-0 space-y-0.5">
|
||||
{recents.map((doc) => {
|
||||
const isLink = !!doc.url
|
||||
return (
|
||||
<li key={doc.id ?? doc.customId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenDocument(doc)}
|
||||
className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-surface-card ring-1 ring-surface-border group-hover:bg-[#182333] transition-colors">
|
||||
{isLink ? (
|
||||
<ExternalLink className="size-3 text-fg-subtle" />
|
||||
) : (
|
||||
<FileText className="size-3 text-fg-subtle" />
|
||||
)}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-fg-muted group-hover:text-white transition-colors">
|
||||
{doc.title?.trim() || "Untitled"}
|
||||
</span>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-fg-faint group-hover:text-fg-muted transition-colors" />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="flex-[2] min-w-0 hidden sm:block">
|
||||
<RecommendedPluginsCard
|
||||
profession={profession}
|
||||
setProfession={setProfession}
|
||||
connectedProviders={connectedProviders}
|
||||
hasMcp={hasMcp}
|
||||
onOpenPlugins={onOpenPlugins}
|
||||
onOpenIntegrations={onOpenIntegrations}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* No recents yet — show suggestions full-width */
|
||||
<>
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.12em] text-fg-faint">
|
||||
Suggested for you
|
||||
</p>
|
||||
<div className="max-w-sm">
|
||||
<RecommendedPluginsCard
|
||||
profession={profession}
|
||||
setProfession={setProfession}
|
||||
connectedProviders={connectedProviders}
|
||||
hasMcp={hasMcp}
|
||||
onOpenPlugins={onOpenPlugins}
|
||||
onOpenIntegrations={onOpenIntegrations}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -96,14 +96,14 @@ export const FilePreview = memo(function FilePreview({
|
|||
className="w-4 h-4"
|
||||
/>
|
||||
<p
|
||||
className={cn(dmSansClassName(), "text-[10px] font-semibold")}
|
||||
className={cn(dmSansClassName(), "text-[11px] font-semibold")}
|
||||
style={{ color: color }}
|
||||
>
|
||||
{extension}
|
||||
</p>
|
||||
</div>
|
||||
{document.content && (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
{document.content}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -24,20 +24,20 @@ export function GoogleDocsPreview({
|
|||
url={document.url}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
|
||||
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
{document.summary ? (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
{document.summary}
|
||||
</p>
|
||||
) : document.content ? (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
{document.content}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
No summary available
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export function McpPreview({ document }: { document: DocumentWithMemories }) {
|
|||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[12px] font-semibold flex items-center gap-1",
|
||||
"text-[13px] font-semibold flex items-center gap-1",
|
||||
)}
|
||||
>
|
||||
<ClaudeDesktopIcon className="size-3" />
|
||||
|
|
@ -26,12 +26,12 @@ export function McpPreview({ document }: { document: DocumentWithMemories }) {
|
|||
</div>
|
||||
<div className="space-y-[6px]">
|
||||
{document.title && (
|
||||
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
|
||||
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
|
||||
{document.title}
|
||||
</p>
|
||||
)}
|
||||
{document.content && (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
{document.content}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export function NotePreview({ document }: { document: DocumentWithMemories }) {
|
|||
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<DocumentIcon type="note" className="w-4 h-4" />
|
||||
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
|
||||
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
|
||||
Note
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -23,14 +23,14 @@ export function NotePreview({ document }: { document: DocumentWithMemories }) {
|
|||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[12px] font-semibold line-clamp-2 leading-[125%]",
|
||||
"text-[13px] font-semibold line-clamp-2 leading-[125%]",
|
||||
)}
|
||||
>
|
||||
{document.title}
|
||||
</p>
|
||||
)}
|
||||
{document.summary && (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
{document.summary}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export function NotionPreview({
|
|||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] tracking-wide text-[#929292] uppercase",
|
||||
"text-[11px] tracking-wide text-[#929292] uppercase",
|
||||
)}
|
||||
>
|
||||
Notion
|
||||
|
|
@ -85,7 +85,7 @@ export function NotionPreview({
|
|||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[12px] font-semibold text-[#E5E5E5] line-clamp-2 leading-[140%]",
|
||||
"text-[13px] font-semibold text-[#E5E5E5] line-clamp-2 leading-[140%]",
|
||||
)}
|
||||
>
|
||||
{document.title}
|
||||
|
|
@ -128,6 +128,8 @@ export function NotionPreview({
|
|||
<svg
|
||||
viewBox="0 0 10 10"
|
||||
className="w-full h-full text-white"
|
||||
aria-label="Checked"
|
||||
role="img"
|
||||
>
|
||||
<path
|
||||
d="M2.5 5L4.5 7L7.5 3.5"
|
||||
|
|
@ -142,7 +144,7 @@ export function NotionPreview({
|
|||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[10px] line-clamp-1 leading-[140%]",
|
||||
"text-[11px] line-clamp-1 leading-[140%]",
|
||||
block.checked
|
||||
? "text-[#555] line-through"
|
||||
: "text-[#737373]",
|
||||
|
|
@ -158,7 +160,7 @@ export function NotionPreview({
|
|||
return (
|
||||
<div key={i} className="flex items-start gap-1.5">
|
||||
<div className="mt-[5px] w-[4px] h-[4px] rounded-full bg-[#555] shrink-0" />
|
||||
<p className="text-[10px] text-[#737373] line-clamp-1 leading-[140%]">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-1 leading-[140%]">
|
||||
{block.text}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -168,7 +170,7 @@ export function NotionPreview({
|
|||
return (
|
||||
<p
|
||||
key={i}
|
||||
className="text-[10px] text-[#737373] line-clamp-1 leading-[140%]"
|
||||
className="text-[11px] text-[#737373] line-clamp-1 leading-[140%]"
|
||||
>
|
||||
{block.text}
|
||||
</p>
|
||||
|
|
@ -176,7 +178,7 @@ export function NotionPreview({
|
|||
})}
|
||||
</div>
|
||||
) : document.summary ? (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
{document.summary}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
@ -189,7 +191,7 @@ export function NotionPreview({
|
|||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] font-semibold flex items-center gap-1",
|
||||
"text-[11px] font-semibold flex items-center gap-1",
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
|
|
@ -203,7 +205,7 @@ export function NotionPreview({
|
|||
{document.memoryEntries.length}
|
||||
</p>
|
||||
)}
|
||||
<p className={cn(dmSansClassName(), "text-[10px] text-[#737373]")}>
|
||||
<p className={cn(dmSansClassName(), "text-[11px] text-[#737373]")}>
|
||||
{new Date(document.createdAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ function CustomTweetHeader({
|
|||
<div className="flex gap-0.5 items-center">
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold leading-tight overflow-hidden text-[#fafafa] text-[12px] truncate tracking-[-0.12px]",
|
||||
"font-semibold leading-tight overflow-hidden text-[#fafafa] text-[13px] truncate tracking-[-0.12px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
@ -73,7 +73,7 @@ function CustomTweetHeader({
|
|||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"font-medium leading-tight overflow-hidden text-[#737373] text-[12px] truncate tracking-[-0.12px]",
|
||||
"font-medium leading-tight overflow-hidden text-[#737373] text-[13px] truncate tracking-[-0.12px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@ export const YoutubePreview = memo(function YoutubePreview({
|
|||
return (
|
||||
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
|
||||
{document.title && (
|
||||
<p className={cn(dmSansClassName(), "text-[12px] font-semibold")}>
|
||||
<p className={cn(dmSansClassName(), "text-[13px] font-semibold")}>
|
||||
{document.title}
|
||||
</p>
|
||||
)}
|
||||
{document.content && (
|
||||
<p className="text-[10px] text-[#737373] line-clamp-4">
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
{document.content}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -128,9 +128,15 @@ function TextDocumentIcon({ className }: { className?: string }) {
|
|||
|
||||
function XIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={cn("font-bold", className)} style={{ color: "#FFFFFF" }}>
|
||||
𝕏
|
||||
</span>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn("text-white", className)}
|
||||
>
|
||||
<title>X (Twitter)</title>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.746l7.73-8.835L1.254 2.25H8.08l4.253 5.622 5.911-5.622zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export function DocumentContent({
|
|||
<TweetContent
|
||||
url={document.url}
|
||||
tweetMetadata={document.metadata?.sm_internal_twitter_metadata}
|
||||
content={document.content}
|
||||
/>
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,14 @@ import { ExternalLinkIcon } from "lucide-react"
|
|||
interface TweetContentProps {
|
||||
url?: string | null
|
||||
tweetMetadata?: unknown
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
export function TweetContent({ url, tweetMetadata }: TweetContentProps) {
|
||||
export function TweetContent({
|
||||
url,
|
||||
tweetMetadata,
|
||||
content,
|
||||
}: TweetContentProps) {
|
||||
if (tweetMetadata) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center w-full p-4 overflow-auto">
|
||||
|
|
@ -18,6 +23,27 @@ export function TweetContent({ url, tweetMetadata }: TweetContentProps) {
|
|||
)
|
||||
}
|
||||
|
||||
if (content) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col w-full p-6 overflow-auto">
|
||||
<pre className="whitespace-pre-wrap text-sm text-[#E5E5E5] font-sans leading-relaxed">
|
||||
{content}
|
||||
</pre>
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-sm text-blue-400 hover:underline mt-4"
|
||||
>
|
||||
View on X
|
||||
<ExternalLinkIcon className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-gray-400">
|
||||
<p>Tweet preview unavailable</p>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ export function Summary({
|
|||
<p className="text-[16px] font-semibold text-[#FAFAFA] line-clamp-1 leading-[125%]">
|
||||
Summary
|
||||
</p>
|
||||
<div className="text-[#737373] text-[10px] leading-[150%]">
|
||||
<div className="flex items-center gap-1 text-[#737373] opacity-50 text-[10px] leading-[150%]">
|
||||
<SyncLogoIcon className="w-[10px] h-[10px]" />
|
||||
powered by supermemory
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ interface DocumentsCommandPaletteProps {
|
|||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
projectId: string
|
||||
novaContainerTags?: string[]
|
||||
onOpenDocument: (document: DocumentWithMemories) => void
|
||||
onAddMemory?: () => void
|
||||
onOpenIntegrations?: () => void
|
||||
|
|
@ -46,7 +45,6 @@ export function DocumentsCommandPalette({
|
|||
open,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
novaContainerTags,
|
||||
onOpenDocument,
|
||||
onAddMemory,
|
||||
onOpenIntegrations,
|
||||
|
|
@ -159,8 +157,7 @@ export function DocumentsCommandPalette({
|
|||
body: {
|
||||
q: search.trim(),
|
||||
limit: 10,
|
||||
containerTags:
|
||||
novaContainerTags ?? (projectId ? [projectId] : undefined),
|
||||
containerTags: projectId ? [projectId] : undefined,
|
||||
includeSummary: true,
|
||||
},
|
||||
signal: controller.signal,
|
||||
|
|
@ -178,7 +175,7 @@ export function DocumentsCommandPalette({
|
|||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [search, projectId, novaContainerTags])
|
||||
}, [search, projectId])
|
||||
|
||||
// Build the item list
|
||||
const hasQuery = search.trim().length > 0
|
||||
|
|
@ -202,7 +199,7 @@ export function DocumentsCommandPalette({
|
|||
// Reset selection on items change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [search, searchResults.length])
|
||||
}, [])
|
||||
|
||||
// Scroll selected into view
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { session, organizations, isRestoring } = useAuth()
|
||||
|
||||
const isMcpPublicPage = searchParams.get("view") === "mcp"
|
||||
|
||||
useEffect(() => {
|
||||
if (isMcpPublicPage) return
|
||||
if (isRestoring) return
|
||||
if (!session) {
|
||||
router.replace(
|
||||
|
|
@ -20,8 +24,8 @@ export function EnsureWorkspace({ children }: { children: React.ReactNode }) {
|
|||
if (organizations === null) return
|
||||
if (organizations.length > 0) return
|
||||
if (pathname.startsWith("/onboarding")) return
|
||||
router.replace("/onboarding/welcome?step=input")
|
||||
}, [session, organizations, isRestoring, pathname, router])
|
||||
router.replace("/onboarding")
|
||||
}, [session, organizations, isRestoring, pathname, router, isMcpPublicPage])
|
||||
|
||||
return children
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,7 @@ import { dmSansClassName } from "@/lib/fonts"
|
|||
import { ShareModal } from "./share-modal"
|
||||
import { shareParam } from "@/lib/search-params"
|
||||
|
||||
interface GraphLayoutViewProps {
|
||||
isChatOpen: boolean
|
||||
}
|
||||
|
||||
export const GraphLayoutView = memo<GraphLayoutViewProps>(({ isChatOpen }) => {
|
||||
export const GraphLayoutView = memo(function GraphLayoutView() {
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const { documentIds: allHighlightDocumentIds } = useGraphHighlights()
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useQueryState(
|
||||
|
|
@ -34,14 +30,14 @@ export const GraphLayoutView = memo<GraphLayoutViewProps>(({ isChatOpen }) => {
|
|||
}, [setIsShareModalOpen])
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-[calc(100vh-86px)]">
|
||||
<div className="relative h-full min-h-0 w-full">
|
||||
{/* Full-width graph */}
|
||||
<div className="absolute inset-0">
|
||||
<MemoryGraph
|
||||
containerTags={effectiveContainerTags}
|
||||
variant="consumer"
|
||||
highlightDocumentIds={allHighlightDocumentIds}
|
||||
highlightsVisible={isChatOpen}
|
||||
highlightsVisible
|
||||
maxNodes={undefined}
|
||||
canvasRef={canvasRef}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { Logo } from "@ui/assets/Logo"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import {
|
||||
LayoutGridIcon,
|
||||
Plus,
|
||||
SearchIcon,
|
||||
Settings,
|
||||
|
|
@ -13,11 +12,12 @@ import {
|
|||
ExternalLink,
|
||||
MenuIcon,
|
||||
MessageCircleIcon,
|
||||
LifeBuoy,
|
||||
LayoutGrid,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs"
|
||||
import { GraphIcon, IntegrationsIcon } from "@/components/integration-icons"
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
||||
import { useProject } from "@/stores"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
|
|
@ -34,17 +35,16 @@ import { useIsMobile } from "@hooks/use-mobile"
|
|||
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
|
||||
import { UserProfileMenu } from "@/components/user-profile-menu"
|
||||
import { FeedbackModal } from "./feedback-modal"
|
||||
import { useViewMode, type ViewMode } from "@/lib/view-mode-context"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { feedbackParam } from "@/lib/search-params"
|
||||
|
||||
interface HeaderProps {
|
||||
onAddMemory?: () => void
|
||||
onOpenChat?: () => void
|
||||
onOpenSearch?: () => void
|
||||
}
|
||||
|
||||
export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
||||
export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
||||
const { user, isRestoring } = useAuth()
|
||||
const { selectedProjects, setSelectedProjects } = useProject()
|
||||
const router = useRouter()
|
||||
|
|
@ -65,21 +65,21 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
|||
""
|
||||
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
|
||||
return (
|
||||
<div className="flex p-3 md:p-4 justify-between items-center gap-2">
|
||||
<div className="flex items-center justify-center gap-2 md:gap-4 z-10! min-w-0">
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between gap-1.5 p-2.5 md:gap-2 md:p-3">
|
||||
<div className="z-10! flex min-w-0 shrink items-center justify-center gap-1.5 md:gap-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center rounded-lg px-2 py-1.5 -ml-2 cursor-pointer hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-colors shrink-0"
|
||||
className="-ml-2 flex shrink-0 cursor-pointer items-center rounded-lg px-1.5 py-1 transition-colors hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
>
|
||||
<Logo className="h-7" />
|
||||
<Logo className="h-6 md:h-7" />
|
||||
{!isMobile && userName && (
|
||||
<div className="flex flex-col items-start justify-center ml-2">
|
||||
<p className="text-[#8B8B8B] text-[11px] leading-tight">
|
||||
<div className="ml-1.5 flex flex-col items-start justify-center sm:ml-2">
|
||||
<p className="text-[10px] leading-tight text-[#6B6B6B] sm:text-[11px]">
|
||||
{userName}
|
||||
</p>
|
||||
<p className="text-white font-bold text-xl leading-none -mt-1">
|
||||
<p className="-mt-0.5 text-base leading-none font-medium text-white/90 sm:text-lg">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -129,7 +129,6 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="self-stretch w-px bg-[#FFFFFF33] hidden md:block" />
|
||||
{!isMobile && (
|
||||
<SpaceSelector
|
||||
selectedProjects={selectedProjects}
|
||||
|
|
@ -140,47 +139,120 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
|||
)}
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<Tabs
|
||||
value={viewMode === "list" ? "grid" : viewMode}
|
||||
onValueChange={(v) =>
|
||||
setViewMode(v === "grid" ? "list" : (v as ViewMode))
|
||||
}
|
||||
>
|
||||
<TabsList className="rounded-full border border-[#161F2C] h-11! z-10!">
|
||||
<TabsTrigger
|
||||
value="grid"
|
||||
className={cn(
|
||||
"rounded-full data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-4 py-4 cursor-pointer",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<LayoutGridIcon className="size-4" />
|
||||
Grid
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="graph"
|
||||
className={cn(
|
||||
"rounded-full dark:data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-4 py-4 cursor-pointer",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<GraphIcon className="size-4" />
|
||||
Graph
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="integrations"
|
||||
className={cn(
|
||||
"rounded-full dark:data-[state=active]:bg-[#00173C]! dark:data-[state=active]:border-[#2261CA33]! px-4 py-4 cursor-pointer",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<IntegrationsIcon className="size-4" />
|
||||
Integrations
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="z-10! flex min-w-0 max-w-full flex-1 items-center justify-center gap-1.5 overflow-hidden px-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Home"
|
||||
aria-current={viewMode === "dashboard" ? "page" : undefined}
|
||||
onClick={() => void setViewMode("dashboard")}
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
|
||||
viewMode === "dashboard"
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<Home className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Home
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Content"
|
||||
aria-orientation="horizontal"
|
||||
className="text-muted-foreground z-10! inline-flex h-10 w-fit min-w-0 max-w-full items-center justify-center gap-0.5 overflow-x-auto rounded-full border border-[#161F2C] bg-muted p-1 [scrollbar-width:thin]"
|
||||
>
|
||||
{(
|
||||
[
|
||||
{
|
||||
mode: "integrations" as const,
|
||||
label: "Integrations",
|
||||
icon: IntegrationsIcon,
|
||||
},
|
||||
{ mode: "graph" as const, label: "Graph", icon: GraphIcon },
|
||||
{
|
||||
mode: "list" as const,
|
||||
label: "Memories",
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
] as const
|
||||
).map(({ mode, label, icon: Icon }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={
|
||||
mode === "integrations"
|
||||
? [
|
||||
"integrations",
|
||||
"mcp",
|
||||
"plugins",
|
||||
"chrome",
|
||||
"connections",
|
||||
"shortcuts",
|
||||
"raycast",
|
||||
"import",
|
||||
].includes(viewMode)
|
||||
: viewMode === mode
|
||||
}
|
||||
onClick={() => void setViewMode(mode)}
|
||||
className={cn(
|
||||
"inline-flex h-[calc(100%-1px)] min-h-0 cursor-pointer items-center justify-center gap-1 rounded-full border border-transparent px-2.5 text-xs font-medium whitespace-nowrap transition-colors sm:gap-1.5 sm:px-3 sm:text-sm",
|
||||
(
|
||||
mode === "integrations"
|
||||
? [
|
||||
"integrations",
|
||||
"mcp",
|
||||
"plugins",
|
||||
"chrome",
|
||||
"connections",
|
||||
"shortcuts",
|
||||
"raycast",
|
||||
"import",
|
||||
].includes(viewMode)
|
||||
: viewMode === mode
|
||||
)
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "text-foreground hover:bg-white/5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 sm:size-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Chat"
|
||||
aria-current={viewMode === "chat" ? "page" : undefined}
|
||||
onClick={() => void setViewMode("chat")}
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors",
|
||||
viewMode === "chat"
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "border-[#161F2C] bg-muted text-muted-foreground hover:bg-white/5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<MessageCircleIcon className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Chat
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 z-10!">
|
||||
<div className="z-10! flex shrink-0 items-center gap-1.5">
|
||||
{isMobile ? (
|
||||
<>
|
||||
<SpaceSelector
|
||||
|
|
@ -217,6 +289,13 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
|||
<Plus className="h-4 w-4 text-[#737373]" />
|
||||
Add memory
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void setViewMode("dashboard")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<Home className="h-4 w-4 text-[#737373]" />
|
||||
Home
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setViewMode("integrations")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
|
|
@ -225,7 +304,28 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
|||
Integrations
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onOpenChat}
|
||||
onClick={() => void setViewMode("graph")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<GraphIcon className="h-4 w-4 text-[#737373]" />
|
||||
Graph
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenSearch?.()}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<SearchIcon className="h-4 w-4 text-[#737373]" />
|
||||
Search
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void setViewMode("list")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4 text-[#737373]" />
|
||||
Memories
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => void setViewMode("chat")}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<MessageCircleIcon className="h-4 w-4 text-[#737373]" />
|
||||
|
|
@ -236,7 +336,7 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
|||
onClick={handleFeedback}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<MessageCircleIcon className="h-4 w-4 text-[#737373]" />
|
||||
<LifeBuoy className="h-4 w-4 text-[#737373]" />
|
||||
Feedback
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
|
|
@ -251,62 +351,48 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-2 h-10!"
|
||||
onClick={onAddMemory}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="size-4" />
|
||||
Add memory
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm size-4 text-[10px] flex items-center justify-center",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
C
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-2 h-10!"
|
||||
onClick={onOpenSearch}
|
||||
>
|
||||
<SearchIcon className="size-4" />
|
||||
<span className="bg-[#21212180] border border-[#73737333] text-[#737373] rounded-sm text-[10px] flex items-center justify-center gap-0.5 px-1">
|
||||
<svg
|
||||
className="size-[7.5px]"
|
||||
viewBox="0 0 9 9"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"rounded-full! h-9! min-h-9 shrink-0",
|
||||
"max-lg:w-9 max-lg:min-w-9 max-lg:justify-center max-lg:gap-0 max-lg:px-0",
|
||||
"lg:min-w-0 lg:gap-1.5 lg:px-3 lg:font-medium",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={onAddMemory}
|
||||
aria-label="Add memory"
|
||||
>
|
||||
<title>Command Key</title>
|
||||
<path
|
||||
d="M6.66663 0.416626C6.33511 0.416626 6.01716 0.548322 5.78274 0.782743C5.54832 1.01716 5.41663 1.33511 5.41663 1.66663V6.66663C5.41663 6.99815 5.54832 7.31609 5.78274 7.55051C6.01716 7.78493 6.33511 7.91663 6.66663 7.91663C6.99815 7.91663 7.31609 7.78493 7.55051 7.55051C7.78493 7.31609 7.91663 6.99815 7.91663 6.66663C7.91663 6.33511 7.78493 6.01716 7.55051 5.78274C7.31609 5.54832 6.99815 5.41663 6.66663 5.41663H1.66663C1.33511 5.41663 1.01716 5.54832 0.782743 5.78274C0.548322 6.01716 0.416626 6.33511 0.416626 6.66663C0.416626 6.99815 0.548322 7.31609 0.782743 7.55051C1.01716 7.78493 1.33511 7.91663 1.66663 7.91663C1.99815 7.91663 2.31609 7.78493 2.55051 7.55051C2.78493 7.31609 2.91663 6.99815 2.91663 6.66663V1.66663C2.91663 1.33511 2.78493 1.01716 2.55051 0.782743C2.31609 0.548322 1.99815 0.416626 1.66663 0.416626C1.33511 0.416626 1.01716 0.548322 0.782743 0.782743C0.548322 1.01716 0.416626 1.33511 0.416626 1.66663C0.416626 1.99815 0.548322 2.31609 0.782743 2.55051C1.01716 2.78493 1.33511 2.91663 1.66663 2.91663H6.66663C6.99815 2.91663 7.31609 2.78493 7.55051 2.55051C7.78493 2.31609 7.91663 1.99815 7.91663 1.66663C7.91663 1.33511 7.78493 1.01716 7.55051 0.782743C7.31609 0.548322 6.99815 0.416626 6.66663 0.416626Z"
|
||||
stroke="#737373"
|
||||
strokeWidth="0.833333"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className={cn(dmSansClassName())}>K</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="headers"
|
||||
className="rounded-full text-base gap-2 h-10!"
|
||||
onClick={handleFeedback}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircleIcon className="size-4" />
|
||||
Feedback
|
||||
</div>
|
||||
</Button>
|
||||
<Plus className="size-3.5 shrink-0 lg:size-4" />
|
||||
<span className="max-lg:sr-only">Add memory</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Add memory (C)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="headers"
|
||||
className={cn(
|
||||
"size-9! min-h-9 min-w-9 shrink-0 rounded-full! border-[#161F2C]/90 px-0! text-muted-foreground hover:text-foreground",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={onOpenSearch}
|
||||
aria-label="Search"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Search (⌘K)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<UserProfileMenu />
|
||||
<UserProfileMenu onOpenFeedback={handleFeedback} />
|
||||
</div>
|
||||
<FeedbackModal
|
||||
isOpen={feedbackOpen}
|
||||
|
|
@ -315,3 +401,56 @@ export function Header({ onAddMemory, onOpenChat, onOpenSearch }: HeaderProps) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PublicHeader() {
|
||||
return (
|
||||
<div className="relative z-10 flex shrink-0 items-center justify-between gap-2 p-2.5 md:p-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Logo className="h-6 md:h-7" />
|
||||
<div className="hidden sm:flex flex-col items-start">
|
||||
<p className="text-[10px] leading-tight text-[#6B6B6B]">Your AI</p>
|
||||
<p className="-mt-0.5 text-base leading-none font-medium text-white/90">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"hidden md:block text-[13px] text-[#4B5563]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Connect your tools, search everything.
|
||||
</p>
|
||||
<Link href="/login">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"text-[13px] font-medium text-[#8B8B8B] hover:text-white transition-colors px-3 h-8 cursor-pointer",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</Link>
|
||||
<Link href="/login/new">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-[13px] font-medium text-white",
|
||||
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 rounded-full px-4 h-8 transition-colors cursor-pointer",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Get started free
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Info,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Link2,
|
||||
} from "lucide-react"
|
||||
|
|
@ -103,14 +102,24 @@ export function HighlightsCard({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3 min-h-[180px] items-center justify-center",
|
||||
"bg-surface-card/60 backdrop-blur-md rounded-[18px] p-3 flex flex-col gap-3 shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<Loader2 className="size-5 animate-spin text-[#4BA0FA]" />
|
||||
<span className="text-[10px] text-[#737373]">
|
||||
Loading highlights...
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="size-[14px] rounded-full bg-[#1A2030] animate-pulse" />
|
||||
<div className="h-2 w-20 rounded bg-[#1A2030] animate-pulse" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="h-2.5 w-2/5 rounded bg-[#1A2030] animate-pulse" />
|
||||
<div className="h-2 w-full rounded bg-[#1A2030] animate-pulse" />
|
||||
<div className="h-2 w-[85%] rounded bg-[#1A2030] animate-pulse" />
|
||||
<div className="h-2 w-[65%] rounded bg-[#1A2030] animate-pulse" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-[26px] w-14 rounded-lg bg-[#1A2030] animate-pulse" />
|
||||
<div className="h-[26px] w-16 rounded-lg bg-[#1A2030] animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -119,7 +128,7 @@ export function HighlightsCard({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3 min-h-[180px]",
|
||||
"bg-surface-card/60 backdrop-blur-md rounded-[18px] p-3 flex flex-col gap-3 min-h-[180px] shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
@ -137,7 +146,7 @@ export function HighlightsCard({
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-[11px] text-[#737373] text-center">
|
||||
<p className="text-[11px] text-fg-muted text-center">
|
||||
Add some documents to see highlights here
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -148,7 +157,7 @@ export function HighlightsCard({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#0B1017] border border-[rgba(255,255,255,0.05)] rounded-[18px] p-3 flex flex-col gap-3",
|
||||
"bg-surface-card/60 backdrop-blur-md rounded-[18px] p-3 flex flex-col gap-3 shadow-[0_12px_40px_rgba(0,0,0,0.22)]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
@ -164,14 +173,14 @@ export function HighlightsCard({
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Info className="size-[14px] text-[#737373]" />
|
||||
<Info className="size-[14px] text-fg-subtle" />
|
||||
</div>
|
||||
|
||||
<div id="highlights-body" className="flex flex-col gap-1.5">
|
||||
<p className="text-[12px] font-semibold text-[#FAFAFA] leading-tight truncate">
|
||||
<p className="text-[12px] font-semibold text-fg-primary leading-tight truncate">
|
||||
{currentItem.title}
|
||||
</p>
|
||||
<div className="text-[12px] text-[#FAFAFA] leading-normal line-clamp-5">
|
||||
<div className="text-[12px] text-fg-primary leading-normal line-clamp-5">
|
||||
{renderContent(currentItem.content, currentItem.format)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -181,27 +190,27 @@ export function HighlightsCard({
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleChat}
|
||||
className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center gap-1.5 cursor-pointer relative"
|
||||
className="bg-[#182333] border border-surface-border rounded-[8px] px-2 py-1.5 flex items-center gap-1.5 cursor-pointer relative"
|
||||
style={{
|
||||
boxShadow: "0 4px 20px 0 rgba(0, 0, 0, 0.25)",
|
||||
}}
|
||||
aria-label="Chat with Nova"
|
||||
>
|
||||
<MessageSquare className="size-3.5 text-[#FAFAFA]" />
|
||||
<span className="text-[11px] text-[#FAFAFA]">Chat</span>
|
||||
<MessageSquare className="size-3.5 text-fg-primary" />
|
||||
<span className="text-[11px] text-fg-primary">Chat</span>
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_1px_0_rgba(255,255,255,0.1)]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShowRelated}
|
||||
className="bg-[#1B1F24] rounded-[8px] px-2 py-1.5 flex items-center gap-1.5 cursor-pointer relative"
|
||||
className="bg-[#182333] border border-surface-border rounded-[8px] px-2 py-1.5 flex items-center gap-1.5 cursor-pointer relative"
|
||||
style={{
|
||||
boxShadow: "0 4px 20px 0 rgba(0, 0, 0, 0.25)",
|
||||
}}
|
||||
aria-label="Show related"
|
||||
>
|
||||
<Link2 className="size-3.5 text-[#FAFAFA]" />
|
||||
<span className="text-[11px] text-[#FAFAFA]">Related</span>
|
||||
<Link2 className="size-3.5 text-fg-primary" />
|
||||
<span className="text-[11px] text-fg-primary">Related</span>
|
||||
<div className="absolute inset-0 pointer-events-none rounded-[inherit] shadow-[inset_1px_1px_1px_0_rgba(255,255,255,0.1)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -211,7 +220,7 @@ export function HighlightsCard({
|
|||
<button
|
||||
type="button"
|
||||
onClick={handlePrev}
|
||||
className="text-[#737373] hover:text-white transition-colors cursor-pointer"
|
||||
className="text-fg-subtle hover:text-white transition-colors cursor-pointer"
|
||||
aria-label="Previous item"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
|
|
@ -226,7 +235,7 @@ export function HighlightsCard({
|
|||
"rounded-full transition-all cursor-pointer",
|
||||
idx === activeIndex
|
||||
? "w-4 h-1.5 bg-[#4BA0FA]"
|
||||
: "size-1.5 bg-[#737373] hover:bg-[#999999]",
|
||||
: "size-1.5 bg-fg-subtle hover:bg-fg-secondary",
|
||||
)}
|
||||
aria-label={`Go to item ${idx + 1}`}
|
||||
/>
|
||||
|
|
@ -235,7 +244,7 @@ export function HighlightsCard({
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
className="text-[#737373] hover:text-white transition-colors cursor-pointer"
|
||||
className="text-fg-subtle hover:text-white transition-colors cursor-pointer"
|
||||
aria-label="Next item"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { hasActivePlan } from "@lib/queries"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import type { ConnectionResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { Button } from "@ui/components/button"
|
||||
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 { ConnectionsDetail } from "@/components/integrations/connections-detail"
|
||||
import { PluginsDetail } from "@/components/integrations/plugins-detail"
|
||||
import {
|
||||
ChromeIcon,
|
||||
AppleShortcutsIcon,
|
||||
|
|
@ -19,12 +18,15 @@ import {
|
|||
} from "@/components/integration-icons"
|
||||
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
|
||||
import { ArrowLeft, Sun } from "lucide-react"
|
||||
import {
|
||||
integrationParam,
|
||||
pluginsPanelParam,
|
||||
type IntegrationParamValue,
|
||||
} from "@/lib/search-params"
|
||||
import { CHROME_EXTENSION_URL } from "@repo/lib/constants"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import Image from "next/image"
|
||||
import { IntegrationGridCard } from "@/components/integrations/integration-grid-card"
|
||||
import { useViewMode } from "@/lib/view-mode-context"
|
||||
import { addDocumentParam, type ViewParamValue } from "@/lib/search-params"
|
||||
import { useQueryState } from "nuqs"
|
||||
|
||||
type Connection = z.infer<typeof ConnectionResponseSchema>
|
||||
|
||||
type CardId =
|
||||
| "mcp"
|
||||
|
|
@ -41,6 +43,7 @@ interface IntegrationCardDef {
|
|||
description: string
|
||||
icon: React.ReactNode
|
||||
pro?: boolean
|
||||
externalHref?: string
|
||||
}
|
||||
|
||||
const cards: IntegrationCardDef[] = [
|
||||
|
|
@ -108,6 +111,7 @@ const cards: IntegrationCardDef[] = [
|
|||
title: "Chrome Extension",
|
||||
description: "Save any webpage, import bookmarks, sync ChatGPT memories",
|
||||
icon: <ChromeIcon className="size-14" />,
|
||||
externalHref: CHROME_EXTENSION_URL,
|
||||
},
|
||||
{
|
||||
id: "shortcuts",
|
||||
|
|
@ -129,7 +133,7 @@ const cards: IntegrationCardDef[] = [
|
|||
},
|
||||
]
|
||||
|
||||
function DetailWrapper({
|
||||
export function DetailWrapper({
|
||||
onBack,
|
||||
children,
|
||||
}: {
|
||||
|
|
@ -153,74 +157,92 @@ function DetailWrapper({
|
|||
)
|
||||
}
|
||||
|
||||
const INTEGRATION_TO_CARD: Record<IntegrationParamValue, CardId> = {
|
||||
import: "import",
|
||||
chrome: "chrome",
|
||||
connections: "connections",
|
||||
}
|
||||
const CARD_GROUPS: Array<{ label: string; ids: CardId[] }> = [
|
||||
{ label: "AI tools", ids: ["plugins", "mcp"] },
|
||||
{
|
||||
label: "Apps & extensions",
|
||||
ids: ["connections", "chrome", "shortcuts", "raycast", "import"],
|
||||
},
|
||||
]
|
||||
|
||||
export function IntegrationsView() {
|
||||
const [integration, setIntegration] = useQueryState(
|
||||
"integration",
|
||||
integrationParam,
|
||||
)
|
||||
const [pluginsPanel, setPluginsPanel] = useQueryState(
|
||||
"plugins",
|
||||
pluginsPanelParam,
|
||||
)
|
||||
const [selectedCard, setSelectedCard] = useState<CardId | null>(null)
|
||||
const { setViewMode } = useViewMode()
|
||||
const [, setAddDoc] = useQueryState("add", addDocumentParam)
|
||||
const { org } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
const hasProProduct = hasActivePlan(autumn.customer?.products, "api_pro")
|
||||
|
||||
useEffect(() => {
|
||||
if (pluginsPanel === true) {
|
||||
setSelectedCard("plugins")
|
||||
return
|
||||
const { data: connections = [] } = useQuery({
|
||||
queryKey: ["connections"],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@post/connections/list", {
|
||||
body: { containerTags: [] },
|
||||
})
|
||||
if (response.error)
|
||||
throw new Error(response.error?.message || "Failed to load connections")
|
||||
return response.data as Connection[]
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
enabled: hasProProduct,
|
||||
})
|
||||
|
||||
const { data: facetsData } = useQuery({
|
||||
queryKey: ["document-facets", []],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@post/documents/documents/facets", {
|
||||
body: { containerTags: [] },
|
||||
disableValidation: true,
|
||||
})
|
||||
if (response.error)
|
||||
throw new Error(response.error?.message || "Failed to fetch facets")
|
||||
return response.data as {
|
||||
facets: Array<{ category: string; count: number }>
|
||||
total: number
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
type ApiKey = { metadata: Record<string, unknown> | null }
|
||||
const { data: apiKeys = [] } = useQuery({
|
||||
queryKey: ["api-keys", org?.id],
|
||||
queryFn: async () => {
|
||||
if (!org?.id) return []
|
||||
const data = (await authClient.apiKey.list({
|
||||
fetchOptions: { query: { metadata: { organizationId: org.id } } },
|
||||
})) as unknown as ApiKey[]
|
||||
return data.filter((key) => key.metadata?.organizationId === org.id)
|
||||
},
|
||||
enabled: !!org?.id,
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
const connectedPluginCount = apiKeys.filter(
|
||||
(key) => key.metadata?.sm_type === "plugin_auth",
|
||||
).length
|
||||
|
||||
const tweetCount =
|
||||
facetsData?.facets.find((f) => f.category === "tweet")?.count ?? 0
|
||||
|
||||
const getStatusLabel = (
|
||||
id: CardId,
|
||||
): { label: string; variant: "connected" | "neutral" } | undefined => {
|
||||
if (id === "connections" && hasProProduct) {
|
||||
return connections.length > 0
|
||||
? { label: `${connections.length} connected`, variant: "connected" }
|
||||
: { label: "Not connected", variant: "neutral" }
|
||||
}
|
||||
if (integration && INTEGRATION_TO_CARD[integration]) {
|
||||
setSelectedCard(INTEGRATION_TO_CARD[integration])
|
||||
if (id === "import") {
|
||||
return tweetCount > 0
|
||||
? { label: `${tweetCount} tweets imported`, variant: "connected" }
|
||||
: undefined
|
||||
}
|
||||
}, [integration, pluginsPanel])
|
||||
|
||||
const handleBack = () => {
|
||||
setSelectedCard(null)
|
||||
setIntegration(null)
|
||||
void setPluginsPanel(null)
|
||||
}
|
||||
|
||||
switch (selectedCard) {
|
||||
case "mcp":
|
||||
return <MCPDetailView onBack={handleBack} />
|
||||
case "import":
|
||||
return <XBookmarksDetailView onBack={handleBack} />
|
||||
case "chrome":
|
||||
return (
|
||||
<DetailWrapper onBack={handleBack}>
|
||||
<ChromeDetail />
|
||||
</DetailWrapper>
|
||||
)
|
||||
case "shortcuts":
|
||||
return (
|
||||
<DetailWrapper onBack={handleBack}>
|
||||
<ShortcutsDetail />
|
||||
</DetailWrapper>
|
||||
)
|
||||
case "raycast":
|
||||
return (
|
||||
<DetailWrapper onBack={handleBack}>
|
||||
<RaycastDetail />
|
||||
</DetailWrapper>
|
||||
)
|
||||
case "connections":
|
||||
return (
|
||||
<DetailWrapper onBack={handleBack}>
|
||||
<ConnectionsDetail />
|
||||
</DetailWrapper>
|
||||
)
|
||||
case "plugins":
|
||||
return (
|
||||
<DetailWrapper onBack={handleBack}>
|
||||
<PluginsDetail />
|
||||
</DetailWrapper>
|
||||
)
|
||||
if (id === "plugins") {
|
||||
return connectedPluginCount > 0
|
||||
? { label: `${connectedPluginCount} connected`, variant: "connected" }
|
||||
: undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -236,42 +258,53 @@ export function IntegrationsView() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{cards.map((card) => (
|
||||
<button
|
||||
key={card.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedCard(card.id)}
|
||||
className={cn(
|
||||
"bg-[#080B0F] relative rounded-xl p-4 pt-14",
|
||||
"border border-[#0D121A]",
|
||||
"hover:border-[#3374FF]/50",
|
||||
"transition-all duration-300 cursor-pointer text-left w-full",
|
||||
"hover:bg-[url('/onboarding/bg-gradient-1.png')] hover:bg-[length:200%_auto] hover:bg-[center_top_1rem] hover:bg-no-repeat",
|
||||
"group",
|
||||
)}
|
||||
>
|
||||
{card.pro && (
|
||||
<span className="absolute top-3 left-3 bg-[#4BA0FA] text-[#00171A] text-[10px] font-bold tracking-[0.3px] px-1.5 py-0.5 rounded-[3px]">
|
||||
PRO
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute top-2 right-2 opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
{card.icon}
|
||||
<div className="space-y-6">
|
||||
{CARD_GROUPS.map((group) => {
|
||||
const groupCards = cards.filter((c) => group.ids.includes(c.id))
|
||||
return (
|
||||
<div key={group.label}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-[#3A4455] shrink-0">
|
||||
{group.label}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-[#0F1621]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{groupCards.map((card) => {
|
||||
const status = getStatusLabel(card.id)
|
||||
return (
|
||||
<IntegrationGridCard
|
||||
key={card.id}
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
icon={card.icon}
|
||||
pro={card.pro}
|
||||
statusLabel={status?.label}
|
||||
statusVariant={status?.variant}
|
||||
isExternal={!!card.externalHref}
|
||||
onClick={() => {
|
||||
if (card.externalHref) {
|
||||
window.open(
|
||||
card.externalHref,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
analytics.onboardingChromeExtensionClicked({
|
||||
source: "integrations",
|
||||
})
|
||||
} else if (card.id === "connections") {
|
||||
void setAddDoc("connect")
|
||||
} else {
|
||||
void setViewMode(card.id as ViewParamValue)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-white text-sm font-medium">{card.title}</h3>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-xs leading-relaxed mt-0.5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,425 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { hasActivePlan } from "@lib/queries"
|
||||
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { Check, Plus, Trash2, Zap } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useQueryState } from "nuqs"
|
||||
import type { ConnectionResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { AddDocumentModal } from "@/components/add-document"
|
||||
import { RemoveConnectionDialog } from "@/components/remove-connection-dialog"
|
||||
import { addDocumentParam } from "@/lib/search-params"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import type { Project } from "@lib/types"
|
||||
|
||||
type Connection = z.infer<typeof ConnectionResponseSchema>
|
||||
|
||||
const CONNECTORS = {
|
||||
"google-drive": {
|
||||
title: "Google Drive",
|
||||
icon: GoogleDrive,
|
||||
documentLabel: "documents",
|
||||
},
|
||||
notion: { title: "Notion", icon: Notion, documentLabel: "pages" },
|
||||
onedrive: { title: "OneDrive", icon: OneDrive, documentLabel: "documents" },
|
||||
} as const
|
||||
|
||||
type ConnectorProvider = keyof typeof CONNECTORS
|
||||
|
||||
function ConnectionRow({
|
||||
connection,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
disabled,
|
||||
projects,
|
||||
}: {
|
||||
connection: Connection
|
||||
onDelete: () => void
|
||||
isDeleting: boolean
|
||||
disabled?: boolean
|
||||
projects: Project[]
|
||||
}) {
|
||||
const config = CONNECTORS[connection.provider as ConnectorProvider]
|
||||
if (!config) return null
|
||||
|
||||
const Icon = config.icon
|
||||
const isConnected =
|
||||
!connection.expiresAt || new Date(connection.expiresAt) > new Date()
|
||||
|
||||
const formatRelativeTime = (date: string | null | undefined) => {
|
||||
if (!date) return "Never"
|
||||
const d = new Date(date)
|
||||
const diffMs = Date.now() - d.getTime()
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
if (diffHours < 1) return "Just now"
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays === 1) return "Yesterday"
|
||||
if (diffDays < 7) return `${diffDays} days ago`
|
||||
return d.toLocaleDateString()
|
||||
}
|
||||
|
||||
const getProjectName = (tag: string): string => {
|
||||
if (tag === DEFAULT_PROJECT_ID) return "Default Project"
|
||||
return (
|
||||
projects.find((p) => p.containerTag === tag)?.name ??
|
||||
tag.replace(/^sm_project_/, "")
|
||||
)
|
||||
}
|
||||
|
||||
const documentCount = (connection.metadata?.documentCount as number) ?? 0
|
||||
const containerTags = (
|
||||
connection as Connection & { containerTags?: string[] }
|
||||
).containerTags
|
||||
const projectName = containerTags?.[0]
|
||||
? getProjectName(containerTags[0])
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] px-4 py-3",
|
||||
"shadow-[0px_1px_2px_0px_rgba(0,43,87,0.1)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon className="size-6 shrink-0" />
|
||||
<div className="flex-1 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{config.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"size-[7px] rounded-full",
|
||||
isConnected ? "bg-[#00AC3F]" : "bg-[#737373]",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px]",
|
||||
isConnected ? "text-[#00AC3F]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{isConnected ? "Connected" : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}
|
||||
>
|
||||
{connection.email || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting || disabled}
|
||||
className="text-[#737373] hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="size-[22px]" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{projectName && (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Project: {projectName}
|
||||
</span>
|
||||
<div className="size-[3px] rounded-full bg-[#737373]" />
|
||||
</>
|
||||
)}
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}
|
||||
>
|
||||
Added: {formatRelativeTime(connection.createdAt)}
|
||||
</span>
|
||||
<div className="size-[3px] rounded-full bg-[#737373]" />
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}
|
||||
>
|
||||
{documentCount} {config.documentLabel} connected
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConnectionsDetail() {
|
||||
const queryClient = useQueryClient()
|
||||
const autumn = useCustomer()
|
||||
const [isAddDocumentOpen, setIsAddDocumentOpen] = useState(false)
|
||||
const [removeDialog, setRemoveDialog] = useState<{
|
||||
open: boolean
|
||||
connection: Connection | null
|
||||
}>({ open: false, connection: null })
|
||||
const [, setAddDoc] = useQueryState("add", addDocumentParam)
|
||||
|
||||
const projects = (queryClient.getQueryData<Project[]>(["projects"]) ||
|
||||
[]) as Project[]
|
||||
|
||||
const hasProProduct = hasActivePlan(autumn.customer?.products, "api_pro")
|
||||
|
||||
const connectionsFeature = autumn.customer?.features?.connections
|
||||
const connectionsUsed = connectionsFeature?.usage ?? 0
|
||||
const connectionsLimit = connectionsFeature?.included_usage ?? 10
|
||||
const canAddConnection = connectionsUsed < connectionsLimit
|
||||
|
||||
const {
|
||||
data: connections = [],
|
||||
isLoading: isLoadingConnections,
|
||||
error: connectionsError,
|
||||
} = useQuery({
|
||||
queryKey: ["connections"],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@post/connections/list", {
|
||||
body: { containerTags: [] },
|
||||
})
|
||||
if (response.error)
|
||||
throw new Error(response.error?.message || "Failed to load connections")
|
||||
return response.data as Connection[]
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
enabled: hasProProduct,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionsError) {
|
||||
toast.error("Failed to load connections", {
|
||||
description:
|
||||
connectionsError instanceof Error
|
||||
? connectionsError.message
|
||||
: "Unknown error",
|
||||
})
|
||||
}
|
||||
}, [connectionsError])
|
||||
|
||||
const deleteConnectionMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
connectionId,
|
||||
deleteDocuments,
|
||||
}: {
|
||||
connectionId: string
|
||||
deleteDocuments: boolean
|
||||
}) => {
|
||||
await $fetch(`@delete/connections/${connectionId}`, {
|
||||
query: { deleteDocuments },
|
||||
})
|
||||
return { deleteDocuments }
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
analytics.connectionDeleted()
|
||||
toast.success(
|
||||
variables.deleteDocuments
|
||||
? "Connection removal has started. Documents will be permanently deleted in the next few minutes."
|
||||
: "Connection removed. Your memories have been kept.",
|
||||
)
|
||||
setRemoveDialog({ open: false, connection: null })
|
||||
queryClient.invalidateQueries({ queryKey: ["connections"] })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to remove connection", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
try {
|
||||
await autumn.attach({
|
||||
productId: "api_pro",
|
||||
successUrl: "https://app.supermemory.ai/?view=integrations",
|
||||
})
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = autumn.isLoading
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#14161A] rounded-[14px] p-6 relative overflow-hidden",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)}
|
||||
>
|
||||
{!hasProProduct && !isLoading && (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-[#14161A]/80 backdrop-blur-sm z-5" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Zap className="size-6 text-[#737373]" />
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] text-[#737373] text-center max-w-[220px]",
|
||||
)}
|
||||
>
|
||||
Connect Google Drive, Notion, and OneDrive to import your
|
||||
knowledge
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
"Unlimited memories",
|
||||
"10 connections",
|
||||
"Advanced search",
|
||||
"Priority support",
|
||||
].map((text) => (
|
||||
<div key={text} className="flex items-center gap-2">
|
||||
<Check className="size-4 shrink-0 text-[#4BA0FA]" />
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] text-white",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpgrade}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2",
|
||||
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 text-white",
|
||||
"rounded-full h-10 px-6 font-medium text-sm transition-colors cursor-pointer",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-4",
|
||||
!hasProProduct && !isLoading && "opacity-30 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Connected to Supermemory
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{connections.length}/{connectionsLimit} connections used
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isLoadingConnections ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="size-6 border-2 border-[#737373] border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : connections.length > 0 ? (
|
||||
connections.map((connection) => (
|
||||
<ConnectionRow
|
||||
key={connection.id}
|
||||
connection={connection}
|
||||
onDelete={() => setRemoveDialog({ open: true, connection })}
|
||||
isDeleting={deleteConnectionMutation.isPending}
|
||||
disabled={!hasProProduct}
|
||||
projects={projects}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Zap className="size-6 text-[#737373] mb-2" />
|
||||
<p
|
||||
className={cn(dmSans125ClassName(), "text-[14px] text-[#737373]")}
|
||||
>
|
||||
No connections yet
|
||||
</p>
|
||||
<p
|
||||
className={cn(dmSans125ClassName(), "text-[12px] text-[#737373]")}
|
||||
>
|
||||
Connect a service below to import your knowledge
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddDocumentModal
|
||||
isOpen={isAddDocumentOpen}
|
||||
onClose={() => setIsAddDocumentOpen(false)}
|
||||
/>
|
||||
|
||||
<RemoveConnectionDialog
|
||||
open={removeDialog.open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRemoveDialog({ open: false, connection: null })
|
||||
}}
|
||||
provider={removeDialog.connection?.provider}
|
||||
documentCount={
|
||||
(removeDialog.connection?.metadata?.documentCount as number) ?? 0
|
||||
}
|
||||
onConfirm={(deleteDocuments) => {
|
||||
if (removeDialog.connection) {
|
||||
deleteConnectionMutation.mutate({
|
||||
connectionId: removeDialog.connection.id,
|
||||
deleteDocuments,
|
||||
})
|
||||
}
|
||||
}}
|
||||
isDeleting={deleteConnectionMutation.isPending}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddDoc("connect")}
|
||||
disabled={!hasProProduct || !canAddConnection}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center gap-2",
|
||||
"bg-[#0D121A] rounded-full h-11 px-4 w-full",
|
||||
"cursor-pointer transition-opacity hover:opacity-80",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
<Plus className="size-[10px] text-[#FAFAFA]" />
|
||||
<span className="text-[14px] text-[#FAFAFA] font-medium">
|
||||
Connect knowledge bases
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
apps/web/components/integrations/integration-grid-card.tsx
Normal file
76
apps/web/components/integrations/integration-grid-card.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
|
||||
export function IntegrationGridCard({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
pro,
|
||||
statusLabel,
|
||||
statusVariant = "neutral",
|
||||
isExternal,
|
||||
onClick,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
icon: ReactNode
|
||||
pro?: boolean
|
||||
statusLabel?: string
|
||||
statusVariant?: "connected" | "neutral"
|
||||
isExternal?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"bg-[#080B0F] relative rounded-xl p-4 pt-14",
|
||||
"border border-[#0D121A]",
|
||||
"hover:border-[#3374FF]/50",
|
||||
"transition-all duration-300 cursor-pointer text-left w-full",
|
||||
"hover:bg-[url('/onboarding/bg-gradient-1.png')] hover:bg-[length:200%_auto] hover:bg-[center_top_1rem] hover:bg-no-repeat",
|
||||
"group",
|
||||
)}
|
||||
>
|
||||
{pro ? (
|
||||
<span className="absolute top-3 left-3 bg-[#4BA0FA] text-[#00171A] text-[10px] font-bold tracking-[0.3px] px-1.5 py-0.5 rounded-[3px]">
|
||||
PRO
|
||||
</span>
|
||||
) : null}
|
||||
{isExternal ? (
|
||||
<ExternalLink className="absolute top-3 left-3 size-3 text-[#3A4455] opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
) : null}
|
||||
<div className="absolute top-2 right-2 opacity-60 group-hover:opacity-100 transition-opacity">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-white text-sm font-medium">{title}</h3>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-xs leading-relaxed mt-0.5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
{statusLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block mt-2 text-[10px] font-medium px-1.5 py-0.5 rounded-full",
|
||||
statusVariant === "connected"
|
||||
? "bg-[#00AC3F]/10 text-[#00AC3F]"
|
||||
: "bg-[#737373]/10 text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,12 +10,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Brain,
|
||||
Check,
|
||||
CheckCircle,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Key,
|
||||
Loader,
|
||||
Trash2,
|
||||
Zap,
|
||||
|
|
@ -30,7 +28,6 @@ import {
|
|||
DialogTitle,
|
||||
DialogPortal,
|
||||
} from "@ui/components/dialog"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/components/tabs"
|
||||
|
||||
/** Match `FREE_TIER_PLUGIN_IDS` in mono `packages/lib/plugins.ts`. */
|
||||
function isFreeTierPlugin(pluginId: string): boolean {
|
||||
|
|
@ -52,11 +49,11 @@ const PLUGIN_CATALOG: Record<string, PluginInfo> = {
|
|||
id: "claude_code",
|
||||
name: "Claude Code",
|
||||
description:
|
||||
"Persistent memory for Claude Code. Remembers your coding context, patterns, and decisions across sessions.",
|
||||
"Claude Code remembers your conventions, past decisions, and project context across every session — no re-explaining yourself.",
|
||||
features: [
|
||||
"Auto-recalls relevant context at session start",
|
||||
"Captures important observations from tool usage",
|
||||
"Builds persistent user profile from interactions",
|
||||
"Picks up where you left off at session start",
|
||||
"Captures decisions and patterns from tool usage",
|
||||
"Builds a persistent profile of how you work",
|
||||
],
|
||||
icon: "/images/plugins/claude-code.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/claude-code",
|
||||
|
|
@ -66,11 +63,11 @@ const PLUGIN_CATALOG: Record<string, PluginInfo> = {
|
|||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
description:
|
||||
"Memory layer for OpenCode. Enhances your coding assistant with long-term memory capabilities.",
|
||||
"Gives OpenCode persistent memory — your patterns, preferences, and decisions carry forward automatically, session to session.",
|
||||
features: [
|
||||
"Semantic search across previous sessions",
|
||||
"Auto-capture of coding decisions",
|
||||
"Context injection before each prompt",
|
||||
"Context injected before each prompt",
|
||||
],
|
||||
icon: "/images/plugins/opencode.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/opencode",
|
||||
|
|
@ -79,11 +76,11 @@ const PLUGIN_CATALOG: Record<string, PluginInfo> = {
|
|||
id: "openclaw",
|
||||
name: "OpenClaw",
|
||||
description:
|
||||
"Multi-platform memory for OpenClaw. Works across Telegram, WhatsApp, Discord, Slack and more.",
|
||||
"Persists memory across Telegram, WhatsApp, Discord, and Slack. OpenClaw knows who users are and what they talked about before.",
|
||||
features: [
|
||||
"Cross-channel memory persistence",
|
||||
"Cross-channel memory that follows the user",
|
||||
"Automatic conversation capture",
|
||||
"User profile building across platforms",
|
||||
"User profiles built across every platform",
|
||||
],
|
||||
icon: "/images/plugins/openclaw.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/openclaw",
|
||||
|
|
@ -92,11 +89,12 @@ const PLUGIN_CATALOG: Record<string, PluginInfo> = {
|
|||
hermes: {
|
||||
id: "hermes",
|
||||
name: "Hermes",
|
||||
description: "Memory layer for Hermes agent",
|
||||
description:
|
||||
"Hermes never forgets. Conversations, user profiles, and context persist so every session feels like a continuation, not a cold start.",
|
||||
features: [
|
||||
"Semantic search across previous sessions",
|
||||
"Auto-capture of conversation context",
|
||||
"Builds persistent user profile from interactions",
|
||||
"Persistent user profile built over time",
|
||||
],
|
||||
icon: "/images/plugins/hermes.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/hermes",
|
||||
|
|
@ -113,119 +111,27 @@ interface ConnectedPlugin {
|
|||
keyStart?: string | null
|
||||
}
|
||||
|
||||
function ProUpgradeBanner({ onUpgrade }: { onUpgrade: () => void }) {
|
||||
function ProUpgradeNudge({ onUpgrade }: { onUpgrade: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-gradient-to-br from-[#0D121A] to-[#14161A] rounded-[14px] p-6 border border-[#4BA0FA]/20 mb-6",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#4BA0FA]/10 shrink-0">
|
||||
<Zap className="size-6 text-[#4BA0FA]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[18px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Unlock Pro plugins
|
||||
</h3>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[14px] text-[#737373] mt-1",
|
||||
)}
|
||||
>
|
||||
Connect Claude Code, OpenCode, OpenClaw, Cursor, and more with a
|
||||
Pro plan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
icon: Brain,
|
||||
title: "Context Retention",
|
||||
desc: "AI remembers your preferences across sessions",
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: "Instant Recall",
|
||||
desc: "Past decisions surface automatically when relevant",
|
||||
},
|
||||
{
|
||||
icon: Key,
|
||||
title: "Secure & Private",
|
||||
desc: "Your data stays yours with encrypted storage",
|
||||
},
|
||||
].map(({ icon: Icon, title, desc }) => (
|
||||
<div key={title} className="flex items-start gap-2.5">
|
||||
<Icon className="mt-0.5 size-4 text-[#4BA0FA] shrink-0" />
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[11px] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{Object.values(PLUGIN_CATALOG)
|
||||
.filter((p) => !isFreeTierPlugin(p.id))
|
||||
.map((plugin) => (
|
||||
<div
|
||||
key={plugin.id}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]"
|
||||
>
|
||||
<Image
|
||||
alt={plugin.name}
|
||||
className="size-5"
|
||||
height={20}
|
||||
src={plugin.icon}
|
||||
width={20}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<span
|
||||
className={cn(dmSans125ClassName(), "text-[12px] text-[#737373]")}
|
||||
>
|
||||
Claude Code, OpenCode, OpenClaw & more
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpgrade}
|
||||
className={cn(
|
||||
"w-full sm:w-auto flex items-center justify-center gap-2",
|
||||
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 text-white",
|
||||
"rounded-full h-11 px-6 font-medium text-sm transition-colors cursor-pointer",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-3 bg-[#4BA0FA]/5 border border-[#4BA0FA]/20 rounded-xl px-4 py-3 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="size-4 text-[#4BA0FA] shrink-0" />
|
||||
<p className={cn(dmSans125ClassName(), "text-[13px] text-[#8B8B8B]")}>
|
||||
Unlock Claude Code, OpenCode, OpenClaw and more with{" "}
|
||||
<span className="text-white font-medium">Pro</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUpgrade}
|
||||
className={cn(
|
||||
"shrink-0 flex items-center gap-1.5 text-[12px] font-medium text-white",
|
||||
"bg-[#4BA0FA] hover:bg-[#4BA0FA]/90 rounded-full px-3 h-7 transition-colors cursor-pointer",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Upgrade
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -321,11 +227,20 @@ function PluginCard({
|
|||
<div
|
||||
className={cn(
|
||||
"bg-[#0D121A] rounded-[12px] p-4 flex flex-col gap-3 border",
|
||||
isConnected ? "border-[#4BA0FA]/30" : "border-[rgba(82,89,102,0.2)]",
|
||||
isConnected
|
||||
? "border-[#4BA0FA]/30"
|
||||
: needsProUpgrade
|
||||
? "border-[rgba(82,89,102,0.12)]"
|
||||
: "border-[rgba(82,89,102,0.2)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[#1E293B] bg-[#080B0F]",
|
||||
needsProUpgrade && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
alt={plugin.name}
|
||||
className="size-6"
|
||||
|
|
@ -339,7 +254,8 @@ function PluginCard({
|
|||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-medium text-[14px] text-[#FAFAFA]",
|
||||
"font-medium text-[14px]",
|
||||
needsProUpgrade ? "text-[#737373]" : "text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{plugin.name}
|
||||
|
|
@ -349,11 +265,17 @@ function PluginCard({
|
|||
<CheckCircle className="size-2.5" /> Connected
|
||||
</span>
|
||||
)}
|
||||
{needsProUpgrade && (
|
||||
<span className="text-[10px] font-bold text-[#00171A] bg-[#4BA0FA] px-1.5 py-0.5 rounded-[3px]">
|
||||
PRO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] text-[#737373] mt-0.5",
|
||||
"text-[12px] mt-0.5",
|
||||
needsProUpgrade ? "text-[#4B5563]" : "text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{plugin.description}
|
||||
|
|
@ -361,7 +283,7 @@ function PluginCard({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-1.5">
|
||||
<ul className={cn("space-y-1.5", needsProUpgrade && "opacity-40")}>
|
||||
{plugin.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2">
|
||||
<ArrowRight className="mt-0.5 size-3 shrink-0 text-[#4BA0FA]" />
|
||||
|
|
@ -524,16 +446,6 @@ export function PluginsDetail() {
|
|||
[connectedPlugins],
|
||||
)
|
||||
|
||||
const freeConnected = useMemo(
|
||||
() => connectedPlugins.filter((p) => isFreeTierPlugin(p.pluginId)),
|
||||
[connectedPlugins],
|
||||
)
|
||||
|
||||
const proConnected = useMemo(
|
||||
() => connectedPlugins.filter((p) => !isFreeTierPlugin(p.pluginId)),
|
||||
[connectedPlugins],
|
||||
)
|
||||
|
||||
const createPluginKeyMutation = useMutation({
|
||||
mutationFn: async (pluginId: string) => {
|
||||
const API_URL =
|
||||
|
|
@ -607,31 +519,11 @@ export function PluginsDetail() {
|
|||
const isLoading = autumn.isLoading
|
||||
const availablePlugins = pluginsData?.plugins ?? Object.keys(PLUGIN_CATALOG)
|
||||
|
||||
const freePluginIds = useMemo(() => {
|
||||
const ids = new Set(
|
||||
availablePlugins.filter(
|
||||
(id) => PLUGIN_CATALOG[id] && isFreeTierPlugin(id),
|
||||
),
|
||||
)
|
||||
if (PLUGIN_CATALOG.hermes) ids.add("hermes")
|
||||
return [...ids]
|
||||
}, [availablePlugins])
|
||||
|
||||
const proPluginIds = useMemo(
|
||||
() =>
|
||||
availablePlugins.filter(
|
||||
(id) => PLUGIN_CATALOG[id] && !isFreeTierPlugin(id),
|
||||
),
|
||||
[availablePlugins],
|
||||
)
|
||||
|
||||
const allCatalogPluginIds = useMemo(
|
||||
() => availablePlugins.filter((id) => PLUGIN_CATALOG[id]),
|
||||
[availablePlugins],
|
||||
)
|
||||
|
||||
const showPaidAllInOne = !isLoading && hasProProduct
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
|
|
@ -640,221 +532,66 @@ export function PluginsDetail() {
|
|||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)}
|
||||
>
|
||||
{showPaidAllInOne ? (
|
||||
<div className="flex flex-col gap-6">
|
||||
{connectedPlugins.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Connected
|
||||
</span>
|
||||
{connectedPlugins.map((plugin) => (
|
||||
<ConnectedPluginRow
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
info={PLUGIN_CATALOG[plugin.pluginId]}
|
||||
onRevoke={handleRevoke}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!hasProProduct && !isLoading && (
|
||||
<ProUpgradeNudge onUpgrade={handleUpgrade} />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{connectedPlugins.length > 0
|
||||
? "Add more plugins"
|
||||
: "Available plugins"}
|
||||
</span>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{allCatalogPluginIds.map((pluginId) => {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
if (!plugin) return null
|
||||
const isConnected = connectedPluginIds.includes(pluginId)
|
||||
const isCurrentlyConnecting = connectingPlugin === pluginId
|
||||
return (
|
||||
<PluginCard
|
||||
key={pluginId}
|
||||
plugin={plugin}
|
||||
pluginId={pluginId}
|
||||
isConnected={isConnected}
|
||||
isCurrentlyConnecting={isCurrentlyConnecting}
|
||||
connectingPlugin={connectingPlugin}
|
||||
needsProUpgrade={false}
|
||||
onConnect={(id) => createPluginKeyMutation.mutate(id)}
|
||||
onUpgrade={handleUpgrade}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="free" className="gap-0">
|
||||
<TabsList
|
||||
{connectedPlugins.length > 0 && (
|
||||
<div className="flex flex-col gap-3 mb-6">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-auto w-full grid-cols-2 gap-0 rounded-none border-0 border-b border-[#252a33] bg-transparent p-0",
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<TabsTrigger
|
||||
value="free"
|
||||
className={cn(
|
||||
"relative flex min-h-12 w-full min-w-0 cursor-pointer items-center justify-center rounded-none border-0 border-transparent bg-transparent px-3 py-3 text-[15px] font-medium shadow-none",
|
||||
"text-[#737373] hover:text-[#FAFAFA] transition-colors",
|
||||
"data-[state=active]:bg-transparent data-[state=active]:text-[#FAFAFA] data-[state=active]:shadow-none",
|
||||
"after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:rounded-t-[1px] after:bg-[#4BA0FA] after:opacity-0 data-[state=active]:after:opacity-100",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Free plugins
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="pro"
|
||||
className={cn(
|
||||
"relative flex min-h-12 w-full min-w-0 cursor-pointer items-center justify-center rounded-none border-0 border-transparent bg-transparent px-3 py-3 text-[15px] font-medium shadow-none",
|
||||
"text-[#737373] hover:text-[#FAFAFA] transition-colors",
|
||||
"data-[state=active]:bg-transparent data-[state=active]:text-[#FAFAFA] data-[state=active]:shadow-none",
|
||||
"after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:rounded-t-[1px] after:bg-[#4BA0FA] after:opacity-0 data-[state=active]:after:opacity-100",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Pro plugins
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="free" className="mt-5">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] text-[#737373] mb-4",
|
||||
)}
|
||||
>
|
||||
Included on every plan — connect with no upgrade.
|
||||
</p>
|
||||
|
||||
{freeConnected.length > 0 && (
|
||||
<div className="flex flex-col gap-3 mb-6">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Connected
|
||||
</span>
|
||||
{freeConnected.map((plugin) => (
|
||||
<ConnectedPluginRow
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
info={PLUGIN_CATALOG[plugin.pluginId]}
|
||||
onRevoke={handleRevoke}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{freeConnected.length > 0 ? "Add or manage" : "Available"}
|
||||
</span>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{freePluginIds.map((pluginId) => {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
if (!plugin) return null
|
||||
const isConnected = connectedPluginIds.includes(pluginId)
|
||||
const isCurrentlyConnecting = connectingPlugin === pluginId
|
||||
return (
|
||||
<PluginCard
|
||||
key={pluginId}
|
||||
plugin={plugin}
|
||||
pluginId={pluginId}
|
||||
isConnected={isConnected}
|
||||
isCurrentlyConnecting={isCurrentlyConnecting}
|
||||
connectingPlugin={connectingPlugin}
|
||||
needsProUpgrade={false}
|
||||
onConnect={(id) => createPluginKeyMutation.mutate(id)}
|
||||
onUpgrade={handleUpgrade}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pro" className="mt-5">
|
||||
{!hasProProduct && !isLoading && (
|
||||
<ProUpgradeBanner onUpgrade={handleUpgrade} />
|
||||
)}
|
||||
|
||||
{proConnected.length > 0 && (
|
||||
<div className="flex flex-col gap-3 mb-6">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
Connected
|
||||
</span>
|
||||
{proConnected.map((plugin) => (
|
||||
<ConnectedPluginRow
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
info={PLUGIN_CATALOG[plugin.pluginId]}
|
||||
onRevoke={handleRevoke}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{proConnected.length > 0 ? "Add more" : "Available plugins"}
|
||||
</span>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{proPluginIds.map((pluginId) => {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
if (!plugin) return null
|
||||
const isConnected = connectedPluginIds.includes(pluginId)
|
||||
const isCurrentlyConnecting = connectingPlugin === pluginId
|
||||
const needsProUpgrade = !hasProProduct
|
||||
return (
|
||||
<PluginCard
|
||||
key={pluginId}
|
||||
plugin={plugin}
|
||||
pluginId={pluginId}
|
||||
isConnected={isConnected}
|
||||
isCurrentlyConnecting={isCurrentlyConnecting}
|
||||
connectingPlugin={connectingPlugin}
|
||||
needsProUpgrade={needsProUpgrade}
|
||||
onConnect={(id) => createPluginKeyMutation.mutate(id)}
|
||||
onUpgrade={handleUpgrade}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
Connected
|
||||
</span>
|
||||
{connectedPlugins.map((plugin) => (
|
||||
<ConnectedPluginRow
|
||||
key={plugin.id}
|
||||
plugin={plugin}
|
||||
info={PLUGIN_CATALOG[plugin.pluginId]}
|
||||
onRevoke={handleRevoke}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{connectedPlugins.length > 0
|
||||
? "Add more plugins"
|
||||
: "Available plugins"}
|
||||
</span>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{allCatalogPluginIds.map((pluginId) => {
|
||||
const plugin = PLUGIN_CATALOG[pluginId]
|
||||
if (!plugin) return null
|
||||
const isConnected = connectedPluginIds.includes(pluginId)
|
||||
const isCurrentlyConnecting = connectingPlugin === pluginId
|
||||
const needsProUpgrade =
|
||||
!isLoading && !hasProProduct && !isFreeTierPlugin(pluginId)
|
||||
return (
|
||||
<PluginCard
|
||||
key={pluginId}
|
||||
plugin={plugin}
|
||||
pluginId={pluginId}
|
||||
isConnected={isConnected}
|
||||
isCurrentlyConnecting={isCurrentlyConnecting}
|
||||
connectingPlugin={connectingPlugin}
|
||||
needsProUpgrade={needsProUpgrade}
|
||||
onConnect={(id) => createPluginKeyMutation.mutate(id)}
|
||||
onUpgrade={handleUpgrade}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
|
|
|
|||
|
|
@ -652,31 +652,33 @@ interface MCPDetailViewProps {
|
|||
|
||||
export function MCPDetailView({ onBack }: MCPDetailViewProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col p-6 md:p-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto self-start p-0 text-[#FAFAFA] hover:text-[#BFBFBF] hover:no-underline"
|
||||
onClick={onBack}
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-1 w-full flex-col p-6 md:p-8">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto self-start p-0 text-[#FAFAFA] hover:text-[#BFBFBF] hover:no-underline"
|
||||
onClick={onBack}
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-start">
|
||||
<h1
|
||||
className={cn(
|
||||
"mb-1 text-2xl font-semibold tracking-[-0.02em] text-[#FAFAFA]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Connect Supermemory MCP to your AI Tools
|
||||
</h1>
|
||||
<p className={cn("mb-4 text-sm text-[#737373]", dmSansClassName())}>
|
||||
Connect Cursor, Claude, VS Code, and more via MCP.
|
||||
</p>
|
||||
<div className="flex flex-1 flex-col items-center justify-start">
|
||||
<h1
|
||||
className={cn(
|
||||
"mb-1 text-2xl font-semibold tracking-[-0.02em] text-[#FAFAFA]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Connect Supermemory MCP to your AI Tools
|
||||
</h1>
|
||||
<p className={cn("mb-4 text-sm text-[#737373]", dmSansClassName())}>
|
||||
Connect Cursor, Claude, VS Code, and more via MCP.
|
||||
</p>
|
||||
|
||||
<MCPSteps variant="full" />
|
||||
<MCPSteps variant="full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,8 +26,7 @@ import { McpPreview } from "./document-cards/mcp-preview"
|
|||
import { NotionPreview } from "./document-cards/notion-preview"
|
||||
import { getFaviconUrl } from "@/lib/url-helpers"
|
||||
import { QuickNoteCard } from "./quick-note-card"
|
||||
import { HighlightsCard, type HighlightItem } from "./highlights-card"
|
||||
import { GraphCard } from "./memory-graph"
|
||||
import type { HighlightItem } from "./highlights-card"
|
||||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
categoriesParam,
|
||||
|
|
@ -44,7 +43,17 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@ui/components/alert-dialog"
|
||||
import { CheckIcon, Loader, Trash2Icon, XIcon } from "lucide-react"
|
||||
import {
|
||||
AlignLeft,
|
||||
BoxSelect,
|
||||
CheckIcon,
|
||||
LayoutGrid,
|
||||
Loader,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import { useProcessingDocuments } from "@/hooks/use-processing-documents"
|
||||
import { TimelineView } from "./timeline-view"
|
||||
|
||||
// Document category type
|
||||
type DocumentCategory =
|
||||
|
|
@ -120,6 +129,7 @@ function fetchOgData(url: string): Promise<OgData | null> {
|
|||
|
||||
const PAGE_SIZE = 100
|
||||
const MAX_TOTAL = 1000
|
||||
const EMPTY_SET = new Set<string>()
|
||||
|
||||
const MEMORIES_LOADING_LABELS = [
|
||||
"Getting your supermemories…",
|
||||
|
|
@ -171,7 +181,15 @@ function MemoriesGridLoading() {
|
|||
}
|
||||
|
||||
// Discriminated union for masonry items
|
||||
type MasonryItem = { type: "document"; id: string; data: DocumentWithMemories }
|
||||
type MasonryItem =
|
||||
| {
|
||||
type: "document"
|
||||
id: string
|
||||
data: DocumentWithMemories
|
||||
isSelectionMode: boolean
|
||||
isSelected: boolean
|
||||
}
|
||||
| { type: "quick-note"; id: "quick-note" }
|
||||
|
||||
interface QuickNoteProps {
|
||||
onSave: (content: string) => void
|
||||
|
|
@ -214,7 +232,7 @@ export function MemoriesGrid({
|
|||
isChatOpen,
|
||||
onOpenDocument,
|
||||
isSelectionMode = false,
|
||||
selectedDocumentIds = new Set(),
|
||||
selectedDocumentIds = EMPTY_SET,
|
||||
onEnterSelectionMode,
|
||||
onToggleSelection,
|
||||
onClearSelection,
|
||||
|
|
@ -226,8 +244,18 @@ export function MemoriesGrid({
|
|||
emptyStateProps,
|
||||
}: MemoriesGridProps) {
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
const [localViewMode, setLocalViewMode] = useState<"grid" | "timeline">(
|
||||
() => {
|
||||
if (typeof window === "undefined") return "grid"
|
||||
return (
|
||||
(localStorage.getItem("memories-view-mode") as "grid" | "timeline") ??
|
||||
"grid"
|
||||
)
|
||||
},
|
||||
)
|
||||
const { user, isSessionPending } = useAuth()
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const processingStatusMap = useProcessingDocuments()
|
||||
const isMobile = useIsMobile()
|
||||
const [selectedCategories, setSelectedCategories] = useQueryState(
|
||||
"categories",
|
||||
|
|
@ -309,6 +337,11 @@ export function MemoriesGrid({
|
|||
enabled: !!user,
|
||||
})
|
||||
|
||||
const handleSetViewMode = useCallback((mode: "grid" | "timeline") => {
|
||||
setLocalViewMode(mode)
|
||||
localStorage.setItem("memories-view-mode", mode)
|
||||
}, [])
|
||||
|
||||
const handleCategoryToggle = useCallback(
|
||||
(category: DocumentCategory) => {
|
||||
setSelectedCategories((prev) => {
|
||||
|
|
@ -334,23 +367,33 @@ export function MemoriesGrid({
|
|||
}, [data])
|
||||
|
||||
const hasQuickNote = !!quickNoteProps
|
||||
const hasHighlights = !!highlightsProps
|
||||
const _hasHighlights = !!highlightsProps
|
||||
|
||||
const masonryItems: MasonryItem[] = useMemo(() => {
|
||||
const items: MasonryItem[] = []
|
||||
|
||||
if (!isMobile && hasQuickNote) {
|
||||
items.push({ type: "quick-note", id: "quick-note" })
|
||||
}
|
||||
|
||||
for (const doc of documents) {
|
||||
items.push({ type: "document", id: doc.id, data: doc })
|
||||
items.push({
|
||||
type: "document",
|
||||
id: doc.id,
|
||||
data: doc,
|
||||
isSelectionMode,
|
||||
isSelected: doc.id ? selectedDocumentIds.has(doc.id) : false,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}, [documents])
|
||||
}, [documents, isMobile, hasQuickNote, isSelectionMode, selectedDocumentIds])
|
||||
|
||||
// Stable key for Masonry based on document IDs, not item values
|
||||
const masonryKey = useMemo(() => {
|
||||
const docIds = documents.map((d) => d.id).join(",")
|
||||
return `masonry-${documents.length}-${docIds}-${isChatOpen}`
|
||||
}, [documents, isChatOpen])
|
||||
return `masonry-${documents.length}-${docIds}-${isChatOpen}-${hasQuickNote}`
|
||||
}, [documents, isChatOpen, hasQuickNote])
|
||||
|
||||
const isLoadingMore = isFetchingNextPage
|
||||
|
||||
|
|
@ -402,6 +445,22 @@ export function MemoriesGrid({
|
|||
onBulkDelete?.()
|
||||
}, [onBulkDelete])
|
||||
|
||||
// All mutable values the render function needs — kept in a ref so the
|
||||
// function identity never changes (masonic uses render as a React component
|
||||
// type, so a new reference unmounts every item and kills textarea focus).
|
||||
const renderRef = useRef({
|
||||
quickNoteProps,
|
||||
handleCardClick,
|
||||
onToggleSelection,
|
||||
processingStatusMap,
|
||||
})
|
||||
renderRef.current = {
|
||||
quickNoteProps,
|
||||
handleCardClick,
|
||||
onToggleSelection,
|
||||
processingStatusMap,
|
||||
}
|
||||
|
||||
const renderMasonryItem = useCallback(
|
||||
({
|
||||
index,
|
||||
|
|
@ -412,6 +471,16 @@ export function MemoriesGrid({
|
|||
data: MasonryItem
|
||||
width: number
|
||||
}) => {
|
||||
const r = renderRef.current
|
||||
|
||||
if (data.type === "quick-note") {
|
||||
return r.quickNoteProps ? (
|
||||
<div style={{ width }} className="p-2">
|
||||
<QuickNoteCard {...r.quickNoteProps} />
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
if (data.type === "document") {
|
||||
const doc = data.data
|
||||
return (
|
||||
|
|
@ -420,14 +489,17 @@ export function MemoriesGrid({
|
|||
index={index}
|
||||
data={doc}
|
||||
width={width}
|
||||
onClick={handleCardClick}
|
||||
isSelectionMode={isSelectionMode}
|
||||
isSelected={doc.id ? selectedDocumentIds.has(doc.id) : false}
|
||||
onClick={r.handleCardClick}
|
||||
isSelectionMode={data.isSelectionMode}
|
||||
isSelected={data.isSelected}
|
||||
onToggleSelection={
|
||||
doc.id && onToggleSelection
|
||||
? () => onToggleSelection(doc.id as string)
|
||||
doc.id && r.onToggleSelection
|
||||
? () => r.onToggleSelection?.(doc.id as string)
|
||||
: undefined
|
||||
}
|
||||
processingStatus={
|
||||
doc.id ? r.processingStatusMap.get(doc.id) : undefined
|
||||
}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
|
@ -435,7 +507,8 @@ export function MemoriesGrid({
|
|||
|
||||
return null
|
||||
},
|
||||
[handleCardClick, isSelectionMode, selectedDocumentIds, onToggleSelection],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
)
|
||||
|
||||
if (isSessionPending) {
|
||||
|
|
@ -455,12 +528,16 @@ export function MemoriesGrid({
|
|||
const isEmpty = documents.length === 0 && !isPending
|
||||
const showNovaEmptyState = isEmpty && emptyStateProps
|
||||
|
||||
const allVisibleSelected =
|
||||
documents.length > 0 &&
|
||||
documents.every((d) => d.id && selectedDocumentIds.has(d.id))
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{!isEmpty && (
|
||||
{!isEmpty && !isSelectionMode && (
|
||||
<div
|
||||
id="filter-pills"
|
||||
className="flex items-center justify-between gap-4 mb-3"
|
||||
className="flex items-center justify-between gap-4 mb-3 pr-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
|
|
@ -496,64 +573,130 @@ export function MemoriesGrid({
|
|||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isSelectionMode && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Exit selection mode"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full border border-[#161F2C] bg-[#0D121A] hover:bg-[#00173C] transition-colors cursor-pointer"
|
||||
onClick={onClearSelection}
|
||||
>
|
||||
<XIcon className="w-4 h-4 text-[#737373]" />
|
||||
</button>
|
||||
{selectedDocumentIds.size > 0 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-xs text-[#737373] hover:text-white transition-colors cursor-pointer",
|
||||
)}
|
||||
onClick={handleSelectAllVisible}
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"flex items-center gap-1 text-xs text-red-400 hover:text-red-300 transition-colors cursor-pointer disabled:opacity-50",
|
||||
)}
|
||||
onClick={handleBulkDeleteClick}
|
||||
disabled={isBulkDeleting}
|
||||
>
|
||||
<Trash2Icon className="w-3 h-3" />
|
||||
Delete ({selectedDocumentIds.size})
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p
|
||||
className={cn(dmSansClassName(), "text-xs text-[#737373]")}
|
||||
>
|
||||
Select one or more documents
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isSelectionMode && onEnterSelectionMode && (
|
||||
{/* View mode toggle — segmented control */}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="View mode"
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex h-8 items-center gap-0.5 rounded-full border border-[#161F2C] bg-[#0D121A] p-0.5",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Enter selection mode"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full border border-[#161F2C] bg-[#0D121A] hover:bg-[#00173C] transition-colors cursor-pointer"
|
||||
role="tab"
|
||||
aria-selected={localViewMode === "grid"}
|
||||
className={cn(
|
||||
"inline-flex h-full items-center justify-center gap-1.5 rounded-full border px-2.5 text-xs font-medium cursor-pointer transition-colors",
|
||||
localViewMode === "grid"
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "border-transparent text-[#737373] hover:bg-white/5",
|
||||
)}
|
||||
onClick={() => handleSetViewMode("grid")}
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
Grid
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={localViewMode === "timeline"}
|
||||
className={cn(
|
||||
"inline-flex h-full items-center justify-center gap-1.5 rounded-full border px-2.5 text-xs font-medium cursor-pointer transition-colors",
|
||||
localViewMode === "timeline"
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "border-transparent text-[#737373] hover:bg-white/5",
|
||||
)}
|
||||
onClick={() => handleSetViewMode("timeline")}
|
||||
>
|
||||
<AlignLeft className="w-3.5 h-3.5" />
|
||||
Timeline
|
||||
</button>
|
||||
</div>
|
||||
{onEnterSelectionMode && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Select documents"
|
||||
title="Select documents"
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full border border-[#161F2C] bg-[#0D121A] hover:bg-[#00173C] hover:border-[#2261CA33] transition-colors cursor-pointer"
|
||||
onClick={onEnterSelectionMode}
|
||||
>
|
||||
<div className="w-3 h-3 rounded-[2.25px] border border-[#737373]" />
|
||||
<BoxSelect className="w-4 h-4 text-[#737373]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEmpty && isSelectionMode && (
|
||||
<div
|
||||
id="selection-toolbar"
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"flex items-center justify-between gap-3 mb-3 mr-2 px-3 py-2 rounded-full border border-[#2261CA33] bg-[#00173C]/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="flex items-center gap-1.5 text-xs text-[#FAFAFA] font-medium shrink-0">
|
||||
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-[#369BFD] text-[#0B0F14] text-[11px] font-semibold">
|
||||
{selectedDocumentIds.size}
|
||||
</span>
|
||||
{selectedDocumentIds.size === 1 ? "selected" : "selected"}
|
||||
</span>
|
||||
{selectedDocumentIds.size === 0 && (
|
||||
<span className="text-xs text-[#737373] truncate">
|
||||
Tap documents to select
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"text-xs px-2.5 h-7 rounded-full transition-colors cursor-pointer",
|
||||
allVisibleSelected
|
||||
? "text-[#737373] hover:text-white"
|
||||
: "text-[#FAFAFA] hover:bg-white/5",
|
||||
)}
|
||||
onClick={
|
||||
allVisibleSelected ? onClearSelection : handleSelectAllVisible
|
||||
}
|
||||
>
|
||||
{allVisibleSelected ? "Deselect all" : "Select visible"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs px-3 h-7 rounded-full transition-colors cursor-pointer",
|
||||
selectedDocumentIds.size === 0 || isBulkDeleting
|
||||
? "text-[#737373]/60 cursor-not-allowed"
|
||||
: "text-red-400 hover:text-red-300 hover:bg-red-500/10",
|
||||
)}
|
||||
onClick={handleBulkDeleteClick}
|
||||
disabled={selectedDocumentIds.size === 0 || isBulkDeleting}
|
||||
>
|
||||
<Trash2Icon className="w-3 h-3" />
|
||||
<span>Delete</span>
|
||||
{selectedDocumentIds.size > 0 && (
|
||||
<span className="text-red-400/70">
|
||||
({selectedDocumentIds.size})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="w-px h-4 bg-[#161F2C] mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Exit selection mode"
|
||||
className="flex items-center gap-1 text-xs px-3 h-7 rounded-full text-[#737373] hover:text-white hover:bg-white/5 transition-colors cursor-pointer"
|
||||
onClick={onClearSelection}
|
||||
>
|
||||
<XIcon className="w-3 h-3" />
|
||||
<span>Done</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={showBulkDeleteConfirm}
|
||||
onOpenChange={setShowBulkDeleteConfirm}
|
||||
|
|
@ -620,41 +763,30 @@ export function MemoriesGrid({
|
|||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-auto scrollbar-thin">
|
||||
{!isMobile && (hasQuickNote || hasHighlights) && (
|
||||
<div className="flex gap-2 mb-2 px-2">
|
||||
{hasQuickNote && quickNoteProps && (
|
||||
<div className="w-[216px] shrink-0">
|
||||
<QuickNoteCard {...quickNoteProps} />
|
||||
</div>
|
||||
)}
|
||||
{hasHighlights && highlightsProps && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<HighlightsCard {...highlightsProps} />
|
||||
</div>
|
||||
)}
|
||||
<div className="w-[216px] shrink-0">
|
||||
<GraphCard
|
||||
containerTags={effectiveContainerTags}
|
||||
width={200}
|
||||
height={220}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{localViewMode === "timeline" ? (
|
||||
<TimelineView
|
||||
documents={documents}
|
||||
onOpenDocument={onOpenDocument}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onLoadMore={loadMoreDocuments}
|
||||
/>
|
||||
) : (
|
||||
<Masonry
|
||||
key={masonryKey}
|
||||
items={masonryItems}
|
||||
render={renderMasonryItem}
|
||||
columnGutter={0}
|
||||
rowGutter={0}
|
||||
columnWidth={260}
|
||||
maxColumnCount={isMobile ? 1 : undefined}
|
||||
itemHeightEstimate={200}
|
||||
overscanBy={3}
|
||||
onRender={maybeLoadMore}
|
||||
/>
|
||||
)}
|
||||
<Masonry
|
||||
key={masonryKey}
|
||||
items={masonryItems}
|
||||
render={renderMasonryItem}
|
||||
columnGutter={0}
|
||||
rowGutter={0}
|
||||
columnWidth={216}
|
||||
maxColumnCount={isMobile ? 1 : undefined}
|
||||
itemHeightEstimate={200}
|
||||
overscanBy={3}
|
||||
onRender={maybeLoadMore}
|
||||
/>
|
||||
|
||||
{isLoadingMore && (
|
||||
{isLoadingMore && localViewMode === "grid" && (
|
||||
<div className="py-10 flex items-center justify-center">
|
||||
<Loader className="size-10 animate-spin text-sky-400" />
|
||||
</div>
|
||||
|
|
@ -676,7 +808,7 @@ function DocumentUrlDisplay({ url }: { url: string }) {
|
|||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] text-[#737373] line-clamp-1",
|
||||
"text-[11px] text-[#737373] line-clamp-1",
|
||||
)}
|
||||
>
|
||||
{isLoading ? "YouTube" : channelName || "YouTube"}
|
||||
|
|
@ -688,7 +820,7 @@ function DocumentUrlDisplay({ url }: { url: string }) {
|
|||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] text-[#737373] line-clamp-1",
|
||||
"text-[11px] text-[#737373] line-clamp-1",
|
||||
)}
|
||||
>
|
||||
{getAbsoluteUrl(url)}
|
||||
|
|
@ -701,6 +833,74 @@ function isTemporaryId(id: string | null | undefined): boolean {
|
|||
return id.startsWith("temp-") || id.startsWith("temp-file-")
|
||||
}
|
||||
|
||||
const PROCESSING_WORDS = [
|
||||
"Reading",
|
||||
"Absorbing",
|
||||
"Scanning",
|
||||
"Thinking",
|
||||
"Connecting",
|
||||
"Pondering",
|
||||
"Synthesizing",
|
||||
"Reflecting",
|
||||
"Understanding",
|
||||
"Organizing",
|
||||
"Memorizing",
|
||||
"Filing",
|
||||
"Saving",
|
||||
"Learning",
|
||||
"Cataloguing",
|
||||
"Weaving",
|
||||
]
|
||||
|
||||
function ProcessingBadge() {
|
||||
const [wordIndex, setWordIndex] = useState(() =>
|
||||
Math.floor(Math.random() * PROCESSING_WORDS.length),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setWordIndex((i) => (i + 1) % PROCESSING_WORDS.length)
|
||||
}, 1800)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="relative flex h-1.5 w-1.5 shrink-0">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-sky-400" />
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] text-sky-400 font-medium",
|
||||
)}
|
||||
>
|
||||
{PROCESSING_WORDS[wordIndex]}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DoneBadge() {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckIcon
|
||||
className="w-2.5 h-2.5 text-emerald-400 shrink-0"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] text-emerald-400 font-medium",
|
||||
)}
|
||||
>
|
||||
Done
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DocumentCard = memo(
|
||||
({
|
||||
index: _index,
|
||||
|
|
@ -710,6 +910,7 @@ const DocumentCard = memo(
|
|||
isSelectionMode = false,
|
||||
isSelected = false,
|
||||
onToggleSelection,
|
||||
processingStatus,
|
||||
}: {
|
||||
index: number
|
||||
data: DocumentWithMemories
|
||||
|
|
@ -718,12 +919,26 @@ const DocumentCard = memo(
|
|||
isSelectionMode?: boolean
|
||||
isSelected?: boolean
|
||||
onToggleSelection?: () => void
|
||||
processingStatus?: string
|
||||
}) => {
|
||||
const canSelect =
|
||||
!isTemporaryId(document.id) && !isTemporaryId(document.customId)
|
||||
const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 })
|
||||
const cardRef = useRef<HTMLButtonElement>(null)
|
||||
const [ogData, setOgData] = useState<OgData | null>(null)
|
||||
const [showDone, setShowDone] = useState(false)
|
||||
const prevStatusRef = useRef<string | undefined>(processingStatus)
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevStatusRef.current
|
||||
prevStatusRef.current = processingStatus
|
||||
// Show the "done" checkmark briefly when the card leaves the processing map
|
||||
if (prev && !processingStatus) {
|
||||
setShowDone(true)
|
||||
const id = setTimeout(() => setShowDone(false), 2000)
|
||||
return () => clearTimeout(id)
|
||||
}
|
||||
}, [processingStatus])
|
||||
|
||||
const ogImage = (document as DocumentWithMemories & { ogImage?: string })
|
||||
.ogImage
|
||||
|
|
@ -847,15 +1062,16 @@ const DocumentCard = memo(
|
|||
) && (
|
||||
<div className="pb-[10px] space-y-1">
|
||||
{document.url &&
|
||||
!document.url.includes("x.com") &&
|
||||
!document.url.includes("twitter.com") &&
|
||||
!document.url.includes("files.supermemory.ai") && (
|
||||
!document.url.includes("files.supermemory.ai") &&
|
||||
(document.title ||
|
||||
(!document.url.includes("x.com") &&
|
||||
!document.url.includes("twitter.com"))) && (
|
||||
<div className="px-3">
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[12px] text-[#E5E5E5] line-clamp-1 font-semibold",
|
||||
"text-[13px] text-[#E5E5E5] line-clamp-1 font-semibold",
|
||||
)}
|
||||
>
|
||||
{document.title || ogData?.title || "Untitled Document"}
|
||||
|
|
@ -878,16 +1094,22 @@ const DocumentCard = memo(
|
|||
<div
|
||||
className={cn(
|
||||
"flex items-center px-3",
|
||||
document.memoryEntries.length > 0
|
||||
processingStatus ||
|
||||
showDone ||
|
||||
document.memoryEntries.length > 0
|
||||
? "justify-between"
|
||||
: "justify-end",
|
||||
)}
|
||||
>
|
||||
{document.memoryEntries.length > 0 && (
|
||||
{processingStatus ? (
|
||||
<ProcessingBadge />
|
||||
) : showDone ? (
|
||||
<DoneBadge />
|
||||
) : document.memoryEntries.length > 0 ? (
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] text-[#369BFD] font-semibold flex items-center gap-1",
|
||||
"text-[11px] text-[#369BFD] font-semibold flex items-center gap-1",
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
|
|
@ -900,11 +1122,11 @@ const DocumentCard = memo(
|
|||
<SyncLogoIcon className="w-[12.33px] h-[10px]" />
|
||||
{document.memoryEntries.length}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] text-[#737373] line-clamp-1",
|
||||
"text-[11px] text-[#737373] line-clamp-1",
|
||||
)}
|
||||
>
|
||||
{new Date(document.createdAt).toLocaleDateString("en-US", {
|
||||
|
|
@ -939,10 +1161,7 @@ function ContentPreview({
|
|||
return <GoogleDocsPreview document={document} />
|
||||
}
|
||||
|
||||
if (
|
||||
document.url?.includes("x.com/") &&
|
||||
document.metadata?.sm_internal_twitter_metadata
|
||||
) {
|
||||
if (document.metadata?.sm_internal_twitter_metadata) {
|
||||
return (
|
||||
<TweetPreview
|
||||
data={
|
||||
|
|
@ -952,6 +1171,13 @@ function ContentPreview({
|
|||
)
|
||||
}
|
||||
|
||||
if (
|
||||
document.url?.includes("x.com/") ||
|
||||
document.url?.includes("twitter.com/")
|
||||
) {
|
||||
return <NotePreview document={document} />
|
||||
}
|
||||
|
||||
if (document.source === "mcp") {
|
||||
return <McpPreview document={document} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ function seededRandom(seed: number) {
|
|||
}
|
||||
}
|
||||
|
||||
function StaticGraphPreview({
|
||||
export function StaticGraphPreview({
|
||||
documentCount,
|
||||
memoryCount,
|
||||
width,
|
||||
|
|
@ -70,10 +70,10 @@ function StaticGraphPreview({
|
|||
let b = Math.floor(rand() * nodes.length)
|
||||
if (b === a) b = (a + 1) % nodes.length
|
||||
result.push({
|
||||
x1: nodes[a]!.x,
|
||||
y1: nodes[a]!.y,
|
||||
x2: nodes[b]!.x,
|
||||
y2: nodes[b]!.y,
|
||||
x1: nodes[a]?.x,
|
||||
y1: nodes[a]?.y,
|
||||
x2: nodes[b]?.x,
|
||||
y2: nodes[b]?.y,
|
||||
})
|
||||
}
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -1,870 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { useAgent } from "agents/react"
|
||||
import { useAgentChat } from "@cloudflare/ai-chat/react"
|
||||
import NovaOrb from "@/components/nova/nova-orb"
|
||||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
PanelRightCloseIcon,
|
||||
SendIcon,
|
||||
CheckIcon,
|
||||
XIcon,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
import { collectValidUrls } from "@/lib/url-helpers"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { useProject } from "@/stores"
|
||||
import { Streamdown } from "streamdown"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
|
||||
interface ChatSidebarProps {
|
||||
formData: {
|
||||
twitter: string
|
||||
linkedin: string
|
||||
description: string
|
||||
otherLinks: string[]
|
||||
} | null
|
||||
}
|
||||
|
||||
interface DraftDoc {
|
||||
kind: "likes" | "link" | "x_research"
|
||||
content: string
|
||||
metadata: Record<string, string>
|
||||
title?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export function ChatSidebar({ formData }: ChatSidebarProps) {
|
||||
const { user } = useAuth()
|
||||
const { selectedProject } = useProject()
|
||||
const isMobile = useIsMobile()
|
||||
const [message, setMessage] = useState("")
|
||||
const [isChatOpen, setIsChatOpen] = useState(!isMobile)
|
||||
const [timelineMessages, setTimelineMessages] = useState<
|
||||
{
|
||||
message: string
|
||||
type?: "formData" | "exa" | "memory" | "waiting"
|
||||
memories?: {
|
||||
url: string
|
||||
title: string
|
||||
description: string
|
||||
fullContent: string
|
||||
}[]
|
||||
url?: string
|
||||
title?: string
|
||||
description?: string
|
||||
}[]
|
||||
>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isFetchingDrafts, setIsFetchingDrafts] = useState(false)
|
||||
const [draftDocs, setDraftDocs] = useState<DraftDoc[]>([])
|
||||
const [xResearchStatus, setXResearchStatus] = useState<
|
||||
"correct" | "incorrect" | null
|
||||
>(null)
|
||||
const [isConfirmed, setIsConfirmed] = useState(false)
|
||||
const [processingByUrl, setProcessingByUrl] = useState<
|
||||
Record<string, boolean>
|
||||
>({})
|
||||
const displayedMemoriesRef = useRef<Set<string>>(new Set())
|
||||
const contextInjectedRef = useRef(false)
|
||||
const draftsBuiltRef = useRef(false)
|
||||
const isProcessingRef = useRef(false)
|
||||
const draftRequestIdRef = useRef(0)
|
||||
|
||||
const backendUrl = new URL(process.env.NEXT_PUBLIC_BACKEND_URL!)
|
||||
const agent = useAgent({
|
||||
agent: "chat-agent",
|
||||
name: user?.id ?? "anonymous",
|
||||
host: backendUrl.host,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
agent.setState({
|
||||
model: "claude-sonnet-4.6" as const,
|
||||
projectId: selectedProject,
|
||||
})
|
||||
}, [agent, selectedProject])
|
||||
|
||||
const {
|
||||
messages: chatMessages,
|
||||
sendMessage,
|
||||
status,
|
||||
} = useAgentChat({
|
||||
agent,
|
||||
getInitialMessages: null,
|
||||
credentials: "include",
|
||||
})
|
||||
|
||||
const buildOnboardingContext = useCallback(() => {
|
||||
if (!formData) return ""
|
||||
|
||||
const contextParts: string[] = []
|
||||
|
||||
if (formData.description?.trim()) {
|
||||
contextParts.push(`User's interests/likes: ${formData.description}`)
|
||||
}
|
||||
|
||||
if (formData.twitter) {
|
||||
contextParts.push(`X/Twitter profile: ${formData.twitter}`)
|
||||
}
|
||||
|
||||
if (formData.linkedin) {
|
||||
contextParts.push(`LinkedIn profile: ${formData.linkedin}`)
|
||||
}
|
||||
|
||||
if (formData.otherLinks.length > 0) {
|
||||
contextParts.push(`Other links: ${formData.otherLinks.join(", ")}`)
|
||||
}
|
||||
|
||||
const memoryTexts = timelineMessages
|
||||
.filter((msg) => msg.type === "memory" && msg.memories)
|
||||
.flatMap(
|
||||
(msg) => msg.memories?.map((m) => `${m.title}: ${m.description}`) || [],
|
||||
)
|
||||
|
||||
if (memoryTexts.length > 0) {
|
||||
contextParts.push(`Extracted memories:\n${memoryTexts.join("\n")}`)
|
||||
}
|
||||
|
||||
return contextParts.join("\n\n")
|
||||
}, [formData, timelineMessages])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!message.trim() || status === "submitted" || status === "streaming")
|
||||
return
|
||||
|
||||
let messageToSend = message
|
||||
|
||||
const context = buildOnboardingContext()
|
||||
|
||||
if (context && !contextInjectedRef.current && chatMessages.length === 0) {
|
||||
messageToSend = `${context}\n\nUser question: ${message}`
|
||||
contextInjectedRef.current = true
|
||||
}
|
||||
|
||||
sendMessage({ text: messageToSend })
|
||||
setMessage("")
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleChat = () => {
|
||||
setIsChatOpen(!isChatOpen)
|
||||
}
|
||||
|
||||
const pollForMemories = useCallback(
|
||||
async (documentIds: string[]) => {
|
||||
const maxAttempts = 30 // 30 attempts * 3 seconds = 90 seconds max
|
||||
const pollInterval = 3000 // 3 seconds
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const response = await $fetch("@get/documents/:id", {
|
||||
params: { id: documentIds[0] ?? "" },
|
||||
disableValidation: true,
|
||||
})
|
||||
|
||||
console.log("response", response)
|
||||
|
||||
if (response.data) {
|
||||
const document = response.data
|
||||
|
||||
if (document.memories && document.memories.length > 0) {
|
||||
const newMemories: {
|
||||
url: string
|
||||
title: string
|
||||
description: string
|
||||
fullContent: string
|
||||
}[] = []
|
||||
|
||||
document.memories.forEach(
|
||||
(memory: { memory: string; title?: string }) => {
|
||||
if (!displayedMemoriesRef.current.has(memory.memory)) {
|
||||
displayedMemoriesRef.current.add(memory.memory)
|
||||
newMemories.push({
|
||||
url: document.url || "",
|
||||
title: memory.title || document.title || "Memory",
|
||||
description: memory.memory || "",
|
||||
fullContent: memory.memory || "",
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (newMemories.length > 0 && timelineMessages.length < 10) {
|
||||
setTimelineMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
message: newMemories
|
||||
.map((memory) => memory.description)
|
||||
.join("\n"),
|
||||
type: "memory" as const,
|
||||
memories: newMemories,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
if (document.memories && document.memories.length > 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval))
|
||||
} catch (error) {
|
||||
console.warn("Error polling for memories:", error)
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval))
|
||||
}
|
||||
}
|
||||
},
|
||||
[timelineMessages.length],
|
||||
)
|
||||
|
||||
const buildDraftDocs = useCallback(async () => {
|
||||
if (!formData || draftsBuiltRef.current) return
|
||||
draftsBuiltRef.current = true
|
||||
|
||||
const hasContent =
|
||||
formData.twitter ||
|
||||
formData.linkedin ||
|
||||
formData.otherLinks.length > 0 ||
|
||||
formData.description?.trim()
|
||||
|
||||
if (!hasContent) return
|
||||
|
||||
const requestId = ++draftRequestIdRef.current
|
||||
|
||||
setIsFetchingDrafts(true)
|
||||
const drafts: DraftDoc[] = []
|
||||
|
||||
const urls = collectValidUrls(formData.linkedin, formData.otherLinks)
|
||||
const allProcessingUrls: string[] = [...urls]
|
||||
if (formData.twitter) {
|
||||
allProcessingUrls.push(formData.twitter)
|
||||
}
|
||||
|
||||
if (allProcessingUrls.length > 0) {
|
||||
setProcessingByUrl((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const url of allProcessingUrls) {
|
||||
next[url] = true
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
if (formData.description?.trim()) {
|
||||
drafts.push({
|
||||
kind: "likes",
|
||||
content: formData.description,
|
||||
metadata: {
|
||||
sm_source: "consumer",
|
||||
description_source: "user_input",
|
||||
},
|
||||
title: "Your Interests",
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch each URL separately for per-link loading state
|
||||
const linkPromises = urls.map(async (url) => {
|
||||
try {
|
||||
const response = await fetch("/api/onboarding/extract-content", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ urls: [url] }),
|
||||
})
|
||||
const data = await response.json()
|
||||
return data.results?.[0] || null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
// Clear this URL's processing state
|
||||
if (draftRequestIdRef.current === requestId) {
|
||||
setProcessingByUrl((prev) => ({ ...prev, [url]: false }))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch X/Twitter research
|
||||
const xResearchPromise = formData.twitter
|
||||
? (async () => {
|
||||
try {
|
||||
const response = await fetch("/api/onboarding/research", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
xUrl: formData.twitter,
|
||||
name: user?.name,
|
||||
email: user?.email,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) return null
|
||||
const data = await response.json()
|
||||
return data?.text?.trim() || null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
// Clear twitter URL's processing state
|
||||
if (draftRequestIdRef.current === requestId) {
|
||||
setProcessingByUrl((prev) => ({
|
||||
...prev,
|
||||
[formData.twitter]: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
})()
|
||||
: Promise.resolve(null)
|
||||
|
||||
const [exaResults, xResearchResult] = await Promise.all([
|
||||
Promise.all(linkPromises),
|
||||
xResearchPromise,
|
||||
])
|
||||
|
||||
// Guard against stale request completing after a newer one
|
||||
if (draftRequestIdRef.current !== requestId) return
|
||||
|
||||
for (const result of exaResults) {
|
||||
if (result && (result.text || result.description)) {
|
||||
drafts.push({
|
||||
kind: "link",
|
||||
content: result.text || result.description || "",
|
||||
metadata: {
|
||||
sm_source: "consumer",
|
||||
exa_url: result.url,
|
||||
exa_title: result.title,
|
||||
},
|
||||
title: result.title || "Extracted Content",
|
||||
url: result.url,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (xResearchResult) {
|
||||
drafts.push({
|
||||
kind: "x_research",
|
||||
content: xResearchResult,
|
||||
metadata: {
|
||||
sm_source: "consumer",
|
||||
onboarding_source: "x_research",
|
||||
x_url: formData.twitter,
|
||||
},
|
||||
title: "X/Twitter Profile Research",
|
||||
url: formData.twitter,
|
||||
})
|
||||
}
|
||||
|
||||
setDraftDocs(drafts)
|
||||
} catch (error) {
|
||||
console.warn("Error building draft docs:", error)
|
||||
} finally {
|
||||
if (draftRequestIdRef.current === requestId) {
|
||||
setIsFetchingDrafts(false)
|
||||
}
|
||||
}
|
||||
}, [formData, user])
|
||||
|
||||
const handleConfirmDocs = useCallback(async () => {
|
||||
if (isConfirmed || isProcessingRef.current) return
|
||||
isProcessingRef.current = true
|
||||
setIsConfirmed(true)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const promises = draftDocs.map(async (draft) => {
|
||||
if (draft.kind === "x_research" && xResearchStatus !== "correct") {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const docResponse = await $fetch("@post/documents", {
|
||||
body: {
|
||||
content: draft.content,
|
||||
containerTags: ["sm_project_default"],
|
||||
metadata: draft.metadata,
|
||||
},
|
||||
})
|
||||
|
||||
return docResponse.data?.id
|
||||
} catch (error) {
|
||||
console.warn("Error creating document:", error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
const documentIds = results.filter(
|
||||
(id): id is string => id !== null && id !== undefined,
|
||||
)
|
||||
|
||||
if (documentIds.length > 0) {
|
||||
await pollForMemories(documentIds)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error confirming documents:", error)
|
||||
setIsConfirmed(false)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
isProcessingRef.current = false
|
||||
}
|
||||
}, [draftDocs, xResearchStatus, isConfirmed, pollForMemories])
|
||||
|
||||
useEffect(() => {
|
||||
if (!formData) return
|
||||
|
||||
const formDataMessages: typeof timelineMessages = []
|
||||
|
||||
if (formData.twitter) {
|
||||
formDataMessages.push({
|
||||
message: formData.twitter,
|
||||
url: formData.twitter,
|
||||
title: "X/Twitter",
|
||||
description: formData.twitter,
|
||||
type: "formData" as const,
|
||||
})
|
||||
}
|
||||
|
||||
if (formData.linkedin) {
|
||||
formDataMessages.push({
|
||||
message: formData.linkedin,
|
||||
url: formData.linkedin,
|
||||
title: "LinkedIn",
|
||||
description: formData.linkedin,
|
||||
type: "formData" as const,
|
||||
})
|
||||
}
|
||||
|
||||
if (formData.otherLinks.length > 0) {
|
||||
formData.otherLinks.forEach((link) => {
|
||||
formDataMessages.push({
|
||||
message: link,
|
||||
url: link,
|
||||
title: "Link",
|
||||
description: link,
|
||||
type: "formData" as const,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (formData.description?.trim()) {
|
||||
formDataMessages.push({
|
||||
message: formData.description,
|
||||
title: "Likes",
|
||||
description: formData.description,
|
||||
type: "formData" as const,
|
||||
})
|
||||
}
|
||||
|
||||
setTimelineMessages(formDataMessages)
|
||||
buildDraftDocs()
|
||||
}, [formData, buildDraftDocs])
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{!isChatOpen ? (
|
||||
<motion.div
|
||||
key="closed"
|
||||
className={cn(
|
||||
"flex items-start justify-start",
|
||||
isMobile
|
||||
? "fixed bottom-4 right-4 z-50"
|
||||
: "absolute top-0 right-0 m-4",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
layoutId="chat-toggle-button"
|
||||
>
|
||||
<motion.button
|
||||
onClick={toggleChat}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium border border-[#17181A] text-white cursor-pointer shadow-lg",
|
||||
isMobile && "px-4 py-2",
|
||||
)}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
<NovaOrb size={24} className="blur-none! z-10" />
|
||||
{!isMobile && "Chat with Nova"}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="open"
|
||||
className={cn(
|
||||
"bg-[#0A0E14] backdrop-blur-md flex flex-col",
|
||||
isMobile
|
||||
? "fixed inset-0 z-50 w-full h-dvh rounded-none m-0"
|
||||
: "w-[450px] h-[calc(100vh-110px)] rounded-2xl m-4",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
initial={
|
||||
isMobile ? { y: "100%", opacity: 0 } : { x: "100px", opacity: 0 }
|
||||
}
|
||||
animate={{ x: 0, y: 0, opacity: 1 }}
|
||||
exit={
|
||||
isMobile ? { y: "100%", opacity: 0 } : { x: "100px", opacity: 0 }
|
||||
}
|
||||
transition={{ duration: 0.3, ease: "easeOut", bounce: 0 }}
|
||||
>
|
||||
<motion.button
|
||||
onClick={toggleChat}
|
||||
className={cn(
|
||||
"absolute top-4 right-4 flex items-center gap-2 rounded-full p-2 text-xs text-white cursor-pointer",
|
||||
isMobile && "bg-[#0D121A] border border-[#73737333]",
|
||||
)}
|
||||
style={
|
||||
isMobile
|
||||
? {
|
||||
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
|
||||
}
|
||||
: {
|
||||
background:
|
||||
"linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}
|
||||
}
|
||||
layoutId="chat-toggle-button"
|
||||
>
|
||||
{isMobile ? (
|
||||
<XIcon className="size-4" />
|
||||
) : (
|
||||
<>
|
||||
<PanelRightCloseIcon className="size-4" />
|
||||
Close chat
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
<div className="flex-1 flex flex-col px-4 space-y-3 pb-4 justify-end overflow-y-auto scrollbar-thin">
|
||||
{timelineMessages.map((msg, i) => (
|
||||
<div
|
||||
key={`message-${i}-${msg.message}`}
|
||||
className="flex items-start gap-2"
|
||||
>
|
||||
{msg.type === "waiting" ? (
|
||||
<div className="flex items-center gap-2 text-white/50">
|
||||
<NovaOrb size={30} className="blur-none!" />
|
||||
<span className="text-sm">{msg.message}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center w-[30px] h-full",
|
||||
i !== 0 && "",
|
||||
)}
|
||||
>
|
||||
{i === 0 && (
|
||||
<div className="w-3 h-3 bg-[#293952]/40 rounded-full mb-1" />
|
||||
)}
|
||||
<div className="w-px flex-1 bg-[#293952]/40" />
|
||||
</div>
|
||||
{msg.type === "formData" && (
|
||||
<div className="bg-[#293952]/40 rounded-lg p-2 px-3 space-y-1 flex-1">
|
||||
{msg.title && (
|
||||
<div className="flex items-center gap-2">
|
||||
<h3
|
||||
className="text-sm font-medium"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
backgroundClip: "text",
|
||||
}}
|
||||
>
|
||||
{msg.title}
|
||||
</h3>
|
||||
{msg.url && processingByUrl[msg.url] && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-blue-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.url && (
|
||||
<a
|
||||
href={msg.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-400 hover:underline break-all block"
|
||||
>
|
||||
{msg.url}
|
||||
</a>
|
||||
)}
|
||||
{msg.title === "Likes" && msg.description && (
|
||||
<p className="text-xs text-white/70 mt-1">
|
||||
{msg.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.type === "memory" && (
|
||||
<div className="space-y-2 w-full max-h-60 overflow-y-auto scrollbar-thin">
|
||||
{msg.memories?.map((memory) => (
|
||||
<div
|
||||
key={memory.url + memory.title}
|
||||
className="bg-[#293952]/40 rounded-lg p-2 px-3 space-y-2"
|
||||
>
|
||||
{memory.title && (
|
||||
<h3
|
||||
className="text-sm font-medium"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
backgroundClip: "text",
|
||||
}}
|
||||
>
|
||||
{memory.title}
|
||||
</h3>
|
||||
)}
|
||||
{memory.url && (
|
||||
<a
|
||||
href={memory.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-400 hover:underline break-all"
|
||||
>
|
||||
{memory.url}
|
||||
</a>
|
||||
)}
|
||||
{memory.description && (
|
||||
<p className="text-xs text-white/50 mt-1">
|
||||
{memory.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{chatMessages.map((msg) => {
|
||||
if (msg.role === "user") {
|
||||
const text = msg.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join(" ")
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="flex items-start gap-2 justify-end"
|
||||
>
|
||||
<div className="bg-[#1B1F24] rounded-[12px] p-3 px-[14px] max-w-[80%]">
|
||||
<p className="text-sm text-white">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (msg.role === "assistant") {
|
||||
return (
|
||||
<div key={msg.id} className="flex items-start gap-2">
|
||||
<NovaOrb size={30} className="blur-none!" />
|
||||
<div className="flex-1">
|
||||
{msg.parts.map((part, partIndex) => {
|
||||
if (part.type === "text") {
|
||||
return (
|
||||
<div
|
||||
key={`${msg.id}-${partIndex}`}
|
||||
className="text-sm text-white/90 chat-markdown-content"
|
||||
>
|
||||
<Streamdown>{part.text}</Streamdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (part.type === "tool-searchMemories") {
|
||||
if (
|
||||
part.state === "input-available" ||
|
||||
part.state === "input-streaming"
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
key={`${msg.id}-${partIndex}`}
|
||||
className="text-xs text-white/50 italic"
|
||||
>
|
||||
Searching memories...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
{(status === "submitted" || status === "streaming") &&
|
||||
chatMessages[chatMessages.length - 1]?.role === "user" && (
|
||||
<div className="flex items-start gap-2">
|
||||
<NovaOrb size={30} className="blur-none!" />
|
||||
<span className="text-sm text-white/50">Thinking...</span>
|
||||
</div>
|
||||
)}
|
||||
{timelineMessages.length === 0 &&
|
||||
chatMessages.length === 0 &&
|
||||
!isLoading &&
|
||||
!formData && (
|
||||
<div className="flex items-center gap-2 text-white/50">
|
||||
<NovaOrb size={28} className="blur-none!" />
|
||||
<span className="text-sm">Waiting for your input</span>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-foreground/50">
|
||||
<NovaOrb size={28} className="blur-none!" />
|
||||
<span className="text-sm">Extracting memories...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{draftDocs.some((d) => d.kind === "x_research") && !isConfirmed && (
|
||||
<div className="px-4 pb-2 space-y-3">
|
||||
<div className="bg-[#293952]/40 rounded-lg p-3 space-y-2">
|
||||
<h3
|
||||
className="text-sm font-medium"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, #369BFD 0%, #36FDFD 30%, #36FDB5 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
backgroundClip: "text",
|
||||
}}
|
||||
>
|
||||
Your Profile Summary
|
||||
</h3>
|
||||
<div className="overflow-y-auto scrollbar-thin max-h-32">
|
||||
<p className="text-xs text-white/70">
|
||||
{draftDocs.find((d) => d.kind === "x_research")?.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<span className="text-xs text-white/50">
|
||||
Is this accurate?
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setXResearchStatus("correct")
|
||||
handleConfirmDocs()
|
||||
}}
|
||||
disabled={isConfirmed || isLoading}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors cursor-pointer",
|
||||
xResearchStatus === "correct"
|
||||
? "bg-green-500/20 text-green-400 border border-green-500/40"
|
||||
: "bg-[#1B1F24] text-white/50 hover:text-white/70",
|
||||
(isConfirmed || isLoading) &&
|
||||
"opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="size-3" />
|
||||
Correct
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setXResearchStatus("incorrect")}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 rounded-md text-xs transition-colors cursor-pointer",
|
||||
xResearchStatus === "incorrect"
|
||||
? "bg-red-500/20 text-red-400 border border-red-500/40"
|
||||
: "bg-[#1B1F24] text-white/50 hover:text-white/70",
|
||||
)}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
Incorrect
|
||||
</button>
|
||||
</div>
|
||||
{xResearchStatus === "incorrect" && (
|
||||
<>
|
||||
<p className="text-xs text-white/40 pt-1">
|
||||
If incorrect, share your info in the input below, or you
|
||||
can add memories later as well.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirmDocs}
|
||||
disabled={isConfirmed || isLoading}
|
||||
className="w-full bg-[#267BF1] hover:bg-[#1E6AD9] text-white rounded-lg py-2 text-sm cursor-pointer mt-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!draftDocs.some((d) => d.kind === "x_research") &&
|
||||
draftDocs.length > 0 &&
|
||||
!isConfirmed && (
|
||||
<div className="px-4 pb-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleConfirmDocs}
|
||||
disabled={isConfirmed || isLoading}
|
||||
className="w-full bg-[#267BF1] hover:bg-[#1E6AD9] text-white rounded-lg py-2 text-sm cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 space-y-2">
|
||||
{isFetchingDrafts && (
|
||||
<div className="flex items-center gap-2 text-white/50 px-2">
|
||||
<NovaOrb size={20} className="blur-none!" />
|
||||
<span className="text-sm">
|
||||
Getting all relevant info about you...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
className="flex flex-col gap-3 bg-[#0D121A] rounded-xl p-2 relative"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (message.trim()) {
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Chat with your Supermemory"
|
||||
className="w-full text-white placeholder:text-white/20 rounded-sm outline-none resize-none text-base leading-relaxed bg-transparent px-2 h-10"
|
||||
disabled={status === "submitted" || status === "streaming"}
|
||||
/>
|
||||
<div className="flex justify-end absolute bottom-3 right-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!message.trim() ||
|
||||
status === "submitted" ||
|
||||
status === "streaming"
|
||||
}
|
||||
className="text-white/20 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all"
|
||||
size="icon"
|
||||
>
|
||||
<SendIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { motion } from "motion/react"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import { UserProfileMenu } from "@/components/user-profile-menu"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useLocalStorageUsername } from "@hooks/use-local-storage-username"
|
||||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
export function SetupHeader() {
|
||||
const { user } = useAuth()
|
||||
const router = useRouter()
|
||||
const localStorageUsername = useLocalStorageUsername()
|
||||
const { markOrgOnboarded, isLoading: isOrgLoading } = useOrgOnboarding()
|
||||
|
||||
const handleSkip = () => {
|
||||
markOrgOnboarded()
|
||||
analytics.onboardingCompleted()
|
||||
router.push("/")
|
||||
}
|
||||
|
||||
const displayName =
|
||||
user?.displayUsername || localStorageUsername || user?.name || ""
|
||||
const userName = displayName ? `${displayName.split(" ")[0]}'s` : "My"
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="relative z-20 flex p-6 justify-between items-center"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
>
|
||||
<nav
|
||||
className={cn(
|
||||
"flex items-center gap-2 sm:gap-3 min-w-0 z-10! text-sm",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
aria-label="Breadcrumb"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/")}
|
||||
className={cn(
|
||||
"flex items-center min-w-0 rounded-lg py-1 pr-2 -ml-1 pl-1",
|
||||
"hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-colors cursor-pointer text-left",
|
||||
)}
|
||||
>
|
||||
<Logo className="h-7 shrink-0" />
|
||||
{displayName ? (
|
||||
<div className="flex flex-col items-start justify-center ml-2 min-w-0">
|
||||
<p className="text-[#8B8B8B] text-[11px] leading-tight">
|
||||
{userName}
|
||||
</p>
|
||||
<p className="text-white font-bold text-xl leading-none -mt-1">
|
||||
supermemory
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<span className="ml-2 font-medium text-white/90">supermemory</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-white/35 shrink-0" aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<span className="text-white/50 font-medium shrink-0">Setup</span>
|
||||
</nav>
|
||||
<div className="flex items-center gap-3 z-10">
|
||||
{!isOrgLoading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className={cn(
|
||||
"text-sm text-white/40 hover:text-white/70 transition-colors cursor-pointer",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Skip Onboarding
|
||||
</button>
|
||||
)}
|
||||
{user && <UserProfileMenu avatarClassName="border-border" />}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { CHROME_EXTENSION_URL } from "@repo/lib/constants"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { MCPDetailView } from "@/components/mcp-modal/mcp-detail-view"
|
||||
import { XBookmarksDetailView } from "@/components/onboarding/x-bookmarks-detail-view"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
const integrationCards = [
|
||||
{
|
||||
title: "Capture",
|
||||
description: "Add the Chrome extension for one-click saves",
|
||||
icon: (
|
||||
<div className="rounded-full flex items-center justify-center">
|
||||
<img
|
||||
src="/onboarding/chrome.png"
|
||||
alt="Chrome"
|
||||
className="w-20 h-auto"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Connect to AI",
|
||||
description: "Set up once and use your memory in Cursor, Claude, etc",
|
||||
icon: (
|
||||
<div className="rounded flex items-center justify-center">
|
||||
<img src="/onboarding/mcp.png" alt="MCP" className="size-28 h-auto" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Connect",
|
||||
description: "Link Notion, Google Drive, or OneDrive to import your docs",
|
||||
icon: (
|
||||
<div className="rounded flex items-center justify-center">
|
||||
<img
|
||||
src="/onboarding/connectors.png"
|
||||
alt="Connectors"
|
||||
className="w-20 h-auto"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Import",
|
||||
description:
|
||||
"Bring in X/Twitter bookmarks, and turn them into useful memories",
|
||||
icon: (
|
||||
<div className="rounded flex items-center justify-center">
|
||||
<img src="/onboarding/x.png" alt="X" className="size-14" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function IntegrationsStep() {
|
||||
const router = useRouter()
|
||||
const [selectedCard, setSelectedCard] = useState<string | null>(null)
|
||||
const { markOrgOnboarded } = useOrgOnboarding()
|
||||
|
||||
const handleContinue = () => {
|
||||
markOrgOnboarded()
|
||||
analytics.onboardingCompleted()
|
||||
router.push("/")
|
||||
}
|
||||
|
||||
if (selectedCard === "Connect to AI") {
|
||||
return <MCPDetailView onBack={() => setSelectedCard(null)} />
|
||||
}
|
||||
if (selectedCard === "Import") {
|
||||
return <XBookmarksDetailView onBack={() => setSelectedCard(null)} />
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8">
|
||||
<div className="text-center mb-6 flex flex-col items-center justify-center space-y-2">
|
||||
<h1 className="text-white text-[32px] font-medium">
|
||||
Build your personal memory
|
||||
</h1>
|
||||
<p
|
||||
className={cn(
|
||||
"text-white text-sm opacity-60 max-w-xs",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Your supermemory comes alive when you <br /> capture and connect
|
||||
what's important
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 max-w-lg w-full mb-12">
|
||||
{integrationCards.map((card) => {
|
||||
const isClickable =
|
||||
card.title === "Connect to AI" ||
|
||||
card.title === "Capture" ||
|
||||
card.title === "Import"
|
||||
|
||||
if (isClickable) {
|
||||
return (
|
||||
<button
|
||||
key={card.title}
|
||||
type="button"
|
||||
className={cn(
|
||||
"bg-[#080B0F] relative rounded-lg p-3 hover:border-[#3374FF] hover:border-[0.1px] transition-colors duration-300 border-[0.1px] border-[#0D121A] cursor-pointer text-left w-full hover:bg-[url('/onboarding/bg-gradient-1.png')] hover:bg-[length:175%_auto] hover:bg-[center_top_2rem] hover:bg-no-repeat",
|
||||
"hover:border-b-0 border-b-0",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (card.title === "Capture") {
|
||||
analytics.onboardingChromeExtensionClicked({
|
||||
source: "onboarding",
|
||||
})
|
||||
window.open(CHROME_EXTENSION_URL, "_blank")
|
||||
} else {
|
||||
analytics.onboardingIntegrationClicked({
|
||||
integration: card.title,
|
||||
})
|
||||
if (card.title === "Connect to AI") {
|
||||
analytics.onboardingMcpDetailOpened()
|
||||
} else if (card.title === "Import") {
|
||||
analytics.onboardingXBookmarksDetailOpened()
|
||||
}
|
||||
setSelectedCard(card.title)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 mt-10">
|
||||
<h3 className="text-white text-sm font-medium">
|
||||
{card.title}
|
||||
</h3>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-xs leading-relaxed",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute top-0 right-0">{card.icon}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={card.title}
|
||||
className={cn(
|
||||
"bg-[#080B0F] relative rounded-lg p-3 hover:border-[#3374FF] hover:border-[0.1px] transition-colors duration-300 border-[0.1px] border-[#0D121A] hover:bg-[url('/onboarding/bg-gradient-1.png')] hover:bg-[length:175%_auto] hover:bg-[center_top_2rem] hover:bg-no-repeat",
|
||||
"hover:border-b-0 border-b-0",
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 mt-10">
|
||||
<h3 className="text-white text-sm font-medium">{card.title}</h3>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-xs leading-relaxed",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{card.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute top-0 right-0">{card.icon}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between w-full max-w-4xl">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-white hover:text-gray-300 hover:no-underline cursor-pointer"
|
||||
onClick={() => router.push("/onboarding/setup?step=relatable")}
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-white hover:text-gray-300 hover:no-underline cursor-pointer"
|
||||
onClick={handleContinue}
|
||||
>
|
||||
Continue →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
const relatableOptions = [
|
||||
{
|
||||
emoji: "😔",
|
||||
text: "I always forget what I save in my twitter bookmarks",
|
||||
},
|
||||
{
|
||||
emoji: "😭",
|
||||
text: "Going through e-books manually is so tedious",
|
||||
},
|
||||
{
|
||||
emoji: "🥲",
|
||||
text: "I always have to feed every AI app with my data",
|
||||
},
|
||||
{
|
||||
emoji: "😵💫",
|
||||
text: "Referring meeting notes makes my AI chat hallucinate",
|
||||
},
|
||||
{
|
||||
emoji: "🫤",
|
||||
text: "I save nothing on my browser, it's just useless",
|
||||
},
|
||||
]
|
||||
|
||||
export function RelatableQuestion() {
|
||||
const router = useRouter()
|
||||
const [selectedOptions, setSelectedOptions] = useState<number[]>([])
|
||||
|
||||
const handleContinueOrSkip = () => {
|
||||
const selectedTexts = selectedOptions.map(
|
||||
(idx) => relatableOptions[idx]?.text || "",
|
||||
)
|
||||
analytics.onboardingRelatableSelected({ options: selectedTexts })
|
||||
router.push("/onboarding/setup?step=integrations")
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex flex-col items-center justify-center h-full"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<motion.h1
|
||||
className="text-white text-[32px] font-medium mb-6 text-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
Which of these sound most relatable?
|
||||
</motion.h1>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap justify-center gap-4 max-w-3xl",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{relatableOptions.map((option, index) => (
|
||||
<div
|
||||
key={option.text}
|
||||
className={cn(
|
||||
"rounded-lg max-w-[140px] min-h-[159px] transition-all duration-300",
|
||||
selectedOptions.includes(index)
|
||||
? "p-px bg-linear-to-b from-[#3374FF] to-[#1A63FF00]"
|
||||
: "p-0 border border-[#0D121A] hover:border-[#4C608B66]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className={`
|
||||
group relative w-full h-full rounded-lg p-2 cursor-pointer transition-all duration-300 overflow-hidden
|
||||
bg-[#080B0F] hover:bg-no-repeat
|
||||
`}
|
||||
onClick={() => {
|
||||
setSelectedOptions((prev) =>
|
||||
prev.includes(index)
|
||||
? prev.filter((i) => i !== index)
|
||||
: [...prev, index],
|
||||
)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
setSelectedOptions((prev) =>
|
||||
prev.includes(index)
|
||||
? prev.filter((i) => i !== index)
|
||||
: [...prev, index],
|
||||
)
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{selectedOptions.includes(index) && (
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-[url('/onboarding/bg-gradient-1.png')] bg-size-[550%_auto] bg-top bg-no-repeat"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="relative flex flex-col items-start justify-between h-full">
|
||||
<span
|
||||
className={`text-2xl ${
|
||||
selectedOptions.includes(index)
|
||||
? "opacity-100"
|
||||
: "opacity-70 group-hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
{option.emoji}
|
||||
</span>
|
||||
<p
|
||||
className={`text-white text-sm leading-[135%] align-bottom text-left transition-opacity duration-300 ${
|
||||
selectedOptions.includes(index)
|
||||
? "opacity-100"
|
||||
: "opacity-50 group-hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
{option.text}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4 my-8">
|
||||
<div key={selectedOptions.length === 0 ? "skip" : "continue"}>
|
||||
<Button
|
||||
className={cn(
|
||||
"font-medium text-white hover:no-underline cursor-pointer",
|
||||
selectedOptions.length !== 0 ? "rounded-xl" : "",
|
||||
)}
|
||||
variant={selectedOptions.length !== 0 ? "onboarding" : "link"}
|
||||
size="lg"
|
||||
onClick={handleContinueOrSkip}
|
||||
style={
|
||||
selectedOptions.length !== 0
|
||||
? {
|
||||
background:
|
||||
"linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{selectedOptions.length === 0
|
||||
? "Skip for now →"
|
||||
: "Remember this →"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { motion, type Variants } from "motion/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ProfileStep } from "./profile-step"
|
||||
import { continueVariants, contentVariants } from "@/lib/variants"
|
||||
|
||||
type OnboardingView = "continue" | "features" | "memories"
|
||||
|
||||
interface OnboardingContentStepProps {
|
||||
currentView?: OnboardingView
|
||||
onSubmit?: (data: {
|
||||
twitter: string
|
||||
linkedin: string
|
||||
description: string
|
||||
otherLinks: string[]
|
||||
}) => void
|
||||
}
|
||||
|
||||
const containerVariants: Variants = {
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: "easeOut",
|
||||
},
|
||||
},
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function OnboardingContentStep({
|
||||
currentView = "continue",
|
||||
onSubmit,
|
||||
}: OnboardingContentStepProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const handleContinue = () => {
|
||||
router.push("/onboarding/welcome?step=features")
|
||||
}
|
||||
|
||||
const handleAddMemories = () => {
|
||||
router.push("/onboarding/welcome?step=memories")
|
||||
}
|
||||
|
||||
const isContinue = currentView === "continue"
|
||||
const isFeatures = currentView === "features"
|
||||
const isMemories = currentView === "memories"
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
className="text-center relative"
|
||||
>
|
||||
{/* Continue content */}
|
||||
<motion.div
|
||||
variants={continueVariants}
|
||||
animate={isContinue ? "visible" : "hidden"}
|
||||
initial="visible"
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center max-w-88",
|
||||
!isContinue && "absolute inset-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8A8A8A] text-sm mb-6 max-w-sm",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
I'm built with Supermemory's super fast memory API,
|
||||
<br /> so you never have to worry about forgetting <br /> what matters
|
||||
across your AI apps.
|
||||
</p>
|
||||
<Button
|
||||
variant="onboarding"
|
||||
onClick={handleContinue}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
width: "147px",
|
||||
}}
|
||||
>
|
||||
Continue →
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Features content */}
|
||||
<motion.div
|
||||
variants={contentVariants}
|
||||
animate={isFeatures ? "visible" : "hiddenDown"}
|
||||
initial="hiddenDown"
|
||||
className={cn(
|
||||
"space-y-6 max-w-88",
|
||||
!isFeatures && "absolute inset-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<h2 className="text-white text-[32px] font-medium leading-[110%]">
|
||||
What I can do for you
|
||||
</h2>
|
||||
|
||||
<div className={cn("space-y-4 mb-[24px] mx-4", dmSansClassName())}>
|
||||
<div className="flex items-start space-x-2">
|
||||
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
|
||||
<img
|
||||
src="/onboarding/human-brain.png"
|
||||
alt="Brain icon"
|
||||
className="w-14 h-14"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-white font-light">Remember every context</p>
|
||||
<p className="text-[#8A8A8A] text-[14px]">
|
||||
I keep track of what you've saved and shared with your
|
||||
supermemory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-2">
|
||||
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
|
||||
<img
|
||||
src="/onboarding/search.png"
|
||||
alt="Search icon"
|
||||
className="w-14 h-14"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-white font-light">Find when you need it</p>
|
||||
<p className="text-[#8A8A8A] text-[14px]">
|
||||
I surface the right memories inside <br /> your supermemory,
|
||||
superfast.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-2">
|
||||
<div className="w-14 h-14 rounded-lg flex items-center justify-center shrink-0">
|
||||
<img
|
||||
src="/onboarding/plant.png"
|
||||
alt="Growth icon"
|
||||
className="w-14 h-14"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-white font-light">
|
||||
Grow with your supermemory
|
||||
</p>
|
||||
<p className="text-[#8A8A8A] text-[14px]">
|
||||
I learn and personalize over time, so every interaction feels
|
||||
natural.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="onboarding"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
}}
|
||||
onClick={handleAddMemories}
|
||||
>
|
||||
Add memories →
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Memories/Profile content */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-full",
|
||||
!isMemories && "absolute inset-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
{onSubmit && (
|
||||
<motion.div
|
||||
variants={contentVariants}
|
||||
animate={isMemories ? "visible" : "hiddenDown"}
|
||||
initial="hiddenDown"
|
||||
>
|
||||
<ProfileStep onSubmit={onSubmit} />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { motion } from "motion/react"
|
||||
|
||||
interface GreetingStepProps {
|
||||
name: string
|
||||
}
|
||||
|
||||
export function GreetingStep({ name }: GreetingStepProps) {
|
||||
const userName = name ? `${name.split(" ")[0]}` : ""
|
||||
return (
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ opacity: 0, y: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 0 }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
layout
|
||||
>
|
||||
<h2 className="text-white text-[32px] font-medium mb-2">
|
||||
Hi {userName}, I'm Nova
|
||||
</h2>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import { motion } from "motion/react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { LabeledInput } from "@ui/input/labeled-input"
|
||||
import { Button } from "@ui/components/button"
|
||||
|
||||
interface InputStepProps {
|
||||
name: string
|
||||
setName: (name: string) => void
|
||||
handleSubmit: () => void
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
export function InputStep({
|
||||
name,
|
||||
setName,
|
||||
handleSubmit,
|
||||
isSubmitting,
|
||||
}: InputStepProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"text-center min-w-[250px] flex flex-col",
|
||||
isSubmitting && "pointer-events-none",
|
||||
)}
|
||||
style={{ gap: "24px" }}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: 10,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -10,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: "easeOut",
|
||||
bounce: 0,
|
||||
},
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
ease: "easeOut",
|
||||
delay: 1,
|
||||
}}
|
||||
layout
|
||||
>
|
||||
<h2 className="text-white text-[32px] font-medium leading-[110%]">
|
||||
What should I call you?
|
||||
</h2>
|
||||
<div className="flex items-center w-full relative">
|
||||
<LabeledInput
|
||||
inputType="text"
|
||||
inputPlaceholder="your name"
|
||||
className="w-full flex-1"
|
||||
inputProps={{
|
||||
defaultValue: name,
|
||||
disabled: isSubmitting,
|
||||
onKeyDown: (e) => {
|
||||
if (e.key !== "Enter") return
|
||||
e.preventDefault()
|
||||
if (isSubmitting) return
|
||||
handleSubmit()
|
||||
},
|
||||
className: "!text-white placeholder:!text-[#525966] !h-[40px] pl-4",
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (isSubmitting) return
|
||||
setName((e.target as HTMLInputElement).value)
|
||||
}}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(0deg, rgba(91, 126, 245, 0.04) 0%, rgba(91, 126, 245, 0.04) 100%)",
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
className={`rounded-[8px] w-8 h-8 p-2 absolute right-1 border-[0.5px] border-[#161F2C] hover:cursor-pointer hover:scale-[0.95] active:scale-[0.95] transition-transform ${
|
||||
isSubmitting ? "scale-[0.90]" : ""
|
||||
}`}
|
||||
size="icon"
|
||||
onClick={handleSubmit}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="9"
|
||||
viewBox="0 0 12 9"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Next</title>
|
||||
<path
|
||||
d="M8.05099 9.60156L6.93234 8.49987L9.00014 6.44902L9.62726 6.04224L9.54251 5.788L8.79675 5.90665H0.0170898V4.31343H8.79675L9.54251 4.43207L9.62726 4.17783L9.00014 3.77105L6.93234 1.72021L8.05099 0.601562L11.9832 4.53377V5.68631L8.05099 9.60156Z"
|
||||
fill="#FAFAFA"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
import { motion } from "motion/react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
parseXHandle,
|
||||
parseLinkedInHandle,
|
||||
toXProfileUrl,
|
||||
toLinkedInProfileUrl,
|
||||
normalizeUrl,
|
||||
} from "@/lib/url-helpers"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
interface ProfileStepProps {
|
||||
onSubmit: (data: {
|
||||
twitter: string
|
||||
linkedin: string
|
||||
description: string
|
||||
otherLinks: string[]
|
||||
}) => void
|
||||
}
|
||||
|
||||
type ValidationError = {
|
||||
twitter: string | null
|
||||
linkedin: string | null
|
||||
}
|
||||
|
||||
export function ProfileStep({ onSubmit }: ProfileStepProps) {
|
||||
const router = useRouter()
|
||||
const [otherLinks, setOtherLinks] = useState([""])
|
||||
const [twitterHandle, setTwitterHandle] = useState("")
|
||||
const [linkedinProfile, setLinkedinProfile] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [isSubmitting] = useState(false)
|
||||
const [errors, setErrors] = useState<ValidationError>({
|
||||
twitter: null,
|
||||
linkedin: null,
|
||||
})
|
||||
|
||||
const addOtherLink = () => {
|
||||
if (otherLinks.length < 3) {
|
||||
setOtherLinks([...otherLinks, ""])
|
||||
}
|
||||
}
|
||||
|
||||
const updateOtherLink = (index: number, value: string) => {
|
||||
const updated = [...otherLinks]
|
||||
updated[index] = value
|
||||
setOtherLinks(updated)
|
||||
}
|
||||
|
||||
const validateTwitterHandle = (handle: string): string | null => {
|
||||
if (!handle.trim()) return null
|
||||
|
||||
// Basic validation: handle should be alphanumeric, underscore, or hyphen
|
||||
// X/Twitter handles can contain letters, numbers, and underscores, max 15 chars
|
||||
const handlePattern = /^[a-zA-Z0-9_]{1,15}$/
|
||||
if (!handlePattern.test(handle.trim())) {
|
||||
return "Enter your handle or profile link"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const validateLinkedInHandle = (handle: string): string | null => {
|
||||
if (!handle.trim()) return null
|
||||
|
||||
// Basic validation: LinkedIn handles are typically alphanumeric with hyphens
|
||||
// They can be quite long, so we'll be lenient
|
||||
const handlePattern = /^[a-zA-Z0-9-]+$/
|
||||
if (!handlePattern.test(handle.trim())) {
|
||||
return "Enter your handle or profile link"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const handleTwitterChange = (value: string) => {
|
||||
setTwitterHandle(value)
|
||||
setErrors((prev) => ({ ...prev, twitter: null }))
|
||||
}
|
||||
|
||||
const handleTwitterBlur = () => {
|
||||
if (!twitterHandle.trim()) return
|
||||
const parsed = parseXHandle(twitterHandle)
|
||||
setTwitterHandle(parsed)
|
||||
const error = validateTwitterHandle(parsed)
|
||||
setErrors((prev) => ({ ...prev, twitter: error }))
|
||||
}
|
||||
|
||||
const handleLinkedInChange = (value: string) => {
|
||||
setLinkedinProfile(value)
|
||||
setErrors((prev) => ({ ...prev, linkedin: null }))
|
||||
}
|
||||
|
||||
const handleLinkedInBlur = () => {
|
||||
if (!linkedinProfile.trim()) return
|
||||
const parsed = parseLinkedInHandle(linkedinProfile)
|
||||
setLinkedinProfile(parsed)
|
||||
const error = validateLinkedInHandle(parsed)
|
||||
setErrors((prev) => ({ ...prev, linkedin: error }))
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut", delay: 0.3 }}
|
||||
className="text-center w-full "
|
||||
>
|
||||
<h2 className="text-white text-[32px] font-medium mb-4 mt-[-36px]">
|
||||
Let's add your memories
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4 max-w-[329px] mx-auto overflow-visible gap-4">
|
||||
<div className="text-left gap-[6px] flex flex-col" id="x-twitter-field">
|
||||
<label
|
||||
htmlFor="twitter-handle"
|
||||
className="text-white text-sm font-medium block pl-2"
|
||||
>
|
||||
X/Twitter
|
||||
</label>
|
||||
<input
|
||||
id="twitter-handle"
|
||||
type="text"
|
||||
placeholder="x.com/handle or @handle"
|
||||
value={twitterHandle}
|
||||
onChange={(e) => handleTwitterChange(e.target.value)}
|
||||
onBlur={handleTwitterBlur}
|
||||
className={`w-full px-4 py-2 bg-[#070E1B] border rounded-xl text-white placeholder-onboarding focus:outline-none focus:border-[#4A4A4A] transition-colors h-[40px] ${
|
||||
errors.twitter
|
||||
? "border-[#52596633] bg-[#290F0A]"
|
||||
: "border-onboarding/20"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-left gap-[6px] flex flex-col" id="linkedin-field">
|
||||
<label
|
||||
htmlFor="linkedin-profile"
|
||||
className="text-white text-sm font-medium block pl-2"
|
||||
>
|
||||
LinkedIn
|
||||
</label>
|
||||
<input
|
||||
id="linkedin-profile"
|
||||
type="text"
|
||||
placeholder="linkedin.com/in/username or username"
|
||||
value={linkedinProfile}
|
||||
onChange={(e) => handleLinkedInChange(e.target.value)}
|
||||
onBlur={handleLinkedInBlur}
|
||||
className={`w-full px-4 py-2 bg-[#070E1B] border rounded-xl text-white placeholder-onboarding focus:outline-none focus:border-[#4A4A4A] transition-colors h-[40px] ${
|
||||
errors.linkedin
|
||||
? "border-[#52596633] bg-[#290F0A]"
|
||||
: "border-onboarding/20"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-left gap-[6px] flex flex-col"
|
||||
id="other-links-field"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="other-links"
|
||||
className="text-white text-sm font-medium pl-2"
|
||||
>
|
||||
Other links
|
||||
</label>
|
||||
<span className="text-onboarding text-[10px]">Upto 3</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{otherLinks.map((link, index) => (
|
||||
<div
|
||||
key={`other-link-${index}`}
|
||||
className="flex items-center relative"
|
||||
>
|
||||
<input
|
||||
id={`other-links-${index}`}
|
||||
type="text"
|
||||
placeholder="Add your website, GitHub, Notion..."
|
||||
value={link}
|
||||
onChange={(e) => updateOtherLink(index, e.target.value)}
|
||||
className="flex-1 px-4 py-2 bg-[#070E1B] border border-onboarding/20 rounded-xl text-white placeholder-onboarding focus:outline-none focus:border-[#4A4A4A] transition-colors h-[40px]"
|
||||
/>
|
||||
{index === otherLinks.length - 1 && otherLinks.length < 3 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
addOtherLink()
|
||||
}}
|
||||
className="size-8 m-1 absolute right-0 top-0 bg-black border border-[#161F2C] rounded-lg flex items-center justify-center text-white hover:bg-[#161F2C] transition-colors text-xl"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-left gap-[6px] flex flex-col"
|
||||
id="description-field"
|
||||
>
|
||||
<label
|
||||
htmlFor="description"
|
||||
className="text-white text-sm font-medium block pl-2"
|
||||
>
|
||||
What do you do? What do you like?
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
placeholder="Tell me the basics in your words. A few lines about your work, interests, etc."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 bg-[#070E1B] border border-onboarding/20 rounded-xl text-white placeholder-onboarding focus:outline-none focus:border-[#4A4A4A] transition-colors min-h-16"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
transition={{ duration: 1, ease: "easeOut", delay: 1 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
className="mt-[24px] pb-30"
|
||||
>
|
||||
<Button
|
||||
variant="onboarding"
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0D121A -26.14%, #000 100%)",
|
||||
}}
|
||||
onClick={() => {
|
||||
const formData = {
|
||||
twitter: toXProfileUrl(parseXHandle(twitterHandle)),
|
||||
linkedin: toLinkedInProfileUrl(
|
||||
parseLinkedInHandle(linkedinProfile),
|
||||
),
|
||||
description: description,
|
||||
otherLinks: otherLinks
|
||||
.filter((l) => l.trim())
|
||||
.map((l) => normalizeUrl(l.trim())),
|
||||
}
|
||||
analytics.onboardingProfileSubmitted({
|
||||
has_twitter: !!twitterHandle.trim(),
|
||||
has_linkedin: !!linkedinProfile.trim(),
|
||||
other_links_count: otherLinks.filter((l) => l.trim()).length,
|
||||
description_length: description.trim().length,
|
||||
})
|
||||
onSubmit(formData)
|
||||
router.push("/onboarding/setup?step=relatable")
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? "Fetching..." : "Remember this →"}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { motion } from "motion/react"
|
||||
|
||||
export function WelcomeStep() {
|
||||
return (
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ opacity: 0, y: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 0 }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
layout
|
||||
>
|
||||
<h2 className="text-white text-[32px] font-medium mb-2">Welcome to...</h2>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -34,75 +34,77 @@ export function XBookmarksDetailView({ onBack }: XBookmarksDetailViewProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-8">
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-white hover:text-gray-300 p-0 hover:no-underline cursor-pointer"
|
||||
onClick={onBack}
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start justify-start flex-1">
|
||||
<div>
|
||||
<h1 className="text-white text-[20px] font-medium mb-3 text-start">
|
||||
Import your X bookmarks via the Chrome Extension
|
||||
</h1>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-sm mb-6 text-start max-w-2xl",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
<div className="flex flex-col flex-1 w-full p-8">
|
||||
<div className="mx-auto w-full max-w-3xl flex flex-col flex-1">
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-white hover:text-gray-300 p-0 hover:no-underline cursor-pointer"
|
||||
onClick={onBack}
|
||||
>
|
||||
Bring your X bookmarks into Supermemory in just a few clicks.
|
||||
They'll be automatically embedded so you can easily find what you
|
||||
need, right when you need it.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-6 max-w-5xl w-full">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.number}
|
||||
className="flex flex-col items-center text-center bg-[#080B0F] p-3 rounded-[10px]"
|
||||
>
|
||||
<div className="rounded-2xl p-6 mb-3 w-full aspect-4/4 flex items-center justify-center relative overflow-hidden">
|
||||
<Image
|
||||
src={step.image}
|
||||
alt={`Step ${step.number}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="mb-2">
|
||||
<span className="text-white text-sm font-medium">
|
||||
Step {step.number}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-sm text-start",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
← Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="rounded-xl px-4 py-2 text-white h-10 cursor-pointer mx-auto bg-black"
|
||||
onClick={handleInstall}
|
||||
>
|
||||
Install Chrome Extension →
|
||||
</Button>
|
||||
<div className="flex flex-col items-start justify-start flex-1">
|
||||
<div>
|
||||
<h1 className="text-white text-[20px] font-medium mb-3 text-start">
|
||||
Import your X bookmarks via the Chrome Extension
|
||||
</h1>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-sm mb-6 text-start max-w-2xl",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Bring your X bookmarks into Supermemory in just a few clicks.
|
||||
They'll be automatically embedded so you can easily find what you
|
||||
need, right when you need it.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-6 max-w-5xl w-full">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.number}
|
||||
className="flex flex-col items-center text-center bg-[#080B0F] p-3 rounded-[10px]"
|
||||
>
|
||||
<div className="rounded-2xl p-6 mb-3 w-full aspect-4/4 flex items-center justify-center relative overflow-hidden">
|
||||
<Image
|
||||
src={step.image}
|
||||
alt={`Step ${step.number}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="mb-2">
|
||||
<span className="text-white text-sm font-medium">
|
||||
Step {step.number}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-sm text-start",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="rounded-xl px-4 py-2 text-white h-10 cursor-pointer mx-auto bg-black"
|
||||
onClick={handleInstall}
|
||||
>
|
||||
Install Chrome Extension →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { cn } from "@lib/utils"
|
|||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { Loader2, XIcon } from "lucide-react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { Checkbox } from "@ui/components/checkbox"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -42,12 +43,15 @@ export function RemoveConnectionDialog({
|
|||
onConfirm,
|
||||
isDeleting,
|
||||
}: RemoveConnectionDialogProps) {
|
||||
const [action, setAction] = useState<"keep" | "delete">("keep")
|
||||
const [alsoDelete, setAlsoDelete] = useState(false)
|
||||
const displayName =
|
||||
providerName || (provider ? PROVIDER_LABELS[provider] : "this connection")
|
||||
|
||||
const memoryNoun = documentCount === 1 ? "memory" : "memories"
|
||||
const hasMemories = documentCount > 0
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(action === "delete")
|
||||
onConfirm(alsoDelete)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -56,13 +60,13 @@ export function RemoveConnectionDialog({
|
|||
onOpenChange={(o) => {
|
||||
if (!isDeleting) {
|
||||
onOpenChange(o)
|
||||
if (!o) setAction("keep")
|
||||
if (!o) setAlsoDelete(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"w-[90%]! max-w-[500px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-4 rounded-[22px]",
|
||||
"w-[90%]! max-w-[480px]! border-none bg-[#1B1F24] flex flex-col p-5 gap-4 rounded-[22px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
|
|
@ -71,35 +75,16 @@ export function RemoveConnectionDialog({
|
|||
}}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="pl-1 space-y-1 flex-1">
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Remove connection
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#737373] font-medium text-[16px] leading-[1.35]">
|
||||
What would you like to do with the{" "}
|
||||
{documentCount > 0 ? (
|
||||
<>
|
||||
<span className="text-[#fafafa] font-medium">
|
||||
{documentCount}
|
||||
</span>{" "}
|
||||
memories from{" "}
|
||||
</>
|
||||
) : (
|
||||
<>memories from </>
|
||||
)}
|
||||
<span className="text-[#fafafa] font-medium">
|
||||
{displayName}
|
||||
</span>
|
||||
?
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa] flex-1",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Disconnect {displayName}?
|
||||
</DialogTitle>
|
||||
<DialogPrimitive.Close
|
||||
disabled={isDeleting}
|
||||
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
|
||||
|
|
@ -112,79 +97,37 @@ export function RemoveConnectionDialog({
|
|||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAction("keep")}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-[12px] cursor-pointer transition-colors w-full text-left",
|
||||
action === "keep"
|
||||
? "bg-[#14161A] border border-[rgba(82,89,102,0.3)]"
|
||||
: "bg-[#14161A]/50 border border-transparent hover:border-[rgba(82,89,102,0.2)]",
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
action === "keep"
|
||||
? "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)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
action === "keep" ? "border-blue-500" : "border-[#737373]",
|
||||
)}
|
||||
>
|
||||
{action === "keep" && (
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[#fafafa] text-sm font-medium">
|
||||
Remove connection only
|
||||
</span>
|
||||
<span className="text-[#737373] text-xs">
|
||||
Disconnect the integration but keep all imported memories
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<DialogDescription className="text-[#737373] text-[14px] leading-[1.45]">
|
||||
{hasMemories ? (
|
||||
<>
|
||||
Sync stops. Your{" "}
|
||||
<span className="text-[#fafafa] font-medium">
|
||||
{documentCount} {memoryNoun}
|
||||
</span>{" "}
|
||||
stay in Supermemory.
|
||||
</>
|
||||
) : (
|
||||
<>Sync stops. No memories were imported from this connection.</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAction("delete")}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-[12px] cursor-pointer transition-colors w-full text-left",
|
||||
action === "delete"
|
||||
? "bg-[#14161A] border border-[rgba(220,38,38,0.3)]"
|
||||
: "bg-[#14161A]/50 border border-transparent hover:border-[rgba(82,89,102,0.2)]",
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
action === "delete"
|
||||
? "0px 1px 2px 0px rgba(87,0,0,0.1), inset 0px 0px 0px 1px rgba(67,43,43,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08)"
|
||||
: "none",
|
||||
}}
|
||||
{hasMemories && (
|
||||
<label
|
||||
htmlFor="also-delete-memories"
|
||||
className="flex items-center gap-2.5 cursor-pointer text-[13px] py-1 select-none"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
action === "delete" ? "border-red-500" : "border-[#737373]",
|
||||
)}
|
||||
>
|
||||
{action === "delete" && (
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[#fafafa] text-sm font-medium">
|
||||
Remove connection and memories
|
||||
</span>
|
||||
<span className="text-[#737373] text-xs">
|
||||
Permanently delete all memories imported from this connection
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="also-delete-memories"
|
||||
checked={alsoDelete}
|
||||
onCheckedChange={(checked) => setAlsoDelete(checked === true)}
|
||||
disabled={isDeleting}
|
||||
/>
|
||||
<span className="text-[#B5B8BD]">
|
||||
Also delete the {documentCount} imported {memoryNoun}{" "}
|
||||
<span className="text-[#737373] italic">(optional)</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
|
|
@ -192,7 +135,7 @@ export function RemoveConnectionDialog({
|
|||
disabled={isDeleting}
|
||||
onClick={() => {
|
||||
onOpenChange(false)
|
||||
setAction("keep")
|
||||
setAlsoDelete(false)
|
||||
}}
|
||||
className="text-[#737373] cursor-pointer rounded-full"
|
||||
>
|
||||
|
|
@ -203,19 +146,18 @@ export function RemoveConnectionDialog({
|
|||
disabled={isDeleting}
|
||||
onClick={handleConfirm}
|
||||
className={cn(
|
||||
action === "delete" &&
|
||||
"bg-red-600! hover:bg-red-700! text-white",
|
||||
alsoDelete && "bg-red-600! hover:bg-red-700! text-white",
|
||||
)}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Removing...
|
||||
Disconnecting...
|
||||
</>
|
||||
) : action === "delete" ? (
|
||||
"Remove & delete memories"
|
||||
) : alsoDelete ? (
|
||||
`Disconnect and delete ${memoryNoun}`
|
||||
) : (
|
||||
"Remove connection"
|
||||
"Disconnect"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ import { XIcon, Search, Check } from "lucide-react"
|
|||
import { Button } from "@ui/components/button"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import type { ContainerTagListType } from "@lib/types"
|
||||
import {
|
||||
compareSpacesUserFirst,
|
||||
spaceSelectorDisplayName,
|
||||
} from "@/lib/ingest-auto-space"
|
||||
|
||||
interface SelectSpacesModalProps {
|
||||
isOpen: boolean
|
||||
|
|
@ -75,24 +79,26 @@ export function SelectSpacesModal({
|
|||
name: "My Space",
|
||||
emoji: "📁",
|
||||
containerTag: DEFAULT_PROJECT_ID,
|
||||
isExperimental: false,
|
||||
isNova: false,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
} as ContainerTagListType
|
||||
|
||||
const rest = projects
|
||||
.filter((p) => p.containerTag !== DEFAULT_PROJECT_ID)
|
||||
.sort(compareSpacesUserFirst)
|
||||
|
||||
const allSpaces = [defaultSpace, ...rest]
|
||||
if (!searchQuery.trim()) {
|
||||
return allSpaces
|
||||
}
|
||||
|
||||
const allSpaces = [
|
||||
defaultSpace,
|
||||
...projects.filter((p) => p.containerTag !== DEFAULT_PROJECT_ID),
|
||||
]
|
||||
|
||||
let result = allSpaces
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
result = allSpaces.filter(
|
||||
(p) =>
|
||||
p.containerTag.toLowerCase().includes(query) ||
|
||||
p.name?.toLowerCase().includes(query),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
return allSpaces.filter(
|
||||
(p) =>
|
||||
p.containerTag.toLowerCase().includes(query) ||
|
||||
(p.name ?? "").toLowerCase().includes(query),
|
||||
)
|
||||
}, [projects, searchQuery])
|
||||
|
||||
return (
|
||||
|
|
@ -169,7 +175,7 @@ export function SelectSpacesModal({
|
|||
type="button"
|
||||
onClick={() => handleToggle(project.containerTag)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 w-full px-3 py-2.5 rounded-[12px] cursor-pointer transition-colors text-left",
|
||||
"flex min-w-0 max-w-full items-center gap-3 w-full px-3 py-2.5 rounded-[12px] cursor-pointer transition-colors text-left",
|
||||
isSelected
|
||||
? "bg-[#14161A] border border-[rgba(82,89,102,0.3)]"
|
||||
: "bg-transparent border border-transparent hover:bg-[#14161A]/50",
|
||||
|
|
@ -198,9 +204,14 @@ export function SelectSpacesModal({
|
|||
{isSelected && <Check className="size-3 text-white" />}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-lg">{project.emoji || "📁"}</span>
|
||||
<span className="text-[#fafafa] text-sm font-medium truncate flex-1">
|
||||
{project.name ?? project.containerTag}
|
||||
<span className="shrink-0 text-lg">
|
||||
{project.emoji || "📁"}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-[#fafafa] text-sm font-medium"
|
||||
title={project.name ?? project.containerTag}
|
||||
>
|
||||
{spaceSelectorDisplayName(project, project.containerTag)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,15 +4,7 @@ import { useState, useMemo } from "react"
|
|||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import {
|
||||
ChevronsLeftRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
XIcon,
|
||||
Loader2,
|
||||
Globe,
|
||||
Layers,
|
||||
} from "lucide-react"
|
||||
import { ChevronDown, Plus, Trash2, XIcon, Loader2, Layers } from "lucide-react"
|
||||
import type { ContainerTagListType } from "@lib/types"
|
||||
import { AddSpaceModal } from "./add-space-modal"
|
||||
import { SelectSpacesModal } from "./select-spaces-modal"
|
||||
|
|
@ -42,6 +34,10 @@ import {
|
|||
} from "@repo/ui/components/select"
|
||||
import { Button } from "@repo/ui/components/button"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import {
|
||||
compareSpacesUserFirst,
|
||||
spaceSelectorDisplayName,
|
||||
} from "@/lib/ingest-auto-space"
|
||||
|
||||
export interface SpaceSelectorProps {
|
||||
selectedProjects: string[]
|
||||
|
|
@ -57,8 +53,13 @@ export interface SpaceSelectorProps {
|
|||
}
|
||||
|
||||
const triggerVariants = {
|
||||
default: "px-3 py-2 rounded-md hover:bg-white/5",
|
||||
insideOut: "px-3 py-2 rounded-full bg-[#0D121A] shadow-inside-out",
|
||||
default:
|
||||
"h-10 min-h-10 shrink-0 rounded-full border border-[#161F2C] bg-muted px-3 gap-2 " +
|
||||
"hover:bg-white/5 " +
|
||||
"data-[state=open]:border-[#2261CA33] data-[state=open]:bg-[#00173C]/35 " +
|
||||
"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]",
|
||||
}
|
||||
|
||||
export function SpaceSelector({
|
||||
|
|
@ -90,17 +91,21 @@ export function SpaceSelector({
|
|||
|
||||
const { deleteProjectMutation } = useProjectMutations()
|
||||
|
||||
const { allProjects, novaProjects, isLoading } = useContainerTags()
|
||||
const { allProjects, isLoading } = useContainerTags()
|
||||
|
||||
const isNovaSpaces = selectedProjects.length === 0
|
||||
const sortedOtherSpaces = useMemo(
|
||||
() =>
|
||||
allProjects
|
||||
.filter(
|
||||
(p: ContainerTagListType) => p.containerTag !== DEFAULT_PROJECT_ID,
|
||||
)
|
||||
.sort(compareSpacesUserFirst),
|
||||
[allProjects],
|
||||
)
|
||||
|
||||
const displayInfo = useMemo(() => {
|
||||
if (isNovaSpaces) {
|
||||
return { name: "Nova Spaces", emoji: null, isMultiple: false }
|
||||
}
|
||||
|
||||
if (selectedProjects.length === 1) {
|
||||
const containerTag = selectedProjects[0]
|
||||
const containerTag = selectedProjects[0] ?? ""
|
||||
if (containerTag === DEFAULT_PROJECT_ID) {
|
||||
return { name: "My Space", emoji: "📁", isMultiple: false }
|
||||
}
|
||||
|
|
@ -108,24 +113,22 @@ export function SpaceSelector({
|
|||
(p: ContainerTagListType) => p.containerTag === containerTag,
|
||||
)
|
||||
return {
|
||||
name: found?.name || containerTag,
|
||||
name: spaceSelectorDisplayName(found, containerTag),
|
||||
emoji: found?.emoji || "📁",
|
||||
isMultiple: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: `${selectedProjects.length} spaces`,
|
||||
emoji: null,
|
||||
isMultiple: true,
|
||||
if (selectedProjects.length > 1) {
|
||||
return {
|
||||
name: `${selectedProjects.length} spaces`,
|
||||
emoji: null,
|
||||
isMultiple: true,
|
||||
}
|
||||
}
|
||||
}, [allProjects, selectedProjects, isNovaSpaces])
|
||||
|
||||
const handleSelectNovaSpaces = () => {
|
||||
analytics.spaceSwitched({ space_id: "nova_spaces" })
|
||||
onValueChange([]) // Empty array = "Nova Spaces" (all nova)
|
||||
setIsOpen(false)
|
||||
}
|
||||
return { name: "My Space", emoji: "📁", isMultiple: false }
|
||||
}, [allProjects, selectedProjects])
|
||||
|
||||
const handleSelectSingleSpace = (containerTag: string) => {
|
||||
analytics.spaceSwitched({ space_id: containerTag })
|
||||
|
|
@ -204,13 +207,13 @@ export function SpaceSelector({
|
|||
}
|
||||
|
||||
const availableTargetProjects = useMemo(() => {
|
||||
const filtered = novaProjects.filter(
|
||||
const filtered = allProjects.filter(
|
||||
(p: ContainerTagListType) =>
|
||||
p.id !== deleteDialog.project?.id &&
|
||||
p.containerTag !== deleteDialog.project?.containerTag,
|
||||
)
|
||||
|
||||
const defaultProject = novaProjects.find(
|
||||
const defaultProject = allProjects.find(
|
||||
(p: ContainerTagListType) => p.containerTag === DEFAULT_PROJECT_ID,
|
||||
)
|
||||
|
||||
|
|
@ -227,7 +230,7 @@ export function SpaceSelector({
|
|||
}
|
||||
|
||||
return filtered
|
||||
}, [novaProjects, deleteDialog.project])
|
||||
}, [allProjects, deleteDialog.project])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -235,36 +238,68 @@ export function SpaceSelector({
|
|||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
isLoading
|
||||
? "Loading spaces"
|
||||
: `Space: ${displayInfo.name}. Open menu to switch.`
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-2 cursor-pointer transition-colors focus:outline-none focus-visible:outline-none",
|
||||
"flex min-w-0 max-w-full items-center cursor-pointer transition-colors",
|
||||
triggerVariants[variant],
|
||||
variant === "default" && compact && "h-9 min-h-9 gap-1.5 px-2.5",
|
||||
dmSansClassName(),
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
{isNovaSpaces ? (
|
||||
<Globe className="size-4 text-white" />
|
||||
) : displayInfo.isMultiple ? (
|
||||
<Layers className="size-4 text-white" />
|
||||
{displayInfo.isMultiple ? (
|
||||
<Layers
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
variant === "insideOut" ? "text-white" : "text-[#737373]",
|
||||
compact ? "size-3.5" : "size-4",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm font-bold tracking-[-0.98px]">
|
||||
<span
|
||||
className="shrink-0 text-sm font-bold tracking-[-0.98px]"
|
||||
aria-hidden
|
||||
>
|
||||
{displayInfo.emoji}
|
||||
</span>
|
||||
)}
|
||||
{!compact && (
|
||||
<span className="text-sm font-medium text-white">
|
||||
{isLoading ? "..." : displayInfo.name}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-white",
|
||||
"max-w-[10rem] md:max-w-[15rem]",
|
||||
)}
|
||||
title={isLoading ? undefined : displayInfo.name}
|
||||
>
|
||||
{isLoading ? "…" : displayInfo.name}
|
||||
</span>
|
||||
)}
|
||||
{compact && (
|
||||
<span className="sr-only">
|
||||
{isLoading ? "Loading" : displayInfo.name}
|
||||
</span>
|
||||
)}
|
||||
{showChevron && (
|
||||
<ChevronsLeftRight className="size-4 rotate-90 text-white/70" />
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"shrink-0 opacity-90",
|
||||
variant === "insideOut" ? "text-white/80" : "text-[#737373]",
|
||||
compact ? "size-3.5" : "size-4",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn(
|
||||
"min-w-[200px] p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
"min-w-[200px] max-w-[min(calc(100vw-1.5rem),20rem)] overflow-hidden p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
dmSansClassName(),
|
||||
contentClassName,
|
||||
)}
|
||||
|
|
@ -272,37 +307,23 @@ export function SpaceSelector({
|
|||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col">
|
||||
{!singleSelect && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={handleSelectNovaSpaces}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium",
|
||||
isNovaSpaces
|
||||
? "bg-[#293952]/40"
|
||||
: "opacity-60 hover:opacity-100 hover:bg-[#293952]/40",
|
||||
)}
|
||||
>
|
||||
<Globe className="size-4" />
|
||||
<span className="flex-1">Nova Spaces</span>
|
||||
</DropdownMenuItem>
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-2">
|
||||
<div className="shrink-0 px-3 py-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[#737373] font-medium">
|
||||
My Spaces
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator className="bg-[#2E3033] my-1" />
|
||||
</>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 max-h-[min(40vh,18rem)] min-w-0 flex-col overflow-y-auto overflow-x-hidden overscroll-contain",
|
||||
"scrollbar-thin pr-0.5",
|
||||
)}
|
||||
|
||||
<div className="px-3 py-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-[#737373] font-medium">
|
||||
My Spaces
|
||||
</span>
|
||||
</div>
|
||||
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSelectSingleSpace(DEFAULT_PROJECT_ID)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium",
|
||||
"flex min-w-0 max-w-full items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium",
|
||||
selectedProjects.length === 1 &&
|
||||
selectedProjects[0] === DEFAULT_PROJECT_ID
|
||||
? "bg-[#293952]/40"
|
||||
|
|
@ -310,59 +331,57 @@ export function SpaceSelector({
|
|||
)}
|
||||
>
|
||||
<span className="font-bold tracking-[-0.98px]">📁</span>
|
||||
<span className="flex-1">My Space</span>
|
||||
<span className="min-w-0 flex-1 truncate">My Space</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{novaProjects
|
||||
.filter(
|
||||
(p: ContainerTagListType) =>
|
||||
p.containerTag !== DEFAULT_PROJECT_ID,
|
||||
)
|
||||
.map((project: ContainerTagListType) => (
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onClick={() =>
|
||||
handleSelectSingleSpace(project.containerTag)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium group",
|
||||
selectedProjects.length === 1 &&
|
||||
selectedProjects[0] === project.containerTag
|
||||
? "bg-[#293952]/40"
|
||||
: "opacity-60 hover:opacity-100 hover:bg-[#293952]/40",
|
||||
)}
|
||||
{sortedOtherSpaces.map((project: ContainerTagListType) => (
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onClick={() => handleSelectSingleSpace(project.containerTag)}
|
||||
className={cn(
|
||||
"flex min-w-0 max-w-full items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium group",
|
||||
selectedProjects.length === 1 &&
|
||||
selectedProjects[0] === project.containerTag
|
||||
? "bg-[#293952]/40"
|
||||
: "opacity-60 hover:opacity-100 hover:bg-[#293952]/40",
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 font-bold tracking-[-0.98px]">
|
||||
{project.emoji || "📁"}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate"
|
||||
title={project.name ?? project.containerTag}
|
||||
>
|
||||
<span className="font-bold tracking-[-0.98px]">
|
||||
{project.emoji || "📁"}
|
||||
</span>
|
||||
<span className="truncate flex-1">
|
||||
{project.name ?? project.containerTag}
|
||||
</span>
|
||||
{enableDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) =>
|
||||
handleDeleteClick(e, {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
containerTag: project.containerTag,
|
||||
})
|
||||
}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-red-500/20"
|
||||
>
|
||||
<Trash2 className="size-3.5 text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{spaceSelectorDisplayName(project, project.containerTag)}
|
||||
</span>
|
||||
{enableDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) =>
|
||||
handleDeleteClick(e, {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
containerTag: project.containerTag,
|
||||
})
|
||||
}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-red-500/20"
|
||||
>
|
||||
<Trash2 className="size-3.5 text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator className="bg-[#2E3033]" />
|
||||
<DropdownMenuSeparator className="shrink-0 bg-[#2E3033]" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenSelectSpaces}
|
||||
className="flex items-center justify-center gap-2 px-3 py-2 rounded-md cursor-pointer text-white text-sm font-medium border border-[#161F2C] hover:bg-[#0D121A]/80 transition-colors"
|
||||
className={cn(
|
||||
"flex min-w-0 w-full max-w-full shrink-0 items-center justify-center gap-2 px-3 py-2 rounded-md cursor-pointer text-white text-sm font-medium border border-[#161F2C] hover:bg-[#0D121A]/80 transition-colors",
|
||||
)}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0D121A 0%, #000000 100%)",
|
||||
}}
|
||||
|
|
@ -375,7 +394,9 @@ export function SpaceSelector({
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleNewSpace}
|
||||
className="flex items-center justify-center gap-2 px-3 py-2 rounded-md cursor-pointer text-white text-sm font-medium border border-[#161F2C] hover:bg-[#0D121A]/80 transition-colors"
|
||||
className={cn(
|
||||
"flex min-w-0 w-full max-w-full shrink-0 items-center justify-center gap-2 px-3 py-2 rounded-md cursor-pointer text-white text-sm font-medium border border-[#161F2C] hover:bg-[#0D121A]/80 transition-colors",
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, #0D121A 0%, #000000 100%)",
|
||||
|
|
@ -544,12 +565,12 @@ export function SpaceSelector({
|
|||
value={p.id}
|
||||
className="text-[#fafafa] hover:bg-[#1B1F24] cursor-pointer rounded-md"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span>{p.emoji || "📁"}</span>
|
||||
<span>
|
||||
<span className="truncate">
|
||||
{p.containerTag === DEFAULT_PROJECT_ID
|
||||
? "My Space"
|
||||
: p.name}
|
||||
: spaceSelectorDisplayName(p, p.containerTag)}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
|
|
|
|||
429
apps/web/components/timeline-view.tsx
Normal file
429
apps/web/components/timeline-view.tsx
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { SyncLogoIcon } from "@ui/assets/icons"
|
||||
import { DocumentIcon } from "@/components/document-icon"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
// ─── Time period helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function getTimePeriodLabel(date: Date, now: Date): string {
|
||||
const docDay = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
const todayDay = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const diffDays = Math.round(
|
||||
(todayDay.getTime() - docDay.getTime()) / 86400000,
|
||||
)
|
||||
|
||||
if (diffDays === 0) return "Today"
|
||||
if (diffDays === 1) return "Yesterday"
|
||||
if (diffDays < 7) return date.toLocaleDateString("en-US", { weekday: "long" })
|
||||
if (date.getFullYear() === now.getFullYear())
|
||||
return date.toLocaleDateString("en-US", { month: "long", day: "numeric" })
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Document type helpers ────────────────────────────────────────────────────
|
||||
|
||||
type CategoryInfo = { label: string; singularLabel: string; key: string }
|
||||
|
||||
function getDocumentTypeInfo(doc: DocumentWithMemories): CategoryInfo {
|
||||
if (doc.source === "mcp")
|
||||
return { label: "MCP Items", singularLabel: "MCP Item", key: "mcp" }
|
||||
if (doc.url?.includes("youtube.com") || doc.url?.includes("youtu.be"))
|
||||
return {
|
||||
label: "YouTube Videos",
|
||||
singularLabel: "YouTube Video",
|
||||
key: "youtube",
|
||||
}
|
||||
switch (doc.type) {
|
||||
case "tweet":
|
||||
return { label: "Tweets", singularLabel: "Tweet", key: "tweet" }
|
||||
case "google_doc":
|
||||
return {
|
||||
label: "Google Docs",
|
||||
singularLabel: "Google Doc",
|
||||
key: "google_doc",
|
||||
}
|
||||
case "google_slide":
|
||||
return {
|
||||
label: "Google Slides",
|
||||
singularLabel: "Google Slide",
|
||||
key: "google_slide",
|
||||
}
|
||||
case "google_sheet":
|
||||
return {
|
||||
label: "Google Sheets",
|
||||
singularLabel: "Google Sheet",
|
||||
key: "google_sheet",
|
||||
}
|
||||
case "notion_doc":
|
||||
return {
|
||||
label: "Notion Docs",
|
||||
singularLabel: "Notion Doc",
|
||||
key: "notion_doc",
|
||||
}
|
||||
case "text":
|
||||
return { label: "Notes", singularLabel: "Note", key: "text" }
|
||||
case "pdf":
|
||||
return { label: "PDFs", singularLabel: "PDF", key: "pdf" }
|
||||
case "image":
|
||||
return { label: "Images", singularLabel: "Image", key: "image" }
|
||||
case "video":
|
||||
return { label: "Videos", singularLabel: "Video", key: "video" }
|
||||
case "onedrive":
|
||||
return {
|
||||
label: "OneDrive Files",
|
||||
singularLabel: "OneDrive File",
|
||||
key: "onedrive",
|
||||
}
|
||||
case "webpage":
|
||||
return { label: "Web Pages", singularLabel: "Web Page", key: "webpage" }
|
||||
default:
|
||||
return doc.url?.startsWith("https://")
|
||||
? { label: "Web Pages", singularLabel: "Web Page", key: "webpage" }
|
||||
: { label: "Notes", singularLabel: "Note", key: "text" }
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviewText(doc: DocumentWithMemories): string {
|
||||
return doc.summary || doc.content || doc.title || ""
|
||||
}
|
||||
|
||||
// ─── Grouped data structures ─────────────────────────────────────────────────
|
||||
|
||||
type TypeGroup = { categoryInfo: CategoryInfo; docs: DocumentWithMemories[] }
|
||||
type PeriodGroup = { label: string; typeGroups: TypeGroup[] }
|
||||
|
||||
function groupDocuments(
|
||||
documents: DocumentWithMemories[],
|
||||
now: Date,
|
||||
): PeriodGroup[] {
|
||||
const sorted = [...documents].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
)
|
||||
|
||||
const periodMap = new Map<string, DocumentWithMemories[]>()
|
||||
const periodOrder: string[] = []
|
||||
|
||||
for (const doc of sorted) {
|
||||
const label = getTimePeriodLabel(new Date(doc.createdAt), now)
|
||||
if (!periodMap.has(label)) {
|
||||
periodMap.set(label, [])
|
||||
periodOrder.push(label)
|
||||
}
|
||||
periodMap.get(label)?.push(doc)
|
||||
}
|
||||
|
||||
return periodOrder.map((label) => {
|
||||
const docs = periodMap.get(label)!
|
||||
const categoryMap = new Map<
|
||||
string,
|
||||
{ info: CategoryInfo; docs: DocumentWithMemories[] }
|
||||
>()
|
||||
const categoryOrder: string[] = []
|
||||
|
||||
for (const doc of docs) {
|
||||
const info = getDocumentTypeInfo(doc)
|
||||
if (!categoryMap.has(info.key)) {
|
||||
categoryMap.set(info.key, { info, docs: [] })
|
||||
categoryOrder.push(info.key)
|
||||
}
|
||||
categoryMap.get(info.key)?.docs.push(doc)
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
typeGroups: categoryOrder.map((key) => {
|
||||
const entry = categoryMap.get(key)!
|
||||
return { categoryInfo: entry.info, docs: entry.docs }
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Individual timeline card ─────────────────────────────────────────────────
|
||||
|
||||
function TimelineCard({
|
||||
doc,
|
||||
onOpenDocument,
|
||||
indent = false,
|
||||
}: {
|
||||
doc: DocumentWithMemories
|
||||
onOpenDocument: (doc: DocumentWithMemories) => void
|
||||
indent?: boolean
|
||||
}) {
|
||||
const preview = getPreviewText(doc)
|
||||
const typeLabel = doc.type
|
||||
? doc.type.charAt(0).toUpperCase() + doc.type.slice(1).replace(/_/g, " ")
|
||||
: "Document"
|
||||
const totalMemories = doc.memoryEntries.length
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full text-left px-4 py-3 cursor-pointer transition-colors",
|
||||
indent
|
||||
? "bg-transparent hover:bg-white/[0.04]"
|
||||
: "rounded-2xl border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={() => onOpenDocument(doc)}
|
||||
>
|
||||
{/* Type label */}
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<DocumentIcon
|
||||
type={doc.type}
|
||||
source={doc.source ?? undefined}
|
||||
url={doc.url ?? undefined}
|
||||
className="w-3.5 h-3.5 shrink-0 opacity-60"
|
||||
/>
|
||||
<span className="text-[10px] text-white/40 uppercase tracking-widest">
|
||||
{typeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
{doc.title && (
|
||||
<p className="text-[13px] text-white/85 font-medium leading-snug line-clamp-2 mb-1.5">
|
||||
{doc.title}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{preview && (
|
||||
<p className="text-[12px] text-white/45 line-clamp-3 leading-relaxed">
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{totalMemories > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2.5">
|
||||
<SyncLogoIcon
|
||||
className="w-[11px] h-[9px]"
|
||||
style={{
|
||||
filter:
|
||||
"brightness(0) saturate(100%) invert(58%) sepia(69%) saturate(535%) hue-rotate(181deg) brightness(101%) contrast(98%)",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="text-[11px] font-medium"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}}
|
||||
>
|
||||
{totalMemories}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Collapsed group card ─────────────────────────────────────────────────────
|
||||
|
||||
function GroupCard({
|
||||
group,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onOpenDocument,
|
||||
expandKey,
|
||||
}: {
|
||||
group: TypeGroup
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
onOpenDocument: (doc: DocumentWithMemories) => void
|
||||
expandKey: string
|
||||
}) {
|
||||
const firstDoc = group.docs[0]!
|
||||
const preview = getPreviewText(firstDoc)
|
||||
const count = group.docs.length
|
||||
const { label, singularLabel } = group.categoryInfo
|
||||
const countLabel = count === 1 ? `1 ${singularLabel}` : `${count} ${label}`
|
||||
const totalMemories = group.docs.reduce(
|
||||
(sum, d) => sum + d.memoryEntries.length,
|
||||
0,
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full text-left rounded-2xl px-4 py-3 cursor-pointer transition-colors",
|
||||
"border border-[#252B35] bg-[#1B1F24] hover:bg-[#21262D]",
|
||||
"flex items-center justify-between gap-3",
|
||||
isExpanded && "rounded-b-none border-b-transparent",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
onClick={onToggle}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<DocumentIcon
|
||||
type={firstDoc.type}
|
||||
source={firstDoc.source ?? undefined}
|
||||
url={firstDoc.url ?? undefined}
|
||||
className="w-3.5 h-3.5 shrink-0 opacity-60"
|
||||
/>
|
||||
<span className="text-[13px] text-white/75 font-medium whitespace-nowrap shrink-0">
|
||||
{countLabel}
|
||||
</span>
|
||||
{preview && (
|
||||
<span className="text-[12px] text-white/35 truncate">
|
||||
— {preview}
|
||||
</span>
|
||||
)}
|
||||
{totalMemories > 0 && (
|
||||
<span
|
||||
className="text-[11px] font-medium shrink-0 ml-auto"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(94deg, #369BFD 4.8%, #36FDFD 77.04%, #36FDB5 143.99%)",
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}}
|
||||
>
|
||||
{totalMemories}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"w-3.5 h-3.5 text-white/20 shrink-0 transition-transform duration-200",
|
||||
isExpanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
id={`group-${expandKey}`}
|
||||
className="border border-t-0 border-[#252B35] rounded-b-2xl overflow-hidden divide-y divide-[#252B35]"
|
||||
>
|
||||
{group.docs.map((doc) => (
|
||||
<TimelineCard
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
onOpenDocument={onOpenDocument}
|
||||
indent
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main TimelineView ────────────────────────────────────────────────────────
|
||||
|
||||
interface TimelineViewProps {
|
||||
documents: DocumentWithMemories[]
|
||||
onOpenDocument: (document: DocumentWithMemories) => void
|
||||
hasNextPage?: boolean
|
||||
isFetchingNextPage?: boolean
|
||||
onLoadMore?: () => void
|
||||
}
|
||||
|
||||
export function TimelineView({
|
||||
documents,
|
||||
onOpenDocument,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
}: TimelineViewProps) {
|
||||
const [now] = useState(() => new Date())
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
||||
const sentinelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sentinelRef.current || !onLoadMore) return
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
onLoadMore()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
)
|
||||
observer.observe(sentinelRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [hasNextPage, isFetchingNextPage, onLoadMore])
|
||||
|
||||
const toggleGroup = useCallback((key: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const periodGroups = groupDocuments(documents, now)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full max-w-[780px] mx-auto py-4 pb-12 space-y-6",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{periodGroups.map((period) => (
|
||||
<div key={period.label} className="grid grid-cols-[88px_1fr] gap-x-4">
|
||||
<div className="pt-3 text-right shrink-0">
|
||||
<span className="text-[10px] text-white/30 font-medium uppercase tracking-[0.15em] leading-none">
|
||||
{period.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
{period.typeGroups.map((group) => {
|
||||
const expandKey = `${period.label}::${group.categoryInfo.key}`
|
||||
|
||||
if (group.docs.length === 1) {
|
||||
return (
|
||||
<TimelineCard
|
||||
key={expandKey}
|
||||
doc={group.docs[0]!}
|
||||
onOpenDocument={onOpenDocument}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupCard
|
||||
key={expandKey}
|
||||
group={group}
|
||||
expandKey={expandKey}
|
||||
isExpanded={expandedGroups.has(expandKey)}
|
||||
onToggle={() => toggleGroup(expandKey)}
|
||||
onOpenDocument={onOpenDocument}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useCustomer } from "autumn-js/react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import {
|
||||
|
|
@ -11,25 +12,33 @@ import {
|
|||
} from "@ui/components/dropdown-menu"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { LogOut, Settings, RotateCcw, HelpCircle } from "lucide-react"
|
||||
import { LogOut, Settings, RotateCcw, HelpCircle, LifeBuoy } from "lucide-react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { useOrgOnboarding } from "@hooks/use-org-onboarding"
|
||||
import { useTokenUsage } from "@/hooks/use-token-usage"
|
||||
|
||||
export function UserProfileMenu({
|
||||
className,
|
||||
avatarClassName,
|
||||
onOpenFeedback,
|
||||
}: {
|
||||
className?: string
|
||||
avatarClassName?: string
|
||||
onOpenFeedback?: () => void
|
||||
}) {
|
||||
const { user } = useAuth()
|
||||
const router = useRouter()
|
||||
const { resetOrgOnboarded } = useOrgOnboarding()
|
||||
const autumn = useCustomer()
|
||||
const { currentPlan, isLoading: planLoading } = useTokenUsage(autumn)
|
||||
|
||||
const planBadgeLabel =
|
||||
currentPlan === "pro" ? "PRO" : currentPlan === "scale" ? "SCALE" : null
|
||||
|
||||
const handleTryOnboarding = () => {
|
||||
resetOrgOnboarded()
|
||||
router.push("/onboarding?step=input&flow=welcome")
|
||||
router.push("/onboarding")
|
||||
}
|
||||
|
||||
const handleSignOut = () => {
|
||||
|
|
@ -45,27 +54,72 @@ export function UserProfileMenu({
|
|||
|
||||
if (!user) return null
|
||||
|
||||
const initials = (() => {
|
||||
if (user.name) {
|
||||
const parts = user.name.trim().split(/\s+/)
|
||||
return parts.length >= 2
|
||||
? `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
|
||||
: parts[0].slice(0, 2).toUpperCase()
|
||||
}
|
||||
if (user.email) return user.email.slice(0, 2).toUpperCase()
|
||||
return "SM"
|
||||
})()
|
||||
|
||||
const avatarColor = (() => {
|
||||
const palette = [
|
||||
"#0e2244", // navy blue
|
||||
"#1a1a3e", // deep indigo
|
||||
"#1e1030", // dark violet
|
||||
"#0d2e2e", // dark teal
|
||||
"#2a1020", // dark rose
|
||||
"#1a2a10", // deep forest
|
||||
"#2e1a0a", // dark amber
|
||||
"#0a1e2e", // ocean
|
||||
]
|
||||
const seed = user.email ?? user.name ?? ""
|
||||
let hash = 0
|
||||
for (let i = 0; i < seed.length; i++)
|
||||
hash = seed.charCodeAt(i) + ((hash << 5) - hash)
|
||||
return palette[((hash % palette.length) + palette.length) % palette.length]
|
||||
})()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
planBadgeLabel
|
||||
? `Account menu, ${planBadgeLabel} plan`
|
||||
: "Account menu"
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 transition-transform hover:scale-105",
|
||||
"relative inline-flex shrink-0 rounded-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
className={cn(
|
||||
"border border-[#2E3033] h-8 w-8 md:h-10 md:w-10",
|
||||
avatarClassName,
|
||||
)}
|
||||
className={cn("size-9 border border-[#161F2C]", avatarClassName)}
|
||||
>
|
||||
<AvatarImage src={user.image ?? ""} />
|
||||
<AvatarFallback className="bg-[#0D121A] text-white">
|
||||
{user.name?.charAt(0)}
|
||||
<AvatarFallback
|
||||
className="text-xs font-medium text-white"
|
||||
style={{ background: avatarColor }}
|
||||
>
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{!planLoading && planBadgeLabel ? (
|
||||
<span
|
||||
id="user-plan-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute -bottom-0.5 left-1/2 z-10 -translate-x-1/2 rounded border px-1 py-px text-center text-[8px] font-bold uppercase leading-tight tracking-wide",
|
||||
"border-[#2261CA33] bg-[#00173C] text-[#6BB0FF]",
|
||||
)}
|
||||
>
|
||||
{planBadgeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
|
|
@ -95,8 +149,17 @@ export function UserProfileMenu({
|
|||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 text-[#737373]" />
|
||||
Restart Onboarding
|
||||
Try onboarding
|
||||
</DropdownMenuItem>
|
||||
{onOpenFeedback ? (
|
||||
<DropdownMenuItem
|
||||
onClick={onOpenFeedback}
|
||||
className="px-3 py-2.5 rounded-md hover:bg-[#293952]/40 cursor-pointer text-white text-sm font-medium gap-2"
|
||||
>
|
||||
<LifeBuoy className="h-4 w-4 text-[#737373]" />
|
||||
Feedback
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator className="bg-[#2E3033]" />
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
|
|
|
|||
|
|
@ -4,6 +4,18 @@
|
|||
|
||||
@theme {
|
||||
--color-onboarding: #525966;
|
||||
|
||||
--color-fg-primary: #fafafa;
|
||||
--color-fg-secondary: #e2e8f0;
|
||||
--color-fg-muted: #d0dae7;
|
||||
--color-fg-subtle: #b5c2d3;
|
||||
--color-fg-faint: #a0aec4;
|
||||
|
||||
--color-surface-base: #0b1119;
|
||||
--color-surface-card: #101822;
|
||||
--color-surface-hover: #131b28;
|
||||
--color-surface-border: #263348;
|
||||
|
||||
--animate-file-upload-grow: file-upload-grow 6s cubic-bezier(0.22, 1, 0.36, 1)
|
||||
forwards;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useMemo } from "react"
|
||||
import { $fetch } from "@lib/api"
|
||||
import type { ContainerTagListType } from "@lib/types"
|
||||
|
||||
|
|
@ -18,20 +17,8 @@ export function useContainerTags() {
|
|||
staleTime: 30 * 1000,
|
||||
})
|
||||
|
||||
const novaProjects = useMemo(
|
||||
() => allProjects.filter((p) => p.isNova),
|
||||
[allProjects],
|
||||
)
|
||||
|
||||
const novaContainerTags = useMemo(
|
||||
() => novaProjects.map((p) => p.containerTag),
|
||||
[novaProjects],
|
||||
)
|
||||
|
||||
return {
|
||||
allProjects,
|
||||
novaProjects,
|
||||
novaContainerTags,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -293,6 +293,7 @@ export function useDocumentMutations({
|
|||
description: "Your note is being processed",
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
|
||||
onClose?.()
|
||||
},
|
||||
})
|
||||
|
|
@ -356,6 +357,7 @@ export function useDocumentMutations({
|
|||
description: "Your link is being processed",
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
|
||||
onClose?.()
|
||||
},
|
||||
})
|
||||
|
|
@ -499,6 +501,7 @@ export function useDocumentMutations({
|
|||
analytics.documentAdded({ type: "file", project_id: variables.project })
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["documents-with-memories"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["processing-documents"] })
|
||||
if (data.failures.length === 0) {
|
||||
toast.success(
|
||||
data.successCount === 1
|
||||
|
|
|
|||
301
apps/web/hooks/use-personalization.ts
Normal file
301
apps/web/hooks/use-personalization.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { $fetch } from "@lib/api"
|
||||
import type { SearchResult } from "@repo/lib/api"
|
||||
|
||||
const CACHE_KEY = "sm_profession_v1"
|
||||
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
export type Profession =
|
||||
| "developer"
|
||||
| "finance"
|
||||
| "research"
|
||||
| "design"
|
||||
| "legal"
|
||||
| "marketing"
|
||||
| "medical"
|
||||
| "default"
|
||||
|
||||
export interface PersonalizedCopy {
|
||||
saveLink: string
|
||||
writeNote: string
|
||||
chatPlaceholder: string
|
||||
}
|
||||
|
||||
const COPY: Record<Profession, PersonalizedCopy> = {
|
||||
developer: {
|
||||
saveLink: "Save a repo",
|
||||
writeNote: "Write dev notes",
|
||||
chatPlaceholder: "Ask about your code, docs, or notes…",
|
||||
},
|
||||
finance: {
|
||||
saveLink: "Save an article",
|
||||
writeNote: "Log a thesis",
|
||||
chatPlaceholder: "Ask about your research or portfolio…",
|
||||
},
|
||||
research: {
|
||||
saveLink: "Save a paper",
|
||||
writeNote: "Write notes",
|
||||
chatPlaceholder: "Ask about your reading or research…",
|
||||
},
|
||||
design: {
|
||||
saveLink: "Save inspiration",
|
||||
writeNote: "Write a brief",
|
||||
chatPlaceholder: "What are you working on today?",
|
||||
},
|
||||
legal: {
|
||||
saveLink: "Save a document",
|
||||
writeNote: "Write a memo",
|
||||
chatPlaceholder: "Ask about your cases or contracts…",
|
||||
},
|
||||
marketing: {
|
||||
saveLink: "Save a resource",
|
||||
writeNote: "Write campaign notes",
|
||||
chatPlaceholder: "Ask about your campaigns or research…",
|
||||
},
|
||||
medical: {
|
||||
saveLink: "Save a study",
|
||||
writeNote: "Write clinical notes",
|
||||
chatPlaceholder: "Ask about your research or cases…",
|
||||
},
|
||||
default: {
|
||||
saveLink: "Save link",
|
||||
writeNote: "Write note",
|
||||
chatPlaceholder: "Ask your supermemory…",
|
||||
},
|
||||
}
|
||||
|
||||
const KEYWORDS: Record<Exclude<Profession, "default">, string[]> = {
|
||||
developer: [
|
||||
"software",
|
||||
"engineer",
|
||||
"developer",
|
||||
"programming",
|
||||
"code",
|
||||
"github",
|
||||
"typescript",
|
||||
"javascript",
|
||||
"python",
|
||||
"backend",
|
||||
"frontend",
|
||||
"api",
|
||||
"repository",
|
||||
"startup",
|
||||
"swe",
|
||||
"tech",
|
||||
"devops",
|
||||
"cloud",
|
||||
],
|
||||
finance: [
|
||||
"finance",
|
||||
"investment",
|
||||
"portfolio",
|
||||
"trading",
|
||||
"stock",
|
||||
"fund",
|
||||
"equity",
|
||||
"crypto",
|
||||
"banking",
|
||||
"analyst",
|
||||
"fintech",
|
||||
"hedge",
|
||||
"venture",
|
||||
"capital",
|
||||
"asset",
|
||||
"valuation",
|
||||
"economics",
|
||||
],
|
||||
research: [
|
||||
"research",
|
||||
"academia",
|
||||
"phd",
|
||||
"paper",
|
||||
"journal",
|
||||
"study",
|
||||
"scholar",
|
||||
"university",
|
||||
"professor",
|
||||
"scientist",
|
||||
"thesis",
|
||||
"experiment",
|
||||
"hypothesis",
|
||||
"data analysis",
|
||||
"publication",
|
||||
],
|
||||
design: [
|
||||
"design",
|
||||
"ux",
|
||||
"ui",
|
||||
"figma",
|
||||
"creative",
|
||||
"visual",
|
||||
"brand",
|
||||
"illustrator",
|
||||
"adobe",
|
||||
"typography",
|
||||
"wireframe",
|
||||
"prototype",
|
||||
"product design",
|
||||
"graphic",
|
||||
"art director",
|
||||
],
|
||||
legal: [
|
||||
"lawyer",
|
||||
"attorney",
|
||||
"legal",
|
||||
"law",
|
||||
"contract",
|
||||
"compliance",
|
||||
"litigation",
|
||||
"counsel",
|
||||
"paralegal",
|
||||
"court",
|
||||
"regulatory",
|
||||
"intellectual property",
|
||||
"patent",
|
||||
"trademark",
|
||||
],
|
||||
marketing: [
|
||||
"marketing",
|
||||
"growth",
|
||||
"seo",
|
||||
"content",
|
||||
"campaign",
|
||||
"brand",
|
||||
"advertising",
|
||||
"social media",
|
||||
"pr",
|
||||
"communications",
|
||||
"copywriting",
|
||||
"conversion",
|
||||
"analytics",
|
||||
"inbound",
|
||||
],
|
||||
medical: [
|
||||
"doctor",
|
||||
"physician",
|
||||
"medical",
|
||||
"healthcare",
|
||||
"clinical",
|
||||
"hospital",
|
||||
"nursing",
|
||||
"surgery",
|
||||
"patient",
|
||||
"medicine",
|
||||
"diagnosis",
|
||||
"treatment",
|
||||
"pharmacology",
|
||||
"dentist",
|
||||
],
|
||||
}
|
||||
|
||||
function classifyProfession(results: SearchResult[]): Profession {
|
||||
const text = results
|
||||
.flatMap((r) => [
|
||||
r.title ?? "",
|
||||
r.summary ?? "",
|
||||
...(r.chunks?.slice(0, 2).map((c) => c.content) ?? []),
|
||||
])
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
|
||||
const scores: Partial<Record<Profession, number>> = {}
|
||||
for (const [prof, words] of Object.entries(KEYWORDS)) {
|
||||
scores[prof as Profession] = words.filter((w) => text.includes(w)).length
|
||||
}
|
||||
|
||||
const best = (Object.entries(scores) as [Profession, number][]).sort(
|
||||
(a, b) => b[1] - a[1],
|
||||
)[0]
|
||||
return best && best[1] > 0 ? best[0] : "default"
|
||||
}
|
||||
|
||||
let inflightPromise: Promise<void> | null = null
|
||||
|
||||
export function usePersonalization(): {
|
||||
copy: PersonalizedCopy
|
||||
profession: Profession
|
||||
setProfession: (p: Profession) => void
|
||||
} {
|
||||
const [copy, setCopy] = useState<PersonalizedCopy>(COPY.default)
|
||||
const [profession, setProfessionState] = useState<Profession>("default")
|
||||
|
||||
const setProfession = useCallback((p: Profession) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify({ profession: p, ts: Date.now() }),
|
||||
)
|
||||
} catch {}
|
||||
setCopy(COPY[p])
|
||||
setProfessionState(p)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY)
|
||||
if (raw) {
|
||||
const { profession: cached, ts } = JSON.parse(raw) as {
|
||||
profession: Profession
|
||||
ts: number
|
||||
}
|
||||
if (Date.now() - ts < CACHE_TTL_MS && COPY[cached]) {
|
||||
setCopy(COPY[cached])
|
||||
setProfessionState(cached)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (inflightPromise) {
|
||||
inflightPromise.then(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY)
|
||||
if (raw) {
|
||||
const { profession: cached } = JSON.parse(raw) as {
|
||||
profession: Profession
|
||||
}
|
||||
if (COPY[cached]) {
|
||||
setCopy(COPY[cached])
|
||||
setProfessionState(cached)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
inflightPromise = $fetch("@post/search", {
|
||||
body: {
|
||||
q: "career profession field industry background work role",
|
||||
limit: 8,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
const results = res.data?.results
|
||||
if (!results?.length) return
|
||||
const detected = classifyProfession(results)
|
||||
try {
|
||||
localStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify({ profession: detected, ts: Date.now() }),
|
||||
)
|
||||
} catch {}
|
||||
setCopy(COPY[detected])
|
||||
setProfessionState(detected)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
inflightPromise = null
|
||||
})
|
||||
}, [])
|
||||
|
||||
return { copy, profession, setProfession }
|
||||
}
|
||||
|
||||
export function clearPersonalizationCache() {
|
||||
try {
|
||||
localStorage.removeItem(CACHE_KEY)
|
||||
} catch {}
|
||||
}
|
||||
87
apps/web/hooks/use-processing-documents.ts
Normal file
87
apps/web/hooks/use-processing-documents.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { useProject } from "@/stores"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
const MAX_POLLS = 60
|
||||
const POLL_INTERVAL_MS = 5_000
|
||||
|
||||
export function useProcessingDocuments() {
|
||||
const { user } = useAuth()
|
||||
const { effectiveContainerTags } = useProject()
|
||||
const queryClient = useQueryClient()
|
||||
const prevIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["processing-documents", effectiveContainerTags],
|
||||
queryFn: async () => {
|
||||
const response = await $fetch("@get/documents/processing", {
|
||||
query: { containerTags: effectiveContainerTags },
|
||||
disableValidation: true,
|
||||
})
|
||||
if (response.error) return { documents: [], totalCount: 0 }
|
||||
return response.data ?? { documents: [], totalCount: 0 }
|
||||
},
|
||||
enabled: !!user,
|
||||
refetchInterval: (query) => {
|
||||
const count =
|
||||
(query.state.data as { totalCount?: number } | undefined)?.totalCount ??
|
||||
0
|
||||
const polls = query.state.dataUpdateCount
|
||||
if (count === 0 || polls >= MAX_POLLS) return false
|
||||
return POLL_INTERVAL_MS
|
||||
},
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
const docs =
|
||||
(
|
||||
data as
|
||||
| { documents?: Array<{ id?: string | null; status?: string | null }> }
|
||||
| undefined
|
||||
)?.documents ?? []
|
||||
|
||||
const processingMap = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const doc of docs) {
|
||||
if (doc.id && doc.status) {
|
||||
map.set(doc.id, doc.status)
|
||||
}
|
||||
}
|
||||
return map
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [docs])
|
||||
|
||||
// Detect docs that just finished (present in previous poll, absent now).
|
||||
// Done here — not in the card — because card remounts reset per-card refs
|
||||
// and lose the transition signal.
|
||||
useEffect(() => {
|
||||
const prev = prevIdsRef.current
|
||||
const current = new Set(processingMap.keys())
|
||||
prevIdsRef.current = current
|
||||
|
||||
const justFinished = [...prev].filter((id) => !current.has(id))
|
||||
if (justFinished.length === 0) return
|
||||
|
||||
const refresh = () => {
|
||||
queryClient.refetchQueries({ queryKey: ["documents-with-memories"] })
|
||||
queryClient.refetchQueries({ queryKey: ["dashboard-recents"] })
|
||||
}
|
||||
|
||||
// First pass: give the backend ~1s to finish writing memory entries
|
||||
const t1 = setTimeout(refresh, 1000)
|
||||
// Second pass: insurance in case the first fetch still beat the writes
|
||||
const t2 = setTimeout(refresh, 4000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(t1)
|
||||
clearTimeout(t2)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [processingMap.keys, queryClient.refetchQueries])
|
||||
|
||||
return processingMap
|
||||
}
|
||||
43
apps/web/hooks/use-reset-organization.ts
Normal file
43
apps/web/hooks/use-reset-organization.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"use client"
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { $fetch } from "@lib/api"
|
||||
|
||||
export function useResetOrganization() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (body: { confirmation: string }) => {
|
||||
const res = await $fetch("@post/settings/reset", {
|
||||
body,
|
||||
retry: { attempts: 0 },
|
||||
})
|
||||
if (res.error) {
|
||||
const e = res.error as Record<string, unknown>
|
||||
const msg =
|
||||
typeof e.error === "string"
|
||||
? e.error
|
||||
: typeof e.message === "string"
|
||||
? e.message
|
||||
: "Reset failed"
|
||||
throw new Error(msg)
|
||||
}
|
||||
if (!res.data?.success) throw new Error("Reset failed")
|
||||
return res.data
|
||||
},
|
||||
onSuccess: async () => {
|
||||
queryClient.invalidateQueries()
|
||||
// Clear the daily brief Cache API entry so stale highlights don't survive the reset
|
||||
try {
|
||||
await caches.delete("space-highlights-v1")
|
||||
} catch {
|
||||
// Cache API not available in all environments
|
||||
}
|
||||
toast.success("Organization data has been reset.")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to reset organization.")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -29,8 +29,9 @@ export const analytics = {
|
|||
chatHistoryViewed: () => safeCapture("chat_history_viewed"),
|
||||
chatDeleted: () => safeCapture("chat_deleted"),
|
||||
|
||||
viewModeChanged: (mode: "graph" | "list" | "integrations") =>
|
||||
safeCapture("view_mode_changed", { mode }),
|
||||
viewModeChanged: (
|
||||
mode: "dashboard" | "graph" | "list" | "integrations" | "chat",
|
||||
) => safeCapture("view_mode_changed", { mode }),
|
||||
|
||||
documentCardClicked: () => safeCapture("document_card_clicked"),
|
||||
|
||||
|
|
@ -117,8 +118,9 @@ export const analytics = {
|
|||
}) => safeCapture("highlight_clicked", props),
|
||||
|
||||
// chat analytics
|
||||
chatMessageSent: (props: { source: "typed" | "suggested" | "highlight" }) =>
|
||||
safeCapture("chat_message_sent", props),
|
||||
chatMessageSent: (props: {
|
||||
source: "typed" | "suggested" | "highlight" | "home"
|
||||
}) => safeCapture("chat_message_sent", props),
|
||||
|
||||
chatSuggestedQuestionClicked: () =>
|
||||
safeCapture("chat_suggested_question_clicked"),
|
||||
|
|
|
|||
214
apps/web/lib/chat-highlight-documents.ts
Normal file
214
apps/web/lib/chat-highlight-documents.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import type { UIMessage } from "@ai-sdk/react"
|
||||
import { memoryResultsFromSearchToolOutput } from "@/lib/chat-search-memory-results"
|
||||
|
||||
const UUID_IN_STRING =
|
||||
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
|
||||
|
||||
const UUID_STRICT =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
// Matches [doc:<id>] annotations emitted by sgrep when includeDocIds is enabled.
|
||||
// Supermemory uses NanoIDs (alphanumeric + _ -), not UUIDs.
|
||||
const DOC_ANNOTATION = /\[doc:([A-Za-z0-9_-]{10,40})\]/g
|
||||
|
||||
function collectIdsFromDynamicTool(part: Record<string, unknown>): string[] {
|
||||
const toolName = part.toolName
|
||||
if (!part.output) return []
|
||||
|
||||
if (toolName === "searchMemories") {
|
||||
return memoryResultsFromSearchToolOutput(part.output)
|
||||
.map((r) => r.documentId)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
}
|
||||
|
||||
if (toolName === "bash") {
|
||||
return documentIdsFromBashText(
|
||||
extractBashOutputString(part.output as Record<string, unknown>),
|
||||
)
|
||||
}
|
||||
|
||||
const fromWalk: string[] = []
|
||||
collectDocumentIdsFromUnknown(part.output, fromWalk)
|
||||
return fromWalk
|
||||
}
|
||||
|
||||
function extractBashOutputString(output: Record<string, unknown>): string {
|
||||
const stdout = output.stdout
|
||||
return typeof stdout === "string" ? stdout : ""
|
||||
}
|
||||
|
||||
/** Heuristic: pull document IDs from bash stdout. Handles [doc:<id>] annotations, UUID patterns, and JSON documentId fields. */
|
||||
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])
|
||||
}
|
||||
// Standard UUID format
|
||||
for (const m of text.matchAll(UUID_IN_STRING)) {
|
||||
found.add(m[0].toLowerCase())
|
||||
}
|
||||
// JSON "documentId": "..." fields
|
||||
const quoted = /"documentId"\s*:\s*"([^"]+)"/g
|
||||
let q = quoted.exec(text)
|
||||
while (q !== null) {
|
||||
found.add(q[1])
|
||||
q = quoted.exec(text)
|
||||
}
|
||||
return [...found]
|
||||
}
|
||||
|
||||
function collectDocumentIdsFromUnknown(value: unknown, out: string[]): void {
|
||||
const seen = new Set<string>()
|
||||
const walk = (v: unknown, depth: number) => {
|
||||
if (depth > 18) return
|
||||
if (v === null || v === undefined) return
|
||||
if (typeof v === "string") {
|
||||
if (v.length > 0 && v.length < 400000) {
|
||||
for (const id of documentIdsFromBashText(v)) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id)
|
||||
out.push(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (Array.isArray(v)) {
|
||||
for (const x of v) walk(x, depth + 1)
|
||||
return
|
||||
}
|
||||
if (typeof v !== "object") return
|
||||
const o = v as Record<string, unknown>
|
||||
|
||||
const docId = o.documentId
|
||||
if (
|
||||
typeof docId === "string" &&
|
||||
UUID_STRICT.test(docId) &&
|
||||
!seen.has(docId)
|
||||
) {
|
||||
seen.add(docId)
|
||||
out.push(docId)
|
||||
}
|
||||
|
||||
if (Array.isArray(o.documents)) {
|
||||
for (const d of o.documents) {
|
||||
if (!d || typeof d !== "object") continue
|
||||
const doc = d as Record<string, unknown>
|
||||
const id = doc.id
|
||||
if (typeof id === "string" && UUID_STRICT.test(id) && !seen.has(id)) {
|
||||
seen.add(id)
|
||||
out.push(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of [
|
||||
"results",
|
||||
"memories",
|
||||
"chunks",
|
||||
"hits",
|
||||
"items",
|
||||
"data",
|
||||
]) {
|
||||
if (key in o) walk(o[key], depth + 1)
|
||||
}
|
||||
}
|
||||
walk(value, 0)
|
||||
}
|
||||
|
||||
function toolOutputReady(p: Record<string, unknown>): boolean {
|
||||
const s = p.state
|
||||
return (
|
||||
s === "output-available" ||
|
||||
s === "done" ||
|
||||
(s === undefined && p.output !== undefined)
|
||||
)
|
||||
}
|
||||
|
||||
/** Document IDs referenced by retrieval tools / sources in this thread. */
|
||||
export function extractHighlightDocumentIdsFromMessages(
|
||||
messages: UIMessage[],
|
||||
): string[] {
|
||||
const ids = new Set<string>()
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role !== "assistant") continue
|
||||
const parts = message.parts
|
||||
if (!parts) continue
|
||||
|
||||
for (const part of parts) {
|
||||
const p = part as Record<string, unknown>
|
||||
|
||||
if (p.type === "source-document") {
|
||||
const sid = (p as { sourceId?: unknown }).sourceId
|
||||
if (typeof sid === "string" && UUID_STRICT.test(sid)) {
|
||||
ids.add(sid)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (p.type === "tool-searchMemories" && toolOutputReady(p)) {
|
||||
for (const id of memoryResultsFromSearchToolOutput(p.output)
|
||||
.map((r) => r.documentId)
|
||||
.filter(Boolean)) {
|
||||
ids.add(id as string)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (p.type === "dynamic-tool" && toolOutputReady(p)) {
|
||||
for (const id of collectIdsFromDynamicTool(p)) {
|
||||
ids.add(id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
typeof p.type === "string" &&
|
||||
p.type.startsWith("tool-") &&
|
||||
toolOutputReady(p)
|
||||
) {
|
||||
const name = p.type.slice("tool-".length)
|
||||
if (name === "searchMemories") {
|
||||
for (const id of memoryResultsFromSearchToolOutput(p.output)
|
||||
.map((r) => r.documentId)
|
||||
.filter(Boolean)) {
|
||||
ids.add(id as string)
|
||||
}
|
||||
} else if (name === "bash") {
|
||||
const out = p.output as Record<string, unknown> | undefined
|
||||
const stdout = out && typeof out.stdout === "string" ? out.stdout : ""
|
||||
for (const id of documentIdsFromBashText(stdout)) {
|
||||
ids.add(id)
|
||||
}
|
||||
} else if (p.output) {
|
||||
const buf: string[] = []
|
||||
collectDocumentIdsFromUnknown(p.output, buf)
|
||||
for (const id of buf) ids.add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ids.size === 0) {
|
||||
const lastAssistant = [...messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === "assistant")
|
||||
const parts = lastAssistant?.parts
|
||||
if (parts) {
|
||||
const texts = parts
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("\n")
|
||||
let n = 0
|
||||
for (const m of texts.matchAll(UUID_IN_STRING)) {
|
||||
if (n >= 16) break
|
||||
ids.add(m[0].toLowerCase())
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids]
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import type { ContainerTagListType } from "@lib/types"
|
||||
import { spaceSelectorDisplayName } from "@/lib/ingest-auto-space"
|
||||
|
||||
/** Label for the space sent as chat `metadata.projectId` (container tag). */
|
||||
export function getChatSpaceDisplayLabel(options: {
|
||||
|
|
@ -10,6 +11,6 @@ export function getChatSpaceDisplayLabel(options: {
|
|||
if (selectedProject === DEFAULT_PROJECT_ID) {
|
||||
return "My Space"
|
||||
}
|
||||
const name = allProjects.find((p) => p.containerTag === selectedProject)?.name
|
||||
return name?.trim() || selectedProject
|
||||
const found = allProjects.find((p) => p.containerTag === selectedProject)
|
||||
return spaceSelectorDisplayName(found, selectedProject)
|
||||
}
|
||||
|
|
|
|||
38
apps/web/lib/ingest-auto-space.ts
Normal file
38
apps/web/lib/ingest-auto-space.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import type { ContainerTagListType } from "@lib/types"
|
||||
|
||||
/**
|
||||
* Spaces auto-created on first ingest use `name === \`Space ${containerTag}\``
|
||||
* (mono `apps/api/src/routes/memories/handler-effect.ts`). Those are noisy in the
|
||||
* UI; we sort them after everything else — no per-tool heuristics.
|
||||
*/
|
||||
export function isIngestAutoProvisionedSpace(
|
||||
p: Pick<ContainerTagListType, "name" | "containerTag">,
|
||||
): boolean {
|
||||
if (p.containerTag === DEFAULT_PROJECT_ID) return false
|
||||
return (p.name ?? "") === `Space ${p.containerTag}`
|
||||
}
|
||||
|
||||
/** Normal / named spaces first; auto-ingest `Space {tag}` rows last. */
|
||||
export function compareSpacesUserFirst(
|
||||
a: Pick<ContainerTagListType, "name" | "containerTag">,
|
||||
b: Pick<ContainerTagListType, "name" | "containerTag">,
|
||||
): number {
|
||||
return (
|
||||
Number(isIngestAutoProvisionedSpace(a)) -
|
||||
Number(isIngestAutoProvisionedSpace(b))
|
||||
)
|
||||
}
|
||||
|
||||
export function spaceSelectorDisplayName(
|
||||
p: Pick<ContainerTagListType, "name" | "containerTag"> | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (!p) return fallback
|
||||
const name = p.name ?? p.containerTag
|
||||
const long = name.length > 44
|
||||
if (long) {
|
||||
return `${name.slice(0, 42)}…`
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
|
@ -23,14 +23,34 @@ export const shareParam = parseAsBoolean.withDefault(false)
|
|||
export const feedbackParam = parseAsBoolean.withDefault(false)
|
||||
|
||||
// View & filter states
|
||||
const viewLiterals = ["graph", "list", "integrations"] as const
|
||||
const integrationLiterals = ["import", "chrome", "connections"] as const
|
||||
export type IntegrationParamValue = (typeof integrationLiterals)[number]
|
||||
export const integrationParam = parseAsStringLiteral(integrationLiterals)
|
||||
const viewLiterals = [
|
||||
"dashboard",
|
||||
"graph",
|
||||
"list",
|
||||
"integrations",
|
||||
"chat",
|
||||
// Integration sub-views — each card is its own view
|
||||
"mcp",
|
||||
"plugins",
|
||||
"chrome",
|
||||
"connections",
|
||||
"shortcuts",
|
||||
"raycast",
|
||||
"import",
|
||||
] as const
|
||||
export type ViewParamValue = (typeof viewLiterals)[number]
|
||||
export const viewParam = parseAsStringLiteral(viewLiterals).withDefault("list")
|
||||
export const viewParam =
|
||||
parseAsStringLiteral(viewLiterals).withDefault("dashboard")
|
||||
|
||||
export const pluginsPanelParam = parseAsBoolean
|
||||
// Kept for backwards compat with components that pass integration hints
|
||||
export type IntegrationParamValue =
|
||||
| "mcp"
|
||||
| "plugins"
|
||||
| "chrome"
|
||||
| "connections"
|
||||
| "shortcuts"
|
||||
| "raycast"
|
||||
| "import"
|
||||
export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault(
|
||||
[],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ export default async function proxy(request: Request) {
|
|||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// MCP setup page is public — no auth required
|
||||
if (url.searchParams.get("view") === "mcp") {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
if (!sessionCookie) {
|
||||
console.debug("[MIDDLEWARE] API route without session, returning 401")
|
||||
|
|
@ -66,6 +71,6 @@ export default async function proxy(request: Request) {
|
|||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|images|icon.png|monitoring|opengraph-image.png|bg-rectangle.png|onboarding|ingest|login|api/emails).*)",
|
||||
"/((?!_next/static|_next/image|images|icon.png|monitoring|opengraph-image.png|bg-rectangle.png|onboarding|ingest|login|api/emails|mcp-supported-tools|mcp-icon.svg).*)",
|
||||
],
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5 MiB |
|
|
@ -2,30 +2,19 @@
|
|||
|
||||
import { useQueryState } from "nuqs"
|
||||
import { projectParam } from "@/lib/search-params"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { useCallback } from "react"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
|
||||
export function useProject() {
|
||||
const [selectedProjects, _setSelectedProjects] = useQueryState(
|
||||
"project",
|
||||
projectParam,
|
||||
)
|
||||
const { novaContainerTags } = useContainerTags()
|
||||
|
||||
const isNovaSpaces = selectedProjects.length === 0
|
||||
const selectedProject = selectedProjects[0] ?? DEFAULT_PROJECT_ID
|
||||
|
||||
const selectedProject = isNovaSpaces
|
||||
? DEFAULT_PROJECT_ID
|
||||
: (selectedProjects[0] ?? DEFAULT_PROJECT_ID)
|
||||
|
||||
// Get effective container tags for API calls
|
||||
// When "Nova Spaces" is selected, use all nova container tags
|
||||
// Otherwise, use the selected projects
|
||||
const effectiveContainerTags = useMemo(
|
||||
() => (isNovaSpaces ? novaContainerTags : selectedProjects),
|
||||
[isNovaSpaces, novaContainerTags, selectedProjects],
|
||||
)
|
||||
const effectiveContainerTags =
|
||||
selectedProjects.length === 0 ? [DEFAULT_PROJECT_ID] : selectedProjects
|
||||
|
||||
const setSelectedProjects = useCallback(
|
||||
(projects: string[]) => {
|
||||
|
|
@ -46,9 +35,7 @@ export function useProject() {
|
|||
selectedProject,
|
||||
setSelectedProjects,
|
||||
setSelectedProject,
|
||||
isNovaSpaces,
|
||||
effectiveContainerTags,
|
||||
novaContainerTags,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
MemoryResponseSchema,
|
||||
MigrateMCPRequestSchema,
|
||||
MigrateMCPResponseSchema,
|
||||
ProcessingDocumentsResponseSchema,
|
||||
ProjectSchema,
|
||||
SearchRequestSchema,
|
||||
SearchResponseSchema,
|
||||
|
|
@ -132,6 +133,19 @@ export const apiSchema = createSchema({
|
|||
input: SettingsRequestSchema,
|
||||
output: SettingsResponseSchema,
|
||||
},
|
||||
"@post/settings/reset": {
|
||||
input: z.object({ confirmation: z.string() }),
|
||||
output: z.object({
|
||||
success: z.boolean(),
|
||||
deletedConnections: z.number(),
|
||||
deletedDocumentBatches: z.number(),
|
||||
deletedDocumentsApprox: z.number(),
|
||||
deletedMemoryRows: z.number(),
|
||||
deletedExtraSpaces: z.number(),
|
||||
clearedDefaultSpaceContext: z.boolean(),
|
||||
settingsReset: z.boolean(),
|
||||
}),
|
||||
},
|
||||
// Memory operations
|
||||
"@post/documents": {
|
||||
input: MemoryAddSchema,
|
||||
|
|
@ -165,6 +179,15 @@ export const apiSchema = createSchema({
|
|||
output: MigrateMCPResponseSchema,
|
||||
},
|
||||
|
||||
"@get/documents/processing": {
|
||||
output: ProcessingDocumentsResponseSchema,
|
||||
query: z
|
||||
.object({
|
||||
containerTags: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
},
|
||||
|
||||
"@get/documents/:id": {
|
||||
output: z.any(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ export const authClient = createAuthClient({
|
|||
baseURL: process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai",
|
||||
fetchOptions: {
|
||||
credentials: "include",
|
||||
throw: true,
|
||||
},
|
||||
plugins: [
|
||||
usernameClient(),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const BIG_DIMENSIONS_NEW = 1536
|
||||
const DEFAULT_PROJECT_ID = "sm_project_default"
|
||||
const SEARCH_MEMORY_SHORTCUT_URL =
|
||||
"https://www.icloud.com/shortcuts/f2b5c544372844a38ab4c6900e2a88de"
|
||||
"https://www.icloud.com/shortcuts/b0a132cc3c0d475196bc7014aa702a5c"
|
||||
const ADD_MEMORY_SHORTCUT_URL =
|
||||
"https://www.icloud.com/shortcuts/0fd3e855be444845b457f94c78c2c8d9"
|
||||
const RAYCAST_EXTENSION_URL = "https://www.raycast.com/supermemory/supermemory"
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ describe("generateMockGraphData", () => {
|
|||
const data2 = generateMockGraphData({ documentCount: 10, seed: 42 })
|
||||
|
||||
expect(data1.documents.length).toBe(data2.documents.length)
|
||||
expect(data1.documents[0]!.id).toBe(data2.documents[0]!.id)
|
||||
expect(data1.documents[0]!.title).toBe(data2.documents[0]!.title)
|
||||
expect(data1.documents[0]?.id).toBe(data2.documents[0]?.id)
|
||||
expect(data1.documents[0]?.title).toBe(data2.documents[0]?.title)
|
||||
})
|
||||
|
||||
it("produces different output with different seeds", () => {
|
||||
|
|
@ -43,8 +43,9 @@ describe("generateMockGraphData", () => {
|
|||
const data = generateMockGraphData({ documentCount: 5, seed: 1 })
|
||||
const doc = data.documents.find((d) => d.memories.length > 0)
|
||||
expect(doc).toBeDefined()
|
||||
if (!doc) return
|
||||
|
||||
for (const mem of doc!.memories) {
|
||||
for (const mem of doc.memories) {
|
||||
expect(mem.id).toBeDefined()
|
||||
expect(mem.memory).toBeDefined()
|
||||
expect(typeof mem.isStatic).toBe("boolean")
|
||||
|
|
|
|||
|
|
@ -18,6 +18,15 @@ export interface RenderState {
|
|||
// Module-level reusable batch map – cleared each frame instead of reallocating
|
||||
const edgeBatches = new Map<string, PreparedEdge[]>()
|
||||
|
||||
function nodeMatchesDocumentHighlights(
|
||||
node: GraphNode,
|
||||
highlightIds: Set<string>,
|
||||
): boolean {
|
||||
if (highlightIds.size === 0) return false
|
||||
if (node.type === "document") return highlightIds.has(node.id)
|
||||
return highlightIds.has((node.data as MemoryNodeData).documentId)
|
||||
}
|
||||
|
||||
/** Group items by their `color` property into batches for efficient canvas drawing */
|
||||
function groupByColor<T extends { color: string }>(
|
||||
items: T[],
|
||||
|
|
@ -296,7 +305,13 @@ function drawNodes(
|
|||
|
||||
const isSelected = node.id === state.selectedNodeId
|
||||
const isHovered = node.id === state.hoveredNodeId
|
||||
const isHighlighted = state.highlightIds.has(node.id)
|
||||
const isHighlighted = nodeMatchesDocumentHighlights(
|
||||
node,
|
||||
state.highlightIds,
|
||||
)
|
||||
const highlightFocus = state.highlightIds.size > 0
|
||||
const fadeNonHighlights =
|
||||
highlightFocus && !isSelected && !isHovered && !isHighlighted
|
||||
|
||||
if (screenSize < 8 && !isSelected && !isHovered && !isHighlighted) {
|
||||
if (node.type === "document") {
|
||||
|
|
@ -318,6 +333,9 @@ function drawNodes(
|
|||
if (state.selectedNodeId && state.dimProgress > 0 && !isSelected) {
|
||||
alpha = 1 - state.dimProgress * 0.7
|
||||
}
|
||||
if (fadeNonHighlights) {
|
||||
alpha *= 0.35
|
||||
}
|
||||
ctx.globalAlpha = alpha
|
||||
|
||||
if (node.type === "document") {
|
||||
|
|
@ -363,12 +381,13 @@ function drawNodes(
|
|||
state.selectedNodeId && state.dimProgress > 0
|
||||
? 1 - state.dimProgress * 0.7
|
||||
: 1
|
||||
const hlBatchMult = state.highlightIds.size > 0 ? 0.4 : 1
|
||||
|
||||
if (docDots.length > 0) {
|
||||
ctx.fillStyle = colors.docFill
|
||||
ctx.strokeStyle = colors.docStroke
|
||||
ctx.lineWidth = 1
|
||||
ctx.globalAlpha = dimAlpha
|
||||
ctx.globalAlpha = dimAlpha * hlBatchMult
|
||||
for (const d of docDots) {
|
||||
const h = d.s * 0.5
|
||||
ctx.fillRect(d.x - h, d.y - h, d.s, d.s)
|
||||
|
|
@ -383,7 +402,7 @@ function drawNodes(
|
|||
|
||||
if (normalDots.length > 0) {
|
||||
// Subtle glow behind memory dots for luminous effect
|
||||
ctx.globalAlpha = dimAlpha * 0.25
|
||||
ctx.globalAlpha = dimAlpha * hlBatchMult * 0.25
|
||||
for (const [color, batch] of groupByColor(normalDots)) {
|
||||
ctx.fillStyle = color
|
||||
ctx.beginPath()
|
||||
|
|
@ -395,7 +414,7 @@ function drawNodes(
|
|||
}
|
||||
|
||||
// Filled dot
|
||||
ctx.globalAlpha = dimAlpha
|
||||
ctx.globalAlpha = dimAlpha * hlBatchMult
|
||||
ctx.fillStyle = colors.memFill
|
||||
ctx.beginPath()
|
||||
for (const d of normalDots) {
|
||||
|
|
@ -419,7 +438,7 @@ function drawNodes(
|
|||
|
||||
// Draw dimmed (superseded) memory dots at reduced opacity
|
||||
if (dimmedDots.length > 0) {
|
||||
ctx.globalAlpha = dimAlpha * 0.5
|
||||
ctx.globalAlpha = dimAlpha * hlBatchMult * 0.5
|
||||
ctx.fillStyle = colors.memFill
|
||||
ctx.beginPath()
|
||||
for (const d of dimmedDots) {
|
||||
|
|
|
|||
|
|
@ -104,8 +104,24 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
|
|||
}, [nodes])
|
||||
|
||||
useEffect(() => {
|
||||
s.current.highlightIds = new Set(highlightDocumentIds ?? [])
|
||||
const ids = new Set(highlightDocumentIds ?? [])
|
||||
s.current.highlightIds = ids
|
||||
renderNeeded.current = true
|
||||
|
||||
if (ids.size === 0) return
|
||||
const vp = viewportRef.current
|
||||
if (!vp) return
|
||||
const highlighted = s.current.nodes.filter((n) => {
|
||||
if (n.type === "document") return ids.has(n.id)
|
||||
const d = n.data as { documentId?: string }
|
||||
return typeof d.documentId === "string" && ids.has(d.documentId)
|
||||
})
|
||||
if (highlighted.length === 0) return
|
||||
vp.fitToNodes(
|
||||
highlighted.map((n) => ({ x: n.x, y: n.y, size: n.size ?? 24 })),
|
||||
s.current.width,
|
||||
s.current.height,
|
||||
)
|
||||
}, [highlightDocumentIds])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@supermemory/tools",
|
||||
"type": "module",
|
||||
"version": "1.4.7",
|
||||
"version": "2.0.0",
|
||||
"description": "Memory tools for AI SDK, OpenAI, Voltagent and Mastra with supermemory",
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
// Enhanced glass-morphism color palette
|
||||
export const colors = {
|
||||
background: {
|
||||
primary: "#0f1419", // Deep dark blue-gray
|
||||
secondary: "#1a1f29", // Slightly lighter
|
||||
accent: "#252a35", // Card backgrounds
|
||||
},
|
||||
document: {
|
||||
primary: "rgba(255, 255, 255, 0.06)", // Subtle glass white
|
||||
secondary: "rgba(255, 255, 255, 0.12)", // More visible
|
||||
accent: "rgba(255, 255, 255, 0.18)", // Hover state
|
||||
border: "rgba(255, 255, 255, 0.25)", // Sharp borders
|
||||
glow: "rgba(147, 197, 253, 0.4)", // Blue glow for interaction
|
||||
},
|
||||
memory: {
|
||||
primary: "rgba(147, 197, 253, 0.08)", // Subtle glass blue
|
||||
secondary: "rgba(147, 197, 253, 0.16)", // More visible
|
||||
accent: "rgba(147, 197, 253, 0.24)", // Hover state
|
||||
border: "rgba(147, 197, 253, 0.35)", // Sharp borders
|
||||
glow: "rgba(147, 197, 253, 0.5)", // Blue glow for interaction
|
||||
},
|
||||
connection: {
|
||||
weak: "rgba(148, 163, 184, 0)", // Very subtle
|
||||
memory: "rgba(148, 163, 184, 0.3)", // Very subtle
|
||||
medium: "rgba(148, 163, 184, 0.125)", // Medium visibility
|
||||
strong: "rgba(148, 163, 184, 0.4)", // Strong connection
|
||||
},
|
||||
text: {
|
||||
primary: "#ffffff", // Pure white
|
||||
secondary: "#e2e8f0", // Light gray
|
||||
muted: "#94a3b8", // Medium gray
|
||||
},
|
||||
accent: {
|
||||
primary: "rgba(59, 130, 246, 0.7)", // Clean blue
|
||||
secondary: "rgba(99, 102, 241, 0.6)", // Clean purple
|
||||
glow: "rgba(147, 197, 253, 0.6)", // Subtle glow
|
||||
amber: "rgba(251, 165, 36, 0.8)", // Amber for expiring
|
||||
emerald: "rgba(16, 185, 129, 0.4)", // Emerald for new
|
||||
},
|
||||
status: {
|
||||
forgotten: "rgba(220, 38, 38, 0.15)", // Red for forgotten
|
||||
expiring: "rgba(251, 165, 36, 0.8)", // Amber for expiring soon
|
||||
new: "rgba(16, 185, 129, 0.4)", // Emerald for new memories
|
||||
},
|
||||
relations: {
|
||||
updates: "rgba(147, 77, 253, 0.5)", // purple
|
||||
extends: "rgba(16, 185, 129, 0.5)", // green
|
||||
derives: "rgba(147, 197, 253, 0.5)", // blue
|
||||
},
|
||||
};
|
||||
|
||||
export const LAYOUT_CONSTANTS = {
|
||||
centerX: 400,
|
||||
centerY: 300,
|
||||
clusterRadius: 300, // Memory "bubble" size around a doc - smaller bubble
|
||||
spaceSpacing: 1600, // How far apart the *spaces* (groups of docs) sit - push spaces way out
|
||||
documentSpacing: 1000, // How far the first doc in a space sits from its space-centre - push docs way out
|
||||
minDocDist: 900, // Minimum distance two documents in the **same space** are allowed to be - sets repulsion radius
|
||||
memoryClusterRadius: 300,
|
||||
};
|
||||
|
||||
// Graph view settings
|
||||
export const GRAPH_SETTINGS = {
|
||||
console: {
|
||||
initialZoom: 0.8, // Higher zoom for console - better overview
|
||||
initialPanX: 0,
|
||||
initialPanY: 0,
|
||||
},
|
||||
consumer: {
|
||||
initialZoom: 0.5, // Changed from 0.1 to 0.5 for better initial visibility
|
||||
initialPanX: 400, // Pan towards center to compensate for larger layout
|
||||
initialPanY: 300, // Pan towards center to compensate for larger layout
|
||||
},
|
||||
};
|
||||
|
||||
// Responsive positioning for different app variants
|
||||
export const POSITIONING = {
|
||||
console: {
|
||||
legend: {
|
||||
desktop: "bottom-4 right-4",
|
||||
mobile: "bottom-4 right-4",
|
||||
},
|
||||
loadingIndicator: "top-20 right-4",
|
||||
|
||||
spacesSelector: "top-4 left-4",
|
||||
viewToggle: "", // Not used in console
|
||||
nodeDetail: "top-4 right-4",
|
||||
},
|
||||
consumer: {
|
||||
legend: {
|
||||
desktop: "top-18 right-4",
|
||||
mobile: "bottom-[180px] left-4",
|
||||
},
|
||||
loadingIndicator: "top-20 right-4",
|
||||
|
||||
spacesSelector: "", // Hidden in consumer
|
||||
viewToggle: "top-4 right-4", // Consumer has view toggle
|
||||
nodeDetail: "top-4 right-4",
|
||||
},
|
||||
};
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@lib/utils";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { GlassMenuEffect } from "@ui/other/glass-effect";
|
||||
import { Move, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import type { ControlsProps } from "./types";
|
||||
|
||||
export const Controls = memo<ControlsProps>(
|
||||
({ onZoomIn, onZoomOut, onResetView, variant = "console" }) => {
|
||||
// Use explicit classes - controls positioning not defined in constants
|
||||
// Using a reasonable default position
|
||||
const getPositioningClasses = () => {
|
||||
if (variant === "console") {
|
||||
return "bottom-4 left-4";
|
||||
}
|
||||
if (variant === "consumer") {
|
||||
return "bottom-20 right-4";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-10 rounded-xl overflow-hidden",
|
||||
getPositioningClasses(),
|
||||
)}
|
||||
>
|
||||
{/* Glass effect background */}
|
||||
<GlassMenuEffect rounded="rounded-xl" />
|
||||
|
||||
<div className="relative z-10 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors"
|
||||
onClick={onZoomIn}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors"
|
||||
onClick={onZoomOut}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-8 w-8 p-0 text-slate-200 hover:bg-slate-700/40 hover:text-slate-100 transition-colors"
|
||||
onClick={onResetView}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Move className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Controls.displayName = "Controls";
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue