mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-07 08:26:15 +00:00
Merge branch 'main' into feat/personalisation
This commit is contained in:
commit
82c308be49
37 changed files with 3110 additions and 950 deletions
|
|
@ -199,6 +199,7 @@ Upload PDFs, images, and documents directly.
|
|||
| `customId` | string | **Recommended.** Your ID for the content (conversation ID, doc ID). Enables updates and deduplication |
|
||||
| `containerTag` | string | Group by user/project. Required for user profiles |
|
||||
| `metadata` | object | Key-value pairs for filtering (strings, numbers, booleans) |
|
||||
| `filterByMetadata` | object | Filter which existing memories are used as context during ingestion. See [Filtered Writes](#filtered-writes) |
|
||||
| `entityContext` | string | Context for memory extraction on this container tag. Max 1500 chars. See [Customization](/concepts/customization#entity-context) |
|
||||
|
||||
<AccordionGroup>
|
||||
|
|
@ -276,6 +277,83 @@ Upload PDFs, images, and documents directly.
|
|||
|
||||
---
|
||||
|
||||
## Filtered Writes
|
||||
|
||||
By default, when you add content, Supermemory uses **all** existing memories in the space as context for generating new memories. With **filtered writes**, you can scope this context to only memories from documents matching specific metadata.
|
||||
|
||||
This is useful when you have many documents in a space but want new memories to build on top of a specific subset — for example, only memories from a particular source, category, or user.
|
||||
|
||||
<Note>
|
||||
The metadata itself is still written to the document, but the memories will only be built on top of what's already there matching the filter.
|
||||
</Note>
|
||||
|
||||
<Tabs>
|
||||
<Tab title="TypeScript">
|
||||
```typescript
|
||||
await client.add({
|
||||
content: "New research findings on transformer architectures...",
|
||||
containerTag: "user_123",
|
||||
metadata: { category: "ml", source: "arxiv" },
|
||||
filterByMetadata: { category: "ml" }
|
||||
});
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Python">
|
||||
```python
|
||||
client.add(
|
||||
content="New research findings on transformer architectures...",
|
||||
container_tag="user_123",
|
||||
metadata={"category": "ml", "source": "arxiv"},
|
||||
filter_by_metadata={"category": "ml"}
|
||||
)
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="cURL">
|
||||
```bash
|
||||
curl -X POST "https://api.supermemory.ai/v3/documents" \
|
||||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"content": "New research findings on transformer architectures...",
|
||||
"containerTag": "user_123",
|
||||
"metadata": {"category": "ml", "source": "arxiv"},
|
||||
"filterByMetadata": {"category": "ml"}
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### How it works
|
||||
|
||||
When `filterByMetadata` is provided:
|
||||
- **Profile memories** (static context) are filtered to only those from documents matching the metadata
|
||||
- **Similar memories** used as context during ingestion are filtered the same way
|
||||
- The new document's own metadata is written normally — the filter only affects which **existing** memories are used as context
|
||||
|
||||
### `filterByMetadata` parameter
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `filterByMetadata` | `Record<string, string \| number \| boolean \| string[]>` | Key-value pairs to filter existing memories by their source document metadata |
|
||||
|
||||
- **Scalar values** (string, number, boolean) match exactly
|
||||
- **Array values** match if **any** value in the array matches (OR logic)
|
||||
- **Multiple keys** are combined with AND logic
|
||||
|
||||
```typescript
|
||||
// Match documents where category is "ml" AND source is either "arxiv" or "pubmed"
|
||||
await client.add({
|
||||
content: "...",
|
||||
containerTag: "user_123",
|
||||
filterByMetadata: {
|
||||
category: "ml",
|
||||
source: ["arxiv", "pubmed"]
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Processing Pipeline
|
||||
|
||||
When you add content, Supermemory:
|
||||
|
|
|
|||
|
|
@ -22,10 +22,12 @@ The S3 connector requires a **Scale Plan** or higher. You can also create S3 con
|
|||
});
|
||||
|
||||
const connection = await client.connections.create('s3', {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
bucket: 'my-documents-bucket',
|
||||
region: 'us-east-1',
|
||||
metadata: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
bucket: 'my-documents-bucket',
|
||||
region: 'us-east-1'
|
||||
},
|
||||
containerTags: ['org-123']
|
||||
});
|
||||
```
|
||||
|
|
@ -35,14 +37,16 @@ The S3 connector requires a **Scale Plan** or higher. You can also create S3 con
|
|||
from supermemory import Supermemory
|
||||
import os
|
||||
|
||||
client = Supermemory(api_key=os.environ.get("SUPERMEMORY_API_KEY"))
|
||||
client = Supermemory(api_key=os.environ["SUPERMEMORY_API_KEY"])
|
||||
|
||||
connection = client.connections.create(
|
||||
's3',
|
||||
access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"),
|
||||
secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"),
|
||||
bucket='my-documents-bucket',
|
||||
region='us-east-1',
|
||||
metadata={
|
||||
'accessKeyId': os.environ["AWS_ACCESS_KEY_ID"],
|
||||
'secretAccessKey': os.environ["AWS_SECRET_ACCESS_KEY"],
|
||||
'bucket': 'my-documents-bucket',
|
||||
'region': 'us-east-1'
|
||||
},
|
||||
container_tags=['org-123', 's3-sync']
|
||||
)
|
||||
```
|
||||
|
|
@ -53,10 +57,12 @@ The S3 connector requires a **Scale Plan** or higher. You can also create S3 con
|
|||
-H "Authorization: Bearer $SUPERMEMORY_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"accessKeyId": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"bucket": "my-documents-bucket",
|
||||
"region": "us-east-1",
|
||||
"metadata": {
|
||||
"accessKeyId": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"bucket": "my-documents-bucket",
|
||||
"region": "us-east-1"
|
||||
},
|
||||
"containerTags": ["org-123"]
|
||||
}'
|
||||
```
|
||||
|
|
@ -65,50 +71,81 @@ The S3 connector requires a **Scale Plan** or higher. You can also create S3 con
|
|||
|
||||
## Configuration Options
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `accessKeyId` | Yes | AWS access key ID or S3-compatible service key |
|
||||
| `secretAccessKey` | Yes | AWS secret access key |
|
||||
| `bucket` | Yes | S3 bucket name |
|
||||
| `region` | Yes | AWS region (e.g., `us-east-1`) |
|
||||
| `endpoint` | No | Custom endpoint for S3-compatible services |
|
||||
| `prefix` | No | Key prefix filter (e.g., `documents/`) |
|
||||
| `containerTagRegex` | No | Regex to extract container tags from file paths |
|
||||
| `containerTags` | No | Tags for organizing connections |
|
||||
| `documentLimit` | No | Maximum documents to sync (default: 10,000) |
|
||||
For S3, provider-specific connection fields are passed inside the top-level `metadata` object. General connection options stay top-level.
|
||||
|
||||
| Parameter | Location | Required | Description |
|
||||
|-----------|----------|----------|-------------|
|
||||
| `accessKeyId` | `metadata.accessKeyId` | Yes | AWS access key ID or S3-compatible service key |
|
||||
| `secretAccessKey` | `metadata.secretAccessKey` | Yes | AWS secret access key |
|
||||
| `bucket` | `metadata.bucket` | Yes | S3 bucket name |
|
||||
| `region` | `metadata.region` | Yes | AWS region (e.g., `us-east-1`). Use `auto` for Cloudflare R2. |
|
||||
| `endpoint` | `metadata.endpoint` | No | Custom endpoint for S3-compatible services |
|
||||
| `prefix` | `metadata.prefix` | No | Key prefix filter (e.g., `documents/`) |
|
||||
| `containerTagRegex` | `metadata.containerTagRegex` | No | Regex to extract container tags from file paths |
|
||||
| `containerTags` | top-level | No | Tags for organizing connections |
|
||||
| `documentLimit` | top-level | No | Maximum documents to sync (default: 10,000) |
|
||||
|
||||
<Note>
|
||||
In the Python SDK, use `container_tags` for the top-level option, but keep S3 metadata keys in camelCase: `accessKeyId`, `secretAccessKey`, and `containerTagRegex`.
|
||||
</Note>
|
||||
|
||||
## S3-Compatible Services
|
||||
|
||||
Use a custom `endpoint` to connect to S3-compatible storage:
|
||||
Use `metadata.endpoint` to connect to S3-compatible storage:
|
||||
|
||||
```typescript
|
||||
// MinIO
|
||||
const connection = await client.connections.create('s3', {
|
||||
accessKeyId: 'minio-key',
|
||||
secretAccessKey: 'minio-secret',
|
||||
bucket: 'my-bucket',
|
||||
region: 'us-east-1',
|
||||
endpoint: 'https://minio.example.com',
|
||||
metadata: {
|
||||
accessKeyId: 'minio-key',
|
||||
secretAccessKey: 'minio-secret',
|
||||
bucket: 'my-bucket',
|
||||
region: 'us-east-1',
|
||||
endpoint: 'https://minio.example.com'
|
||||
},
|
||||
containerTags: ['minio-sync']
|
||||
});
|
||||
|
||||
// DigitalOcean Spaces
|
||||
endpoint: 'https://nyc3.digitaloceanspaces.com'
|
||||
|
||||
// Cloudflare R2
|
||||
endpoint: 'https://ACCOUNT_ID.r2.cloudflarestorage.com'
|
||||
```
|
||||
|
||||
Common S3-compatible endpoint values:
|
||||
|
||||
| Service | `metadata.endpoint` | `metadata.region` |
|
||||
|---------|----------------------|-------------------|
|
||||
| DigitalOcean Spaces | `https://nyc3.digitaloceanspaces.com` | `nyc3` |
|
||||
| Cloudflare R2 | `https://<account-id>.r2.cloudflarestorage.com` | `auto` |
|
||||
|
||||
Cloudflare R2 example:
|
||||
|
||||
```typescript
|
||||
const connection = await client.connections.create('s3', {
|
||||
metadata: {
|
||||
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
|
||||
bucket: 'my-bucket',
|
||||
region: 'auto',
|
||||
endpoint: 'https://<account-id>.r2.cloudflarestorage.com'
|
||||
},
|
||||
containerTags: ['r2-sync']
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
For S3-compatible services, `metadata.endpoint` is the base S3 endpoint. Do not include the bucket name in the endpoint URL; pass the bucket separately as `metadata.bucket`.
|
||||
</Note>
|
||||
|
||||
## Prefix Filtering
|
||||
|
||||
Sync only files within a specific path:
|
||||
|
||||
```typescript
|
||||
const connection = await client.connections.create('s3', {
|
||||
// ... credentials
|
||||
bucket: 'company-data',
|
||||
region: 'us-east-1',
|
||||
prefix: 'documents/engineering/', // Only syncs files under this path
|
||||
metadata: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
bucket: 'company-data',
|
||||
region: 'us-east-1',
|
||||
prefix: 'documents/engineering/' // Only syncs files under this path
|
||||
},
|
||||
containerTags: ['engineering-docs']
|
||||
});
|
||||
```
|
||||
|
|
@ -119,10 +156,13 @@ Extract container tags from S3 key paths for multi-tenant setups:
|
|||
|
||||
```typescript
|
||||
const connection = await client.connections.create('s3', {
|
||||
// ... credentials
|
||||
bucket: 'user-files',
|
||||
region: 'us-east-1',
|
||||
containerTagRegex: 'users/(?<userId>[^/]+)/',
|
||||
metadata: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
bucket: 'user-files',
|
||||
region: 'us-east-1',
|
||||
containerTagRegex: 'users/(?<userId>[^/]+)/'
|
||||
},
|
||||
containerTags: ['user-files']
|
||||
});
|
||||
|
||||
|
|
|
|||
8
apps/mcp/.dev.vars.example
Normal file
8
apps/mcp/.dev.vars.example
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Copy to .dev.vars for `wrangler dev`. Optional when running via `bun run dev`
|
||||
# (portless) — that injects API_URL and MCP_URL automatically.
|
||||
|
||||
API_URL=
|
||||
MCP_URL=
|
||||
|
||||
#(optional)
|
||||
POSTHOG_API_KEY=
|
||||
|
|
@ -8,6 +8,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"
|
|||
type Bindings = {
|
||||
MCP_SERVER: DurableObjectNamespace
|
||||
API_URL?: string
|
||||
MCP_URL?: string
|
||||
POSTHOG_API_KEY?: string
|
||||
}
|
||||
|
||||
|
|
@ -22,6 +23,14 @@ type Props = {
|
|||
const app = new Hono<{ Bindings: Bindings }>()
|
||||
|
||||
const DEFAULT_API_URL = "https://api.supermemory.ai"
|
||||
const DEFAULT_MCP_URL = "https://mcp.supermemory.ai"
|
||||
|
||||
const mcpBaseUrl = (c: Context<{ Bindings: Bindings }>) => {
|
||||
if (c.env.MCP_URL) return c.env.MCP_URL.replace(/\/$/, "")
|
||||
const host = c.req.header("x-forwarded-host") || c.req.header("host")
|
||||
const proto = c.req.header("x-forwarded-proto") || "https"
|
||||
return host ? `${proto}://${host}` : DEFAULT_MCP_URL
|
||||
}
|
||||
|
||||
// CORS
|
||||
app.use(
|
||||
|
|
@ -57,21 +66,18 @@ app.get("/", (c) => {
|
|||
})
|
||||
|
||||
// MCP clients use this to discover the authorization server
|
||||
app.get("/.well-known/oauth-protected-resource", (c) => {
|
||||
const protectedResourceHandler = (c: Context<{ Bindings: Bindings }>) => {
|
||||
const apiUrl = c.env.API_URL || DEFAULT_API_URL
|
||||
|
||||
const host = c.req.header("x-forwarded-host") || c.req.header("host")
|
||||
const proto = c.req.header("x-forwarded-proto") || "https"
|
||||
const resourceUrl = host ? `${proto}://${host}` : "https://mcp.supermemory.ai"
|
||||
|
||||
return c.json({
|
||||
resource: resourceUrl,
|
||||
resource: `${mcpBaseUrl(c)}/mcp`,
|
||||
authorization_servers: [apiUrl],
|
||||
scopes_supported: ["openid", "profile", "email", "offline_access"],
|
||||
bearer_methods_supported: ["header"],
|
||||
resource_documentation: "https://docs.supermemory.ai/mcp",
|
||||
})
|
||||
})
|
||||
}
|
||||
app.get("/.well-known/oauth-protected-resource", protectedResourceHandler)
|
||||
app.get("/.well-known/oauth-protected-resource/mcp", protectedResourceHandler)
|
||||
|
||||
// Proxy endpoint for MCP clients that don't follow the spec correctly
|
||||
// Some clients look for oauth-authorization-server on the MCP server domain
|
||||
|
|
@ -116,11 +122,7 @@ const handleMcpRequest = async (c: Context<{ Bindings: Bindings }>) => {
|
|||
const containerTag = c.req.header("x-sm-project")
|
||||
const apiUrl = c.env.API_URL || DEFAULT_API_URL
|
||||
|
||||
const reqHost = c.req.header("x-forwarded-host") || c.req.header("host") || ""
|
||||
const reqProto = c.req.header("x-forwarded-proto") || "https"
|
||||
const resourceMetadataUrl = reqHost
|
||||
? `${reqProto}://${reqHost}/.well-known/oauth-protected-resource`
|
||||
: "/.well-known/oauth-protected-resource"
|
||||
const resourceMetadataUrl = `${mcpBaseUrl(c)}/.well-known/oauth-protected-resource/mcp`
|
||||
|
||||
if (!token) {
|
||||
return new Response("Unauthorized", {
|
||||
|
|
|
|||
5
apps/web/app/(app)/onboarding/[...slug]/page.tsx
Normal file
5
apps/web/app/(app)/onboarding/[...slug]/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function OnboardingCatchAll() {
|
||||
redirect("/onboarding")
|
||||
}
|
||||
|
|
@ -159,6 +159,9 @@ export default function NewPage() {
|
|||
const [fullscreenInitialContent, setFullscreenInitialContent] = useState("")
|
||||
const [queuedChatSeed, setQueuedChatSeed] = useState<string | null>(null)
|
||||
const [queuedChatModel, setQueuedChatModel] = useState<ModelId | null>(null)
|
||||
const [queuedHighlightContent, setQueuedHighlightContent] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
const [queuedMessageSource, setQueuedMessageSource] = useState<
|
||||
"highlight" | "home"
|
||||
>("highlight")
|
||||
|
|
@ -481,8 +484,9 @@ export default function NewPage() {
|
|||
)
|
||||
|
||||
const handleHighlightsChat = useCallback(
|
||||
(seed: string) => {
|
||||
setQueuedChatSeed(seed)
|
||||
(highlightContent: string, userReply: string) => {
|
||||
setQueuedHighlightContent(highlightContent)
|
||||
setQueuedChatSeed(userReply)
|
||||
setQueuedChatModel(null)
|
||||
setQueuedMessageSource("highlight")
|
||||
void setViewMode("chat")
|
||||
|
|
@ -492,6 +496,7 @@ export default function NewPage() {
|
|||
|
||||
const handleHomeChatStart = useCallback(
|
||||
(message: string, model: ModelId) => {
|
||||
setQueuedHighlightContent(null)
|
||||
setQueuedChatSeed(message)
|
||||
setQueuedChatModel(model)
|
||||
setQueuedMessageSource("home")
|
||||
|
|
@ -503,6 +508,7 @@ export default function NewPage() {
|
|||
const consumeQueuedChat = useCallback(() => {
|
||||
setQueuedChatSeed(null)
|
||||
setQueuedChatModel(null)
|
||||
setQueuedHighlightContent(null)
|
||||
setQueuedMessageSource("highlight")
|
||||
}, [])
|
||||
|
||||
|
|
@ -613,6 +619,7 @@ export default function NewPage() {
|
|||
if (!open) void setViewMode("dashboard")
|
||||
}}
|
||||
queuedMessage={queuedChatSeed}
|
||||
queuedHighlightContent={queuedHighlightContent}
|
||||
onConsumeQueuedMessage={consumeQueuedChat}
|
||||
queuedMessageSource={queuedMessageSource}
|
||||
initialSelectedModel={queuedChatModel}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export default function RootLayout({
|
|||
defaultTheme="dark"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
forcedTheme="dark"
|
||||
>
|
||||
<AutumnProvider
|
||||
backendUrl={
|
||||
|
|
|
|||
|
|
@ -519,7 +519,6 @@ export function AddDocument({
|
|||
setLocalSelectedProject(projects[0] ?? localSelectedProject)
|
||||
}
|
||||
variant="insideOut"
|
||||
singleSelect
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ export function ChatSidebar({
|
|||
isChatOpen,
|
||||
setIsChatOpen,
|
||||
queuedMessage,
|
||||
queuedHighlightContent,
|
||||
onConsumeQueuedMessage,
|
||||
queuedMessageSource = "highlight",
|
||||
initialSelectedModel = null,
|
||||
|
|
@ -155,6 +156,7 @@ export function ChatSidebar({
|
|||
isChatOpen: boolean
|
||||
setIsChatOpen: (open: boolean) => void
|
||||
queuedMessage?: string | null
|
||||
queuedHighlightContent?: string | null
|
||||
onConsumeQueuedMessage?: () => void
|
||||
queuedMessageSource?: "highlight" | "home"
|
||||
initialSelectedModel?: ModelId | null
|
||||
|
|
@ -189,6 +191,10 @@ export function ChatSidebar({
|
|||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
const isScrolledToBottomRef = useRef(true)
|
||||
const sentQueuedMessageRef = useRef<string | null>(null)
|
||||
const pendingHighlightReplyRef = useRef<string | null>(null)
|
||||
const awaitingHighlightInjectionRef = useRef(false)
|
||||
const pendingHighlightMessageRef = useRef<UIMessage[] | null>(null)
|
||||
const targetHighlightChatIdRef = useRef<string | null>(null)
|
||||
const { selectedProject } = useProject()
|
||||
const { allProjects } = useContainerTags()
|
||||
const selectedProjectRef = useRef(selectedProject)
|
||||
|
|
@ -526,14 +532,48 @@ export function ChatSidebar({
|
|||
return
|
||||
}
|
||||
sentQueuedMessageRef.current = queuedMessage
|
||||
if (!threadId) setThreadId(fallbackChatId)
|
||||
analytics.chatMessageSent({ source: queuedMessageSource })
|
||||
sendMessage({ text: queuedMessage })
|
||||
|
||||
if (queuedHighlightContent) {
|
||||
// Start a fresh thread for highlight-based chats to avoid overwriting existing conversations
|
||||
const newChatId = generateId()
|
||||
chatIdRef.current = newChatId
|
||||
setThreadId(null)
|
||||
setFallbackChatId(newChatId)
|
||||
|
||||
// Store the highlight message and user reply in refs.
|
||||
// We cannot call setMessages here because setFallbackChatId above triggers
|
||||
// useChat to recreate its internal Chat object (new id → new Chat), which
|
||||
// resets messages to []. Instead, pendingHighlightMessageRef is read by a
|
||||
// separate useEffect that fires after currentChatId has settled, ensuring
|
||||
// setMessages is called on the correct, freshly-created Chat instance.
|
||||
// targetHighlightChatIdRef ensures we only call setMessages once the new
|
||||
// Chat instance (with id=newChatId) is active, not the old one.
|
||||
pendingHighlightReplyRef.current = queuedMessage
|
||||
awaitingHighlightInjectionRef.current = true
|
||||
targetHighlightChatIdRef.current = newChatId
|
||||
pendingHighlightMessageRef.current = [
|
||||
{
|
||||
id: generateId(),
|
||||
role: "assistant" as const,
|
||||
parts: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Here is a highlight from your memories:\n\n${queuedHighlightContent}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
} else {
|
||||
if (!threadId) setThreadId(fallbackChatId)
|
||||
sendMessage({ text: queuedMessage })
|
||||
}
|
||||
onConsumeQueuedMessage?.()
|
||||
}
|
||||
}, [
|
||||
isChatOpen,
|
||||
queuedMessage,
|
||||
queuedHighlightContent,
|
||||
queuedMessageSource,
|
||||
initialSelectedModel,
|
||||
selectedModel,
|
||||
|
|
@ -545,6 +585,41 @@ export function ChatSidebar({
|
|||
threadId,
|
||||
])
|
||||
|
||||
// Inject the pending highlight assistant message once the new Chat instance is ready.
|
||||
// This effect must run AFTER the currentChatId change has been committed and useChat
|
||||
// has recreated its internal Chat object, so that setMessages targets the correct instance.
|
||||
// We gate on currentChatId === targetHighlightChatIdRef to ensure we call setMessages
|
||||
// only when useChat's internal Chat has the new id (not the old one from before setFallbackChatId).
|
||||
useEffect(() => {
|
||||
if (
|
||||
awaitingHighlightInjectionRef.current &&
|
||||
pendingHighlightMessageRef.current &&
|
||||
targetHighlightChatIdRef.current &&
|
||||
currentChatId === targetHighlightChatIdRef.current
|
||||
) {
|
||||
const msgs = pendingHighlightMessageRef.current
|
||||
pendingHighlightMessageRef.current = null
|
||||
targetHighlightChatIdRef.current = null
|
||||
setMessages(msgs)
|
||||
}
|
||||
}, [currentChatId, setMessages])
|
||||
|
||||
// Send pending highlight reply once the injected assistant message is committed
|
||||
useEffect(() => {
|
||||
if (
|
||||
awaitingHighlightInjectionRef.current &&
|
||||
pendingHighlightReplyRef.current &&
|
||||
messages.length >= 1 &&
|
||||
messages[0]?.role === "assistant" &&
|
||||
status === "ready"
|
||||
) {
|
||||
awaitingHighlightInjectionRef.current = false
|
||||
const reply = pendingHighlightReplyRef.current
|
||||
pendingHighlightReplyRef.current = null
|
||||
sendMessage({ text: reply })
|
||||
}
|
||||
}, [messages, sendMessage, status])
|
||||
|
||||
// Reset the sent message ref when queued message is consumed
|
||||
useEffect(() => {
|
||||
if (!queuedMessage) {
|
||||
|
|
|
|||
|
|
@ -1087,7 +1087,7 @@ export function DashboardView({
|
|||
onNavigateToGraph: () => void
|
||||
onOpenDocument: (document: DocumentWithMemories) => void
|
||||
onOpenToolDocument: (document: DocumentWithMemories) => void
|
||||
onHighlightsChat: (seed: string) => void
|
||||
onHighlightsChat: (highlightContent: string, userReply: string) => void
|
||||
onHighlightsShowRelated: (query: string) => void
|
||||
onResetHighlights: () => void
|
||||
memoryOfDay: MemoryOfDay | null
|
||||
|
|
|
|||
|
|
@ -5,11 +5,29 @@ import type { z } from "zod"
|
|||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { ClaudeDesktopIcon, MCPIcon } from "@ui/assets/icons"
|
||||
import type { ParsedPluginDocument } from "@/lib/plugin-document"
|
||||
import { PluginPreview } from "./plugin-preview"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
export function McpPreview({ document }: { document: DocumentWithMemories }) {
|
||||
export function McpPreview({
|
||||
document,
|
||||
parsed,
|
||||
}: {
|
||||
document: DocumentWithMemories
|
||||
parsed?: ParsedPluginDocument | null
|
||||
}) {
|
||||
if (parsed) {
|
||||
return <PluginPreview parsed={parsed} />
|
||||
}
|
||||
const clientName =
|
||||
typeof document.metadata?.sm_internal_mcp_client_name === "string"
|
||||
? document.metadata.sm_internal_mcp_client_name
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/\b\w/g, (match) => match.toUpperCase())
|
||||
: "MCP Client"
|
||||
|
||||
return (
|
||||
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
|
|
@ -20,7 +38,7 @@ export function McpPreview({ document }: { document: DocumentWithMemories }) {
|
|||
)}
|
||||
>
|
||||
<ClaudeDesktopIcon className="size-3" />
|
||||
Claude Desktop
|
||||
{clientName}
|
||||
</p>
|
||||
<MCPIcon className="size-6" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,11 +5,23 @@ import type { z } from "zod"
|
|||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import { DocumentIcon } from "@/components/document-icon"
|
||||
import type { ParsedPluginDocument } from "@/lib/plugin-document"
|
||||
import { PluginPreview } from "./plugin-preview"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
export function NotePreview({ document }: { document: DocumentWithMemories }) {
|
||||
export function NotePreview({
|
||||
document,
|
||||
parsed,
|
||||
}: {
|
||||
document: DocumentWithMemories
|
||||
parsed?: ParsedPluginDocument | null
|
||||
}) {
|
||||
if (parsed) {
|
||||
return <PluginPreview parsed={parsed} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
|
|||
56
apps/web/components/document-cards/plugin-preview.tsx
Normal file
56
apps/web/components/document-cards/plugin-preview.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { cn } from "@lib/utils"
|
||||
import type { ParsedPluginDocument } from "@/lib/plugin-document"
|
||||
|
||||
export function PluginPreview({ parsed }: { parsed: ParsedPluginDocument }) {
|
||||
return (
|
||||
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex items-center gap-1.5 text-[11px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{parsed.pluginIconSrc && (
|
||||
<Image
|
||||
src={parsed.pluginIconSrc}
|
||||
alt=""
|
||||
width={14}
|
||||
height={14}
|
||||
className="rounded-[3px]"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{parsed.pluginLabel}
|
||||
</span>
|
||||
<p className="text-[11px] text-[#737373] truncate">
|
||||
{parsed.formatLabel}
|
||||
</p>
|
||||
</div>
|
||||
{parsed.identifierValue && (
|
||||
<p className="text-[10px] text-[#737373] truncate max-w-[45%]">
|
||||
{parsed.identifierValue}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-[6px]">
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[13px] font-semibold line-clamp-2 leading-[125%]",
|
||||
)}
|
||||
>
|
||||
{parsed.title}
|
||||
</p>
|
||||
<p className="text-[11px] text-[#737373] line-clamp-4">
|
||||
{parsed.preview || parsed.summary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@ import { WebPageContent } from "./web-page"
|
|||
import { TextEditorContent } from "./text-editor-content"
|
||||
import { GoogleDocViewer } from "./google-doc"
|
||||
import type { TextEditorProps } from "./text-editor-content"
|
||||
import type { ParsedPluginDocument } from "@/lib/plugin-document"
|
||||
import { PluginContent } from "./plugin-content"
|
||||
|
||||
export type { TextEditorProps }
|
||||
|
||||
|
|
@ -33,6 +35,7 @@ type DocumentWithMemories = DocumentsResponse["documents"][0]
|
|||
interface DocumentContentProps {
|
||||
document: DocumentWithMemories | null
|
||||
textEditorProps: TextEditorProps
|
||||
pluginDocument?: ParsedPluginDocument | null
|
||||
}
|
||||
|
||||
type ContentType =
|
||||
|
|
@ -73,10 +76,15 @@ function getContentType(document: DocumentWithMemories | null): ContentType {
|
|||
export function DocumentContent({
|
||||
document,
|
||||
textEditorProps,
|
||||
pluginDocument,
|
||||
}: DocumentContentProps) {
|
||||
const contentType = getContentType(document)
|
||||
|
||||
if (!document || !contentType) return null
|
||||
if (!document) return null
|
||||
if (pluginDocument) {
|
||||
return <PluginContent parsed={pluginDocument} />
|
||||
}
|
||||
if (!contentType) return null
|
||||
|
||||
switch (contentType) {
|
||||
case "image":
|
||||
|
|
|
|||
200
apps/web/components/document-modal/content/plugin-content.tsx
Normal file
200
apps/web/components/document-modal/content/plugin-content.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import type {
|
||||
ParsedPluginDocument,
|
||||
PluginDocumentMessage,
|
||||
PluginDocumentSection,
|
||||
} from "@/lib/plugin-document"
|
||||
|
||||
function roleLabel(role: PluginDocumentMessage["role"]): string {
|
||||
switch (role) {
|
||||
case "user":
|
||||
return "User"
|
||||
case "assistant":
|
||||
return "Assistant"
|
||||
case "tool":
|
||||
return "Tool"
|
||||
case "system":
|
||||
return "System"
|
||||
default:
|
||||
return "Message"
|
||||
}
|
||||
}
|
||||
|
||||
function sectionClasses(tone: PluginDocumentSection["tone"]): string {
|
||||
switch (tone) {
|
||||
case "accent":
|
||||
return "border-[#2261CA33] bg-[#0C1829]"
|
||||
case "muted":
|
||||
return "border-[#252A31] bg-[#11151A]"
|
||||
default:
|
||||
return "border-[#1E232B] bg-[#0F1318]"
|
||||
}
|
||||
}
|
||||
|
||||
function PluginHeader({ parsed }: { parsed: ParsedPluginDocument }) {
|
||||
return (
|
||||
<div className="px-4 pt-4 pb-3 border-b border-white/6 bg-[#14181E]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex items-center rounded-full border border-[#2261CA33] bg-[#0D1A2E] px-2.5 py-1 text-[11px] font-medium text-[#8FC8FF]",
|
||||
)}
|
||||
>
|
||||
{parsed.pluginLabel}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex items-center rounded-full border border-[#1F242C] bg-[#0F1318] px-2.5 py-1 text-[11px] font-medium text-[#B7BDC7]",
|
||||
)}
|
||||
>
|
||||
{parsed.formatLabel}
|
||||
</span>
|
||||
{parsed.identifierLabel && parsed.identifierValue && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex items-center rounded-full border border-[#1F242C] bg-[#0F1318] px-2.5 py-1 text-[11px] font-medium text-[#8E97A3]",
|
||||
)}
|
||||
>
|
||||
{parsed.identifierLabel}: {parsed.identifierValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"mt-3 text-[18px] font-semibold text-[#FAFAFA] leading-[120%]",
|
||||
)}
|
||||
>
|
||||
{parsed.title}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[#8E97A3] leading-[150%]">
|
||||
{parsed.summary}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationView({ parsed }: { parsed: ParsedPluginDocument }) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin px-4 py-4 space-y-2">
|
||||
{parsed.messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="rounded-[12px] border border-[#1E232B] bg-[#0F1318] px-3 py-2"
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] uppercase tracking-[0.08em] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{roleLabel(message.role)}
|
||||
</p>
|
||||
<p className="mt-1 whitespace-pre-wrap text-[13px] leading-[1.55] text-[#F3F4F6]">
|
||||
{message.text}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionsView({ parsed }: { parsed: ParsedPluginDocument }) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin px-4 py-4 space-y-3">
|
||||
{parsed.sections.map((section, index) => (
|
||||
<div
|
||||
key={`${section.label}-${index}`}
|
||||
className={cn(
|
||||
"rounded-[16px] border px-4 py-4",
|
||||
sectionClasses(section.tone),
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[12px] font-medium uppercase tracking-[0.08em] text-[#8E97A3]",
|
||||
)}
|
||||
>
|
||||
{section.label}
|
||||
</p>
|
||||
<p className="mt-2 whitespace-pre-wrap text-[15px] leading-[1.6] text-[#F1F3F5]">
|
||||
{section.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RawView({ parsed }: { parsed: ParsedPluginDocument }) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin p-4">
|
||||
<pre className="rounded-[16px] border border-[#1E232B] bg-[#0D1116] p-4 whitespace-pre-wrap break-words text-[13px] leading-[1.6] text-[#C7CDD6]">
|
||||
{parsed.rawContent}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginContent({ parsed }: { parsed: ParsedPluginDocument }) {
|
||||
const [mode, setMode] = useState<"structured" | "raw">("structured")
|
||||
const hasMessages = parsed.messages.length > 0
|
||||
|
||||
const hideHeader = parsed.kind === "claude-code-doc"
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{!hideHeader && <PluginHeader parsed={parsed} />}
|
||||
<div className={cn("px-4", hideHeader ? "pt-4" : "pt-3")}>
|
||||
<div
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex h-9 items-center gap-0.5 rounded-full border border-[#161F2C] bg-[#0D121A] p-0.5",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "structured"}
|
||||
className={cn(
|
||||
"inline-flex h-full items-center justify-center rounded-full border px-3 text-xs font-medium cursor-pointer transition-colors",
|
||||
mode === "structured"
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "border-transparent text-[#737373] hover:bg-white/5",
|
||||
)}
|
||||
onClick={() => setMode("structured")}
|
||||
>
|
||||
{hasMessages ? "Conversation" : "Structured"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "raw"}
|
||||
className={cn(
|
||||
"inline-flex h-full items-center justify-center rounded-full border px-3 text-xs font-medium cursor-pointer transition-colors",
|
||||
mode === "raw"
|
||||
? "border-[#2261CA33] bg-[#00173C] text-white"
|
||||
: "border-transparent text-[#737373] hover:bg-white/5",
|
||||
)}
|
||||
onClick={() => setMode("raw")}
|
||||
>
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{mode === "raw" ? (
|
||||
<RawView parsed={parsed} />
|
||||
) : hasMessages ? (
|
||||
<ConversationView parsed={parsed} />
|
||||
) : (
|
||||
<SectionsView parsed={parsed} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
Loader2,
|
||||
Trash2Icon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
} from "lucide-react"
|
||||
import type { z } from "zod"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
|
|
@ -23,6 +24,8 @@ import { useDocumentMutations } from "@/hooks/use-document-mutations"
|
|||
import type { UseMutationResult } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { useIsMobile } from "@hooks/use-mobile"
|
||||
import { parsePluginDocument } from "@/lib/plugin-document"
|
||||
import { PluginDetails } from "./plugin-details"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
|
@ -153,6 +156,39 @@ function DeleteButton({
|
|||
)
|
||||
}
|
||||
|
||||
function CopySessionIdButton({ sessionId }: { sessionId: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(sessionId)
|
||||
setCopied(true)
|
||||
toast.success("Copy session id")
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
toast.error("Failed to copy session id")
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
tabIndex={-1}
|
||||
title="Copy session id"
|
||||
aria-label="Copy session id"
|
||||
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<CopyIcon className="w-4 h-4 text-[#737373]" />
|
||||
)}
|
||||
<span className="sr-only">Copy session id</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function DocumentModal({
|
||||
document: _document,
|
||||
isOpen,
|
||||
|
|
@ -168,6 +204,10 @@ export function DocumentModal({
|
|||
initialEditorString: content ?? "",
|
||||
}
|
||||
}, [_document?.content])
|
||||
const pluginDocument = useMemo(
|
||||
() => parsePluginDocument(_document),
|
||||
[_document],
|
||||
)
|
||||
|
||||
const [draftContentString, setDraftContentString] =
|
||||
useState(initialEditorString)
|
||||
|
|
@ -253,9 +293,14 @@ export function DocumentModal({
|
|||
title={_document?.title}
|
||||
documentType={_document?.type ?? "text"}
|
||||
url={_document?.url}
|
||||
pluginIconSrc={pluginDocument?.pluginIconSrc}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 md:gap-2 shrink-0">
|
||||
{pluginDocument?.kind === "claude-code-doc" &&
|
||||
_document?.customId && (
|
||||
<CopySessionIdButton sessionId={_document.customId} />
|
||||
)}
|
||||
<DeleteButton
|
||||
documentId={_document?.id}
|
||||
customId={_document?.customId}
|
||||
|
|
@ -298,6 +343,7 @@ export function DocumentModal({
|
|||
<DocumentContent
|
||||
document={_document}
|
||||
textEditorProps={textEditorProps}
|
||||
pluginDocument={pluginDocument}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -307,10 +353,17 @@ export function DocumentModal({
|
|||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{_document?.summary && (
|
||||
{pluginDocument &&
|
||||
pluginDocument.kind !== "claude-code-doc" &&
|
||||
pluginDocument.kind !== "openclaw-session" && (
|
||||
<PluginDetails parsed={pluginDocument} />
|
||||
)}
|
||||
{_document && (_document.summary || pluginDocument?.summary) && (
|
||||
<DocumentSummary
|
||||
memoryEntries={_document.memoryEntries}
|
||||
summary={_document.summary}
|
||||
summary={
|
||||
(pluginDocument?.summary ?? _document.summary) as string
|
||||
}
|
||||
createdAt={_document.createdAt}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
83
apps/web/components/document-modal/plugin-details.tsx
Normal file
83
apps/web/components/document-modal/plugin-details.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import Image from "next/image"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import type { ParsedPluginDocument } from "@/lib/plugin-document"
|
||||
|
||||
function DetailPill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-[12px] border border-[#1E232B] bg-[#0F1318] px-3 py-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[10px] uppercase tracking-[0.08em] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] text-[#F3F4F6] break-all">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginDetails({ parsed }: { parsed: ParsedPluginDocument }) {
|
||||
return (
|
||||
<div className="bg-[#14161A] p-3 rounded-[14px] space-y-3 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[16px] font-semibold text-[#FAFAFA] leading-[125%]">
|
||||
Details
|
||||
</p>
|
||||
<span
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"inline-flex items-center gap-1.5 text-[12px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{parsed.pluginIconSrc && (
|
||||
<Image
|
||||
src={parsed.pluginIconSrc}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded-[3px]"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{parsed.pluginLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<DetailPill label="Format" value={parsed.formatLabel} />
|
||||
{parsed.identifierLabel && parsed.identifierValue && (
|
||||
<DetailPill
|
||||
label={parsed.identifierLabel}
|
||||
value={parsed.identifierValue}
|
||||
/>
|
||||
)}
|
||||
{parsed.clientLabel && parsed.clientValue && (
|
||||
<DetailPill label={parsed.clientLabel} value={parsed.clientValue} />
|
||||
)}
|
||||
</div>
|
||||
{parsed.artifacts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSansClassName(),
|
||||
"text-[11px] font-medium uppercase tracking-[0.08em] text-[#737373]",
|
||||
)}
|
||||
>
|
||||
Outputs
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{parsed.artifacts.map((artifact, index) => (
|
||||
<DetailPill
|
||||
key={`${artifact.label}-${artifact.value}-${index}`}
|
||||
label={artifact.label}
|
||||
value={artifact.value}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import Image from "next/image"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import { DocumentIcon } from "@/components/document-icon"
|
||||
|
|
@ -15,10 +16,12 @@ export function Title({
|
|||
title,
|
||||
documentType,
|
||||
url,
|
||||
pluginIconSrc,
|
||||
}: {
|
||||
title: string | null | undefined
|
||||
documentType: string
|
||||
url?: string | null
|
||||
pluginIconSrc?: string
|
||||
}) {
|
||||
const extension = getFileExtension(documentType)
|
||||
|
||||
|
|
@ -30,7 +33,18 @@ export function Title({
|
|||
)}
|
||||
>
|
||||
<div className="pl-1 flex items-center gap-1 shrink-0">
|
||||
<DocumentIcon type={documentType} url={url} className="size-5" />
|
||||
{pluginIconSrc ? (
|
||||
<Image
|
||||
src={pluginIconSrc}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="rounded-[4px]"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<DocumentIcon type={documentType} url={url} className="size-5" />
|
||||
)}
|
||||
{extension && (
|
||||
<p
|
||||
className={cn(dmSansClassName(), "text-[12px] font-semibold")}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
MessageCircleIcon,
|
||||
LifeBuoy,
|
||||
LayoutGrid,
|
||||
ChevronRight,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import { cn } from "@lib/utils"
|
||||
|
|
@ -131,12 +132,17 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!isMobile && (
|
||||
<SpaceSelector
|
||||
selectedProjects={selectedProjects}
|
||||
onValueChange={setSelectedProjects}
|
||||
showChevron
|
||||
enableDelete
|
||||
/>
|
||||
<>
|
||||
<ChevronRight
|
||||
className="size-4 shrink-0 text-[#3F4853]"
|
||||
aria-hidden
|
||||
/>
|
||||
<SpaceSelector
|
||||
selectedProjects={selectedProjects}
|
||||
onValueChange={setSelectedProjects}
|
||||
enableDelete
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isMobile && (
|
||||
|
|
@ -259,7 +265,6 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
|
|||
<SpaceSelector
|
||||
selectedProjects={selectedProjects}
|
||||
onValueChange={setSelectedProjects}
|
||||
showChevron
|
||||
enableDelete
|
||||
compact
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSansClassName } from "@/lib/fonts"
|
||||
import {
|
||||
|
|
@ -9,6 +9,8 @@ import {
|
|||
Info,
|
||||
MessageSquare,
|
||||
Link2,
|
||||
ArrowUp,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { Logo } from "@ui/assets/Logo"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
|
@ -26,7 +28,7 @@ export interface HighlightItem {
|
|||
|
||||
interface HighlightsCardProps {
|
||||
items: HighlightItem[]
|
||||
onChat: (seed: string) => void
|
||||
onChat: (highlightContent: string, userReply: string) => void
|
||||
onShowRelated: (query: string) => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
|
@ -50,7 +52,7 @@ function renderContent(content: string, format: HighlightFormat) {
|
|||
}
|
||||
case "quote":
|
||||
return (
|
||||
<p className="text-[12px] leading-normal italic border-l-2 border-[#4BA0FA] pl-2">
|
||||
<p className="text-[12px] leading-normal italic border-l-2 border-brand-accent pl-2">
|
||||
"{content}"
|
||||
</p>
|
||||
)
|
||||
|
|
@ -68,26 +70,64 @@ export function HighlightsCard({
|
|||
isLoading = false,
|
||||
}: HighlightsCardProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [isReplyOpen, setIsReplyOpen] = useState(false)
|
||||
const [replyText, setReplyText] = useState("")
|
||||
const replyInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const currentItem = items[activeIndex]
|
||||
|
||||
useEffect(() => {
|
||||
if (isReplyOpen) replyInputRef.current?.focus()
|
||||
}, [isReplyOpen])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally re-run when items changes
|
||||
useEffect(() => {
|
||||
setIsReplyOpen(false)
|
||||
setReplyText("")
|
||||
}, [items])
|
||||
|
||||
const handlePrev = useCallback(() => {
|
||||
setActiveIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1))
|
||||
setIsReplyOpen(false)
|
||||
setReplyText("")
|
||||
}, [items.length])
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
setActiveIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0))
|
||||
setIsReplyOpen(false)
|
||||
setReplyText("")
|
||||
}, [items.length])
|
||||
|
||||
const handleChat = useCallback(() => {
|
||||
const handleChatClick = useCallback(() => {
|
||||
if (!currentItem) return
|
||||
analytics.highlightClicked({
|
||||
highlight_id: currentItem.id,
|
||||
action: "chat",
|
||||
})
|
||||
const seed = `Tell me more about "${currentItem.title}"`
|
||||
onChat(seed)
|
||||
}, [currentItem, onChat])
|
||||
setIsReplyOpen(true)
|
||||
}, [currentItem])
|
||||
|
||||
const handleReplySubmit = useCallback(() => {
|
||||
if (!currentItem || !replyText.trim()) return
|
||||
const highlightContent = `${currentItem.title}\n\n${currentItem.content}`
|
||||
onChat(highlightContent, replyText.trim())
|
||||
setIsReplyOpen(false)
|
||||
setReplyText("")
|
||||
}, [currentItem, replyText, onChat])
|
||||
|
||||
const handleReplyKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleReplySubmit()
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setIsReplyOpen(false)
|
||||
setReplyText("")
|
||||
}
|
||||
},
|
||||
[handleReplySubmit],
|
||||
)
|
||||
|
||||
const handleShowRelated = useCallback(() => {
|
||||
if (!currentItem) return
|
||||
|
|
@ -107,18 +147,18 @@ export function HighlightsCard({
|
|||
)}
|
||||
>
|
||||
<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 className="size-[14px] rounded-full bg-surface-skeleton animate-pulse" />
|
||||
<div className="h-2 w-20 rounded bg-surface-skeleton 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 className="h-2.5 w-2/5 rounded bg-surface-skeleton animate-pulse" />
|
||||
<div className="h-2 w-full rounded bg-surface-skeleton animate-pulse" />
|
||||
<div className="h-2 w-[85%] rounded bg-surface-skeleton animate-pulse" />
|
||||
<div className="h-2 w-[65%] rounded bg-surface-skeleton 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 className="h-[26px] w-14 rounded-lg bg-surface-skeleton animate-pulse" />
|
||||
<div className="h-[26px] w-16 rounded-lg bg-surface-skeleton animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -136,10 +176,10 @@ export function HighlightsCard({
|
|||
<div className="flex items-center gap-1">
|
||||
<Logo className="size-[14px]" />
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-[10px] text-[#4BA0FA] tracking-[-0.3px]">
|
||||
<span className="text-[10px] text-brand-accent tracking-[-0.3px]">
|
||||
powered by
|
||||
</span>
|
||||
<span className="text-[10px] text-[#4BA0FA] font-medium tracking-[-0.3px]">
|
||||
<span className="text-[10px] text-brand-accent font-medium tracking-[-0.3px]">
|
||||
supermemory
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -165,10 +205,10 @@ export function HighlightsCard({
|
|||
<div className="flex items-center gap-1">
|
||||
<Logo className="size-[14px]" />
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-[10px] text-[#4BA0FA] tracking-[-0.3px]">
|
||||
<span className="text-[10px] text-brand-accent tracking-[-0.3px]">
|
||||
powered by
|
||||
</span>
|
||||
<span className="text-[10px] text-[#4BA0FA] font-medium tracking-[-0.3px]">
|
||||
<span className="text-[10px] text-brand-accent font-medium tracking-[-0.3px]">
|
||||
supermemory
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -185,33 +225,61 @@ export function HighlightsCard({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{isReplyOpen && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1 flex items-center bg-pill-bg border border-pill-border rounded-full px-3 py-1 gap-1.5 focus-within:border-pill-border-active transition-colors">
|
||||
<input
|
||||
ref={replyInputRef}
|
||||
type="text"
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyDown={handleReplyKeyDown}
|
||||
placeholder={`Ask Nova about "${currentItem.title.length > 36 ? `${currentItem.title.slice(0, 36)}…` : currentItem.title}"`}
|
||||
className="flex-1 bg-transparent text-[11px] text-fg-primary placeholder:text-fg-subtle outline-none min-w-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReplySubmit}
|
||||
disabled={!replyText.trim()}
|
||||
className="shrink-0 flex items-center justify-center text-fg-subtle hover:text-fg-primary disabled:opacity-30 disabled:hover:text-fg-subtle cursor-pointer disabled:cursor-default transition-colors"
|
||||
aria-label="Send reply"
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsReplyOpen(false)
|
||||
setReplyText("")
|
||||
}}
|
||||
className="shrink-0 text-fg-subtle hover:text-fg-primary transition-colors cursor-pointer"
|
||||
aria-label="Cancel reply"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
<div id="highlights-actions" className="flex gap-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleChat}
|
||||
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)",
|
||||
}}
|
||||
onClick={handleChatClick}
|
||||
className="rounded-full border border-pill-border bg-pill-bg px-2.5 py-1 text-[11px] text-fg-secondary hover:bg-pill-bg-active hover:border-pill-border-active hover:text-fg-primary transition-colors flex items-center gap-1.5 cursor-pointer"
|
||||
aria-label="Chat with Nova"
|
||||
>
|
||||
<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)]" />
|
||||
<MessageSquare className="size-3.5" />
|
||||
Chat
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShowRelated}
|
||||
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)",
|
||||
}}
|
||||
className="rounded-full border border-pill-border bg-pill-bg px-2.5 py-1 text-[11px] text-fg-secondary hover:bg-pill-bg-active hover:border-pill-border-active hover:text-fg-primary transition-colors flex items-center gap-1.5 cursor-pointer"
|
||||
aria-label="Show related"
|
||||
>
|
||||
<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)]" />
|
||||
<Link2 className="size-3.5" />
|
||||
Related
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -220,7 +288,7 @@ export function HighlightsCard({
|
|||
<button
|
||||
type="button"
|
||||
onClick={handlePrev}
|
||||
className="text-fg-subtle hover:text-white transition-colors cursor-pointer"
|
||||
className="text-fg-subtle hover:text-fg-primary transition-colors cursor-pointer"
|
||||
aria-label="Previous item"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
|
|
@ -230,11 +298,15 @@ export function HighlightsCard({
|
|||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => setActiveIndex(idx)}
|
||||
onClick={() => {
|
||||
setActiveIndex(idx)
|
||||
setIsReplyOpen(false)
|
||||
setReplyText("")
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-full transition-all cursor-pointer",
|
||||
idx === activeIndex
|
||||
? "w-4 h-1.5 bg-[#4BA0FA]"
|
||||
? "w-4 h-1.5 bg-brand-accent"
|
||||
: "size-1.5 bg-fg-subtle hover:bg-fg-secondary",
|
||||
)}
|
||||
aria-label={`Go to item ${idx + 1}`}
|
||||
|
|
@ -244,7 +316,7 @@ export function HighlightsCard({
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
className="text-fg-subtle hover:text-white transition-colors cursor-pointer"
|
||||
className="text-fg-subtle hover:text-fg-primary transition-colors cursor-pointer"
|
||||
aria-label="Next item"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ const cards: IntegrationCardDef[] = [
|
|||
id: "plugins",
|
||||
title: "Plugins",
|
||||
description:
|
||||
"Hermes on every plan; Claude Code, OpenCode, OpenClaw, and more with Pro",
|
||||
"Hermes on every plan; Claude Code, Codex, OpenCode, OpenClaw, and more with Pro",
|
||||
icon: (
|
||||
<div className="flex items-center -space-x-1.5">
|
||||
<Image
|
||||
|
|
@ -61,6 +61,13 @@ const cards: IntegrationCardDef[] = [
|
|||
height={24}
|
||||
className="size-6 rounded"
|
||||
/>
|
||||
<Image
|
||||
src="/images/plugins/codex.png"
|
||||
alt="Codex"
|
||||
width={24}
|
||||
height={24}
|
||||
className="size-6 rounded"
|
||||
/>
|
||||
<Image
|
||||
src="/images/plugins/opencode.svg"
|
||||
alt="OpenCode"
|
||||
|
|
|
|||
166
apps/web/components/integrations/install-steps.tsx
Normal file
166
apps/web/components/integrations/install-steps.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"use client"
|
||||
|
||||
import { useState, type ReactNode } from "react"
|
||||
import { Check, Copy } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { cn } from "@lib/utils"
|
||||
import { dmSans125ClassName } from "@/lib/fonts"
|
||||
import type { InstallStep } from "@/lib/plugin-catalog"
|
||||
|
||||
/** Recessed "inside-out" inset shadow used across Supermemory surfaces. */
|
||||
export const INSET =
|
||||
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
|
||||
|
||||
export function PillButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
|
||||
"text-[12px] font-medium text-[#FAFAFA] sm:text-[14px]",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
"cursor-pointer transition-opacity hover:opacity-80",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function CopyButton({ text, label }: { text: string; label?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Copy ${label ?? "to clipboard"}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
toast.success(label ? `${label} copied!` : "Copied!")
|
||||
} catch {
|
||||
toast.error("Failed to copy")
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<Copy className="size-3.5 text-[#737373]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function CodeBlock({
|
||||
code,
|
||||
copyLabel = "Command",
|
||||
secret,
|
||||
}: {
|
||||
code: string
|
||||
copyLabel?: string
|
||||
secret?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="group flex min-w-0 items-center gap-2 rounded-[10px] border border-white/[0.07] bg-[#0B0E13] px-3 py-2.5">
|
||||
<pre
|
||||
className={cn(
|
||||
"scrollbar-none min-w-0 flex-1 overflow-x-auto whitespace-pre font-mono text-[12px] leading-[1.6] text-[#E4E4E7] transition-[filter] duration-150",
|
||||
secret &&
|
||||
"select-none blur-[5px] group-focus-within:select-text group-focus-within:blur-none group-hover:select-text group-hover:blur-none",
|
||||
)}
|
||||
>
|
||||
{code}
|
||||
</pre>
|
||||
<CopyButton text={code} label={copyLabel} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InstallSteps({
|
||||
steps,
|
||||
apiKey,
|
||||
}: {
|
||||
steps: InstallStep[]
|
||||
apiKey?: string
|
||||
}) {
|
||||
return (
|
||||
<ol className="flex min-w-0 flex-col gap-4">
|
||||
{steps.map((step, i) => (
|
||||
<li key={step.title} className="flex min-w-0 gap-3">
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-[22px] shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[11px] font-semibold text-[#4BA0FA]",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
{i < steps.length - 1 && (
|
||||
<span className="w-px flex-1 bg-white/[0.14]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2 pb-1">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
{step.optional && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"shrink-0 rounded-[4px] bg-white/[0.08] px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-[#A1A1AA]",
|
||||
)}
|
||||
>
|
||||
Optional
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{step.description && (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] leading-relaxed text-[#A1A1AA]",
|
||||
)}
|
||||
>
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{step.code && (
|
||||
<CodeBlock
|
||||
code={apiKey ? step.code.replace("sm_...", apiKey) : step.code}
|
||||
copyLabel={step.copyLabel}
|
||||
secret={step.secret}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,162 +8,19 @@ import { hasActivePlan } from "@lib/queries"
|
|||
import { useCustomer } from "autumn-js/react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Loader,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react"
|
||||
import { BookOpen, Check, ChevronDown, Loader, X, Zap } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
|
||||
|
||||
/** Recessed "inside-out" inset shadow used across Supermemory surfaces. */
|
||||
const INSET =
|
||||
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
|
||||
|
||||
/** Match `FREE_TIER_PLUGIN_IDS` in mono `packages/lib/plugins.ts`. */
|
||||
const FREE_TIER_PLUGIN_IDS = ["hermes", "codex"]
|
||||
function isFreeTierPlugin(pluginId: string): boolean {
|
||||
return FREE_TIER_PLUGIN_IDS.includes(pluginId)
|
||||
}
|
||||
|
||||
interface InstallStep {
|
||||
title: string
|
||||
description?: string
|
||||
code?: string
|
||||
copyLabel?: string
|
||||
optional?: boolean
|
||||
/** Blur the code block until hovered/focused (e.g. it contains the key). */
|
||||
secret?: boolean
|
||||
}
|
||||
|
||||
interface PluginInfo {
|
||||
id: string
|
||||
name: string
|
||||
tagline: string
|
||||
icon: string
|
||||
docsUrl?: string
|
||||
/** Steps shown after a key is minted. The literal `sm_...` is replaced
|
||||
* with the freshly generated key when rendered. */
|
||||
installSteps?: InstallStep[]
|
||||
}
|
||||
|
||||
const PLUGIN_CATALOG: Record<string, PluginInfo> = {
|
||||
claude_code: {
|
||||
id: "claude_code",
|
||||
name: "Claude Code",
|
||||
tagline: "Remembers your conventions, decisions, and project context",
|
||||
icon: "/images/plugins/claude-code.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/claude-code",
|
||||
installSteps: [
|
||||
{
|
||||
title: "Save your API key",
|
||||
description:
|
||||
"Add this to your shell profile so Claude Code can authenticate. This key is shown only once — save it now.",
|
||||
code: 'export SUPERMEMORY_CC_API_KEY="sm_..."',
|
||||
copyLabel: "API key",
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
title: "Install the plugin",
|
||||
description: "Run these commands inside a Claude Code session:",
|
||||
code: "/plugin marketplace add supermemoryai/claude-supermemory\n/plugin install claude-supermemory",
|
||||
},
|
||||
],
|
||||
},
|
||||
codex: {
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
tagline: "Persistent memory for the Codex CLI — free on every plan",
|
||||
icon: "/images/plugins/codex.png",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/codex",
|
||||
installSteps: [
|
||||
{
|
||||
title: "Save your API key",
|
||||
description:
|
||||
"Add this to your shell profile. This key is shown only once — save it now.",
|
||||
code: 'export SUPERMEMORY_CODEX_API_KEY="sm_..."',
|
||||
copyLabel: "API key",
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
title: "Install the hooks",
|
||||
description: "Run this to wire Supermemory into Codex CLI:",
|
||||
code: "npx codex-supermemory@latest install",
|
||||
},
|
||||
],
|
||||
},
|
||||
opencode: {
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
tagline: "Long-term memory for your OpenCode sessions",
|
||||
icon: "/images/plugins/opencode.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/opencode",
|
||||
installSteps: [
|
||||
{
|
||||
title: "Save your API key",
|
||||
description:
|
||||
"Add this to your shell profile. This key is shown only once — save it now.",
|
||||
code: 'export SUPERMEMORY_API_KEY="sm_..."',
|
||||
copyLabel: "API key",
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
title: "Install the plugin",
|
||||
description: "Use --no-tui for non-interactive environments.",
|
||||
code: "bunx opencode-supermemory@latest install",
|
||||
},
|
||||
{
|
||||
title: "Verify your config",
|
||||
description:
|
||||
"Ensure ~/.config/opencode/opencode.jsonc includes the plugin:",
|
||||
code: '{\n "plugin": ["opencode-supermemory"]\n}',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
openclaw: {
|
||||
id: "openclaw",
|
||||
name: "OpenClaw",
|
||||
tagline: "Cross-platform memory across Telegram, Discord, Slack",
|
||||
icon: "/images/plugins/openclaw.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/openclaw",
|
||||
installSteps: [
|
||||
{
|
||||
title: "Install the plugin",
|
||||
description: "Run this in your OpenClaw project:",
|
||||
code: "openclaw plugins install @supermemory/openclaw-supermemory",
|
||||
},
|
||||
{
|
||||
title: "Configure Supermemory",
|
||||
description:
|
||||
"Run the setup command and paste your API key when prompted:",
|
||||
code: "openclaw supermemory setup",
|
||||
},
|
||||
],
|
||||
},
|
||||
hermes: {
|
||||
id: "hermes",
|
||||
name: "Hermes",
|
||||
tagline: "Persistent memory for the Hermes agent — free on every plan",
|
||||
icon: "/images/plugins/hermes.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/hermes",
|
||||
installSteps: [
|
||||
{
|
||||
title: "Run Hermes memory setup",
|
||||
description:
|
||||
"On the machine where Hermes is deployed, start the memory wizard, choose Supermemory as the provider, and paste your API key when prompted:",
|
||||
code: "hermes memory setup",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
import {
|
||||
PLUGIN_CATALOG,
|
||||
isFreeTierPlugin,
|
||||
type InstallStep,
|
||||
type PluginInfo,
|
||||
} from "@/lib/plugin-catalog"
|
||||
import { INSET, InstallSteps, PillButton } from "./install-steps"
|
||||
|
||||
interface ConnectedPlugin {
|
||||
id: string
|
||||
|
|
@ -222,46 +79,20 @@ function ProChip() {
|
|||
)
|
||||
}
|
||||
|
||||
function PillButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: ReactNode
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"relative flex h-9 min-w-[116px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-5",
|
||||
"text-[14px] font-medium text-[#FAFAFA]",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
|
||||
"cursor-pointer transition-opacity hover:opacity-80",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function DocsLink({ href }: { href: string }) {
|
||||
return (
|
||||
<a
|
||||
aria-label="Open plugin docs"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex shrink-0 items-center gap-1 text-[12px] text-[#A1A1AA] transition-colors hover:text-white",
|
||||
"flex size-8 shrink-0 items-center justify-center gap-1 rounded-full text-[12px] text-[#A1A1AA] transition-colors hover:text-white sm:h-auto sm:w-auto sm:justify-start sm:rounded-none",
|
||||
)}
|
||||
>
|
||||
<BookOpen className="size-3.5" /> Docs
|
||||
<BookOpen className="size-3.5" />{" "}
|
||||
<span className="hidden sm:inline">Docs</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
|
@ -304,7 +135,7 @@ function ConnectedPill({
|
|||
type="button"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-9 min-w-[116px] shrink-0 cursor-pointer items-center justify-center gap-2 rounded-full bg-[#0D121A] px-4 text-[13px] font-medium text-[#00AC3F]",
|
||||
"flex h-8 min-w-[104px] shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 text-[12px] font-medium text-[#00AC3F] sm:h-9 sm:min-w-[116px] sm:gap-2 sm:px-4 sm:text-[13px]",
|
||||
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)] transition-opacity hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
|
|
@ -376,21 +207,21 @@ function PluginRow({
|
|||
}) {
|
||||
const isConnected = connectedKeys.length > 0
|
||||
return (
|
||||
<div className="flex items-center gap-3.5 border-b border-white/[0.06] py-4 last:border-b-0">
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-2.5 border-b border-white/[0.06] py-4 last:border-b-0 sm:flex sm:items-center sm:gap-3.5">
|
||||
<PluginIconBox
|
||||
src={plugin.icon}
|
||||
alt={plugin.name}
|
||||
dimmed={needsProUpgrade && !isConnected}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{isConnected && (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-[#00AC3F]" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"truncate text-[14px] font-medium text-[#FAFAFA]",
|
||||
"min-w-0 truncate text-[14px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{plugin.name}
|
||||
|
|
@ -400,13 +231,13 @@ function PluginRow({
|
|||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"mt-0.5 truncate text-[13px] text-[#A1A1AA]",
|
||||
"mt-0.5 line-clamp-2 text-[12px] leading-snug text-[#A1A1AA] sm:truncate sm:text-[13px]",
|
||||
)}
|
||||
>
|
||||
{plugin.tagline}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-4">
|
||||
<div className="col-start-2 flex min-w-0 shrink-0 items-center gap-2 sm:col-start-auto sm:gap-4">
|
||||
{plugin.docsUrl && <DocsLink href={plugin.docsUrl} />}
|
||||
{isConnected ? (
|
||||
<ConnectedPill connectedKeys={connectedKeys} onRevoke={onRevoke} />
|
||||
|
|
@ -470,132 +301,6 @@ function TierFilterToggle({
|
|||
)
|
||||
}
|
||||
|
||||
function CopyButton({ text, label }: { text: string; label?: string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Copy ${label ?? "to clipboard"}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
toast.success(label ? `${label} copied!` : "Copied!")
|
||||
} catch {
|
||||
toast.error("Failed to copy")
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5 text-[#4BA0FA]" />
|
||||
) : (
|
||||
<Copy className="size-3.5 text-[#737373]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({
|
||||
code,
|
||||
copyLabel = "Command",
|
||||
secret,
|
||||
}: {
|
||||
code: string
|
||||
copyLabel?: string
|
||||
secret?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="group flex min-w-0 items-center gap-2 rounded-[10px] border border-white/[0.07] bg-[#0B0E13] px-3 py-2.5">
|
||||
<pre
|
||||
className={cn(
|
||||
"scrollbar-none min-w-0 flex-1 overflow-x-auto whitespace-pre font-mono text-[12px] leading-[1.6] text-[#E4E4E7] transition-[filter] duration-150",
|
||||
secret &&
|
||||
"select-none blur-[5px] group-focus-within:select-text group-focus-within:blur-none group-hover:select-text group-hover:blur-none",
|
||||
)}
|
||||
>
|
||||
{code}
|
||||
</pre>
|
||||
<CopyButton text={code} label={copyLabel} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InstallSteps({
|
||||
steps,
|
||||
apiKey,
|
||||
}: {
|
||||
steps: InstallStep[]
|
||||
apiKey: string
|
||||
}) {
|
||||
return (
|
||||
<ol className="flex min-w-0 flex-col gap-4">
|
||||
{steps.map((step, i) => (
|
||||
<li key={step.title} className="flex min-w-0 gap-3">
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-[22px] shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[11px] font-semibold text-[#4BA0FA]",
|
||||
INSET,
|
||||
)}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
{i < steps.length - 1 && (
|
||||
<span className="w-px flex-1 bg-white/[0.14]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2 pb-1">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[13px] font-medium text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
{step.optional && (
|
||||
<span
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"shrink-0 rounded-[4px] bg-white/[0.08] px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-[#A1A1AA]",
|
||||
)}
|
||||
>
|
||||
Optional
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{step.description && (
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"text-[12px] leading-relaxed text-[#A1A1AA]",
|
||||
)}
|
||||
>
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{step.code && (
|
||||
<CodeBlock
|
||||
code={apiKey ? step.code.replace("sm_...", apiKey) : step.code}
|
||||
copyLabel={step.copyLabel}
|
||||
secret={step.secret}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginsDetail() {
|
||||
const { org } = useAuth()
|
||||
const autumn = useCustomer()
|
||||
|
|
@ -782,7 +487,7 @@ export function PluginsDetail() {
|
|||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-[14px] bg-[#14161A] p-6",
|
||||
"relative overflow-hidden rounded-[14px] bg-[#14161A] p-4 sm:p-6",
|
||||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ import { WebsitePreview } from "./document-cards/website-preview"
|
|||
import { GoogleDocsPreview } from "./document-cards/google-docs-preview"
|
||||
import { FilePreview } from "./document-cards/file-preview"
|
||||
import { NotePreview } from "./document-cards/note-preview"
|
||||
import {
|
||||
claudeCodeTokenBadge,
|
||||
parsePluginDocument,
|
||||
type ParsedPluginDocument,
|
||||
} from "@/lib/plugin-document"
|
||||
import { YoutubePreview } from "./document-cards/youtube-preview"
|
||||
import { getAbsoluteUrl, isYouTubeUrl, useYouTubeChannelName } from "./utils"
|
||||
import { SyncLogoIcon } from "@ui/assets/icons"
|
||||
|
|
@ -199,7 +204,7 @@ interface QuickNoteProps {
|
|||
|
||||
interface HighlightsProps {
|
||||
items: HighlightItem[]
|
||||
onChat: (seed: string) => void
|
||||
onChat: (highlightContent: string, userReply: string) => void
|
||||
onShowRelated: (query: string) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
|
@ -923,6 +928,10 @@ const DocumentCard = memo(
|
|||
}) => {
|
||||
const canSelect =
|
||||
!isTemporaryId(document.id) && !isTemporaryId(document.customId)
|
||||
const pluginDocument = useMemo(
|
||||
() => parsePluginDocument(document),
|
||||
[document],
|
||||
)
|
||||
const [rotation, setRotation] = useState({ rotateX: 0, rotateY: 0 })
|
||||
const cardRef = useRef<HTMLButtonElement>(null)
|
||||
const [ogData, setOgData] = useState<OgData | null>(null)
|
||||
|
|
@ -1054,7 +1063,11 @@ const DocumentCard = memo(
|
|||
{isSelectionMode && isSelected && (
|
||||
<div className="absolute inset-0 bg-[rgba(75,160,250,0.25)] rounded-[22px] z-1 pointer-events-none" />
|
||||
)}
|
||||
<ContentPreview document={document} ogData={ogData} />
|
||||
<ContentPreview
|
||||
document={document}
|
||||
ogData={ogData}
|
||||
parsed={pluginDocument}
|
||||
/>
|
||||
{!(
|
||||
document.type === "image" ||
|
||||
document.type === "notion_doc" ||
|
||||
|
|
@ -1129,11 +1142,20 @@ const DocumentCard = memo(
|
|||
"text-[11px] text-[#737373] line-clamp-1",
|
||||
)}
|
||||
>
|
||||
{new Date(document.createdAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
{(() => {
|
||||
const badge =
|
||||
pluginDocument?.kind === "claude-code-doc"
|
||||
? claudeCodeTokenBadge(document)
|
||||
: null
|
||||
const date = new Date(
|
||||
document.createdAt,
|
||||
).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
return badge ? `${badge} · ${date}` : date
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1149,9 +1171,11 @@ DocumentCard.displayName = "DocumentCard"
|
|||
function ContentPreview({
|
||||
document,
|
||||
ogData,
|
||||
parsed,
|
||||
}: {
|
||||
document: DocumentWithMemories
|
||||
ogData?: OgData | null
|
||||
parsed?: ParsedPluginDocument | null
|
||||
}) {
|
||||
if (
|
||||
document.url?.includes("https://docs.googleapis.com/v1/documents") ||
|
||||
|
|
@ -1175,11 +1199,11 @@ function ContentPreview({
|
|||
document.url?.includes("x.com/") ||
|
||||
document.url?.includes("twitter.com/")
|
||||
) {
|
||||
return <NotePreview document={document} />
|
||||
return <NotePreview document={document} parsed={parsed} />
|
||||
}
|
||||
|
||||
if (document.source === "mcp") {
|
||||
return <McpPreview document={document} />
|
||||
return <McpPreview document={document} parsed={parsed} />
|
||||
}
|
||||
|
||||
if (isYouTubeUrl(document.url)) {
|
||||
|
|
@ -1204,5 +1228,5 @@ function ContentPreview({
|
|||
}
|
||||
|
||||
// Default to Note
|
||||
return <NotePreview document={document} />
|
||||
return <NotePreview document={document} parsed={parsed} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export function XBookmarksDetailView({ onBack }: XBookmarksDetailViewProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 w-full p-8">
|
||||
<div className="flex flex-col flex-1 w-full p-5 sm:p-8">
|
||||
<div className="mx-auto w-full max-w-3xl flex flex-col flex-1">
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
|
|
@ -63,13 +63,13 @@ export function XBookmarksDetailView({ onBack }: XBookmarksDetailViewProps) {
|
|||
need, right when you need it.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-6 max-w-5xl w-full">
|
||||
<div className="grid w-full max-w-5xl grid-cols-1 gap-3 mb-6 sm:grid-cols-3 sm:gap-4">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.number}
|
||||
className="flex flex-col items-center text-center bg-[#080B0F] p-3 rounded-[10px]"
|
||||
className="flex items-center gap-3 rounded-[10px] bg-[#080B0F] p-3 sm:flex-col sm:items-center sm:gap-0 sm:text-center"
|
||||
>
|
||||
<div className="rounded-2xl p-6 mb-3 w-full aspect-4/4 flex items-center justify-center relative overflow-hidden">
|
||||
<div className="relative flex size-24 shrink-0 items-center justify-center overflow-hidden rounded-2xl sm:mb-3 sm:h-auto sm:w-full sm:aspect-square">
|
||||
<Image
|
||||
src={step.image}
|
||||
alt={`Step ${step.number}`}
|
||||
|
|
@ -78,15 +78,15 @@ export function XBookmarksDetailView({ onBack }: XBookmarksDetailViewProps) {
|
|||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start">
|
||||
<div className="mb-2">
|
||||
<div className="flex min-w-0 flex-col items-start justify-start">
|
||||
<div className="mb-1 sm:mb-2">
|
||||
<span className="text-white text-sm font-medium">
|
||||
Step {step.number}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[#8B8B8B] text-sm text-start",
|
||||
"text-[#8B8B8B] text-sm text-start leading-snug",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,45 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useMemo, useEffect } from "react"
|
||||
import Image from "next/image"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
|
||||
import { cn } from "@lib/utils"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon, Search, Check } from "lucide-react"
|
||||
import { Button } from "@ui/components/button"
|
||||
import {
|
||||
XIcon,
|
||||
Search,
|
||||
FolderIcon,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
Trash2,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Loader,
|
||||
} from "lucide-react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import { authClient } from "@lib/auth"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
import type { ContainerTagListType } from "@lib/types"
|
||||
import {
|
||||
compareSpacesUserFirst,
|
||||
spaceSelectorDisplayName,
|
||||
} from "@/lib/ingest-auto-space"
|
||||
import {
|
||||
detectPluginSpace,
|
||||
pluginInitial,
|
||||
type PluginSpaceInfo,
|
||||
} from "@/lib/plugin-space"
|
||||
import { usePluginSpaceMeta } from "@/hooks/use-plugin-space-meta"
|
||||
import {
|
||||
PLUGIN_CATALOG,
|
||||
spacePluginIdToCatalogId,
|
||||
type PluginInfo,
|
||||
} from "@/lib/plugin-catalog"
|
||||
import { InstallSteps, PillButton } from "./integrations/install-steps"
|
||||
|
||||
interface SelectSpacesModalProps {
|
||||
isOpen: boolean
|
||||
|
|
@ -20,7 +47,29 @@ interface SelectSpacesModalProps {
|
|||
selectedProjects: string[]
|
||||
onApply: (selected: string[]) => void
|
||||
projects: ContainerTagListType[]
|
||||
singleSelect?: boolean
|
||||
recents?: string[]
|
||||
showNewSpace?: boolean
|
||||
onNewSpace?: () => void
|
||||
enableDelete?: boolean
|
||||
onDeleteRequest?: (project: {
|
||||
id: string
|
||||
name: string
|
||||
containerTag: string
|
||||
}) => void
|
||||
}
|
||||
|
||||
type CategoryId =
|
||||
| "all"
|
||||
| "my"
|
||||
| `plugin:${PluginSpaceInfo["pluginId"]}`
|
||||
| `discover:${string}`
|
||||
|
||||
type Category = {
|
||||
id: CategoryId
|
||||
label: string
|
||||
iconSrc: string | null
|
||||
emoji: string | null
|
||||
count: number
|
||||
}
|
||||
|
||||
export function SelectSpacesModal({
|
||||
|
|
@ -29,51 +78,26 @@ export function SelectSpacesModal({
|
|||
selectedProjects,
|
||||
onApply,
|
||||
projects,
|
||||
singleSelect = false,
|
||||
recents,
|
||||
showNewSpace = false,
|
||||
onNewSpace,
|
||||
enableDelete = false,
|
||||
onDeleteRequest,
|
||||
}: SelectSpacesModalProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [localSelection, setLocalSelection] =
|
||||
useState<string[]>(selectedProjects)
|
||||
const currentSelection = selectedProjects[0] ?? ""
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalSelection(selectedProjects)
|
||||
}
|
||||
}, [isOpen, selectedProjects])
|
||||
const pluginTags = useMemo(
|
||||
() =>
|
||||
projects
|
||||
.filter((p) => !!detectPluginSpace(p.containerTag))
|
||||
.map((p) => p.containerTag),
|
||||
[projects],
|
||||
)
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
onClose()
|
||||
setSearchQuery("")
|
||||
setLocalSelection(selectedProjects)
|
||||
}
|
||||
}
|
||||
const pluginMetaMap = usePluginSpaceMeta(pluginTags)
|
||||
|
||||
const handleToggle = (containerTag: string) => {
|
||||
if (singleSelect) {
|
||||
setLocalSelection([containerTag])
|
||||
return
|
||||
}
|
||||
setLocalSelection((prev) => {
|
||||
if (prev.includes(containerTag)) {
|
||||
return prev.filter((tag) => tag !== containerTag)
|
||||
}
|
||||
return [...prev, containerTag]
|
||||
})
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(localSelection)
|
||||
setSearchQuery("")
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose()
|
||||
setSearchQuery("")
|
||||
setLocalSelection(selectedProjects)
|
||||
}
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
const allSpaces = useMemo(() => {
|
||||
const defaultSpace = {
|
||||
id: "default",
|
||||
name: "My Space",
|
||||
|
|
@ -84,28 +108,363 @@ export function SelectSpacesModal({
|
|||
createdAt: "",
|
||||
updatedAt: "",
|
||||
} as ContainerTagListType
|
||||
|
||||
const rest = projects
|
||||
.filter((p) => p.containerTag !== DEFAULT_PROJECT_ID)
|
||||
.sort(compareSpacesUserFirst)
|
||||
return [defaultSpace, ...rest]
|
||||
}, [projects])
|
||||
|
||||
const allSpaces = [defaultSpace, ...rest]
|
||||
if (!searchQuery.trim()) {
|
||||
return allSpaces
|
||||
const { categories, connectedCatalogIds } = useMemo<{
|
||||
categories: Category[]
|
||||
connectedCatalogIds: Set<string>
|
||||
}>(() => {
|
||||
const pluginCounts = new Map<
|
||||
PluginSpaceInfo["pluginId"],
|
||||
{ label: string; iconSrc: string | null; count: number }
|
||||
>()
|
||||
let myCount = 0
|
||||
for (const p of allSpaces) {
|
||||
const plugin = detectPluginSpace(p.containerTag)
|
||||
if (plugin) {
|
||||
const prev = pluginCounts.get(plugin.pluginId)
|
||||
pluginCounts.set(plugin.pluginId, {
|
||||
label: plugin.label,
|
||||
iconSrc: plugin.iconSrc,
|
||||
count: (prev?.count ?? 0) + 1,
|
||||
})
|
||||
} else {
|
||||
myCount += 1
|
||||
}
|
||||
}
|
||||
const pluginCats: Category[] = Array.from(pluginCounts.entries())
|
||||
.map(([id, info]) => ({
|
||||
id: `plugin:${id}` as CategoryId,
|
||||
label: info.label,
|
||||
iconSrc: info.iconSrc,
|
||||
emoji: null,
|
||||
count: info.count,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label))
|
||||
const connectedIds = new Set<string>()
|
||||
for (const pluginId of pluginCounts.keys()) {
|
||||
const catalogId = spacePluginIdToCatalogId(pluginId)
|
||||
if (catalogId) connectedIds.add(catalogId)
|
||||
}
|
||||
return {
|
||||
categories: [
|
||||
{
|
||||
id: "all",
|
||||
label: "All Spaces",
|
||||
iconSrc: null,
|
||||
emoji: null,
|
||||
count: allSpaces.length,
|
||||
},
|
||||
{
|
||||
id: "my",
|
||||
label: "My Spaces",
|
||||
iconSrc: null,
|
||||
emoji: "📁",
|
||||
count: myCount,
|
||||
},
|
||||
...pluginCats,
|
||||
],
|
||||
connectedCatalogIds: connectedIds,
|
||||
}
|
||||
}, [allSpaces])
|
||||
|
||||
const defaultCategory = useMemo<CategoryId>(() => {
|
||||
if (!currentSelection) return "all"
|
||||
const plugin = detectPluginSpace(currentSelection)
|
||||
if (plugin) return `plugin:${plugin.pluginId}`
|
||||
return "my"
|
||||
}, [currentSelection])
|
||||
|
||||
const [activeCategory, setActiveCategory] =
|
||||
useState<CategoryId>(defaultCategory)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setActiveCategory(defaultCategory)
|
||||
}, [isOpen, defaultCategory])
|
||||
|
||||
const { org } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const [connectingPluginId, setConnectingPluginId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [newKey, setNewKey] = useState<{
|
||||
pluginId: string
|
||||
key: string
|
||||
} | null>(null)
|
||||
|
||||
const { data: availablePluginsData } = useQuery({
|
||||
queryKey: ["plugins"],
|
||||
queryFn: async () => {
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const res = await fetch(`${API_URL}/v3/auth/plugins`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to fetch plugins")
|
||||
return (await res.json()) as { plugins: string[] }
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
enabled: isOpen,
|
||||
})
|
||||
|
||||
const { data: apiKeys = [] } = useQuery({
|
||||
queryKey: ["api-keys", org?.id],
|
||||
enabled: isOpen && !!org?.id,
|
||||
queryFn: async () => {
|
||||
if (!org?.id) return []
|
||||
const data = await authClient.apiKey.list({
|
||||
fetchOptions: { query: { metadata: { organizationId: org.id } } },
|
||||
})
|
||||
return data.filter((key) => key.metadata?.organizationId === org.id)
|
||||
},
|
||||
})
|
||||
|
||||
const apiKeyConnectedIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
for (const key of apiKeys) {
|
||||
if (!key.metadata) continue
|
||||
try {
|
||||
const metadata =
|
||||
typeof key.metadata === "string"
|
||||
? (JSON.parse(key.metadata) as {
|
||||
sm_type?: string
|
||||
sm_client?: string
|
||||
})
|
||||
: (key.metadata as { sm_type?: string; sm_client?: string })
|
||||
if (metadata.sm_type === "plugin_auth" && metadata.sm_client) {
|
||||
ids.add(metadata.sm_client)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return ids
|
||||
}, [apiKeys])
|
||||
|
||||
const discoverCategories = useMemo<Category[]>(() => {
|
||||
const availableIds =
|
||||
availablePluginsData?.plugins ?? Object.keys(PLUGIN_CATALOG)
|
||||
return availableIds
|
||||
.filter((id) => !!PLUGIN_CATALOG[id])
|
||||
.filter(
|
||||
(id) => !apiKeyConnectedIds.has(id) && !connectedCatalogIds.has(id),
|
||||
)
|
||||
.map((id) => {
|
||||
const info = PLUGIN_CATALOG[id] as PluginInfo
|
||||
return {
|
||||
id: `discover:${id}` as CategoryId,
|
||||
label: info.name,
|
||||
iconSrc: info.icon,
|
||||
emoji: null,
|
||||
count: 0,
|
||||
}
|
||||
})
|
||||
}, [availablePluginsData, apiKeyConnectedIds, connectedCatalogIds])
|
||||
|
||||
const connectMutation = useMutation({
|
||||
mutationFn: async (pluginId: string) => {
|
||||
const API_URL =
|
||||
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
|
||||
const params = new URLSearchParams({ client: pluginId })
|
||||
const res = await fetch(`${API_URL}/v3/auth/key?${params}`, {
|
||||
credentials: "include",
|
||||
})
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
throw new Error(
|
||||
"This plugin requires a Pro plan. Hermes is available on the Free plan.",
|
||||
)
|
||||
}
|
||||
const errorData = (await res.json().catch(() => ({}))) as {
|
||||
message?: string
|
||||
}
|
||||
throw new Error(errorData.message || "Failed to create plugin key")
|
||||
}
|
||||
return (await res.json()) as { key: string }
|
||||
},
|
||||
onMutate: (pluginId) => setConnectingPluginId(pluginId),
|
||||
onError: (err) => {
|
||||
toast.error("Failed to connect plugin", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
setConnectingPluginId(null)
|
||||
queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] })
|
||||
},
|
||||
onSuccess: (data, pluginId) => {
|
||||
setNewKey({ pluginId, key: data.key })
|
||||
toast.success("Plugin connected!")
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setNewKey(null)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
onClose()
|
||||
setSearchQuery("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (containerTag: string) => {
|
||||
onApply([containerTag])
|
||||
setSearchQuery("")
|
||||
}
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
const byCategory = allSpaces.filter((p) => {
|
||||
if (activeCategory === "all") return true
|
||||
const plugin = detectPluginSpace(p.containerTag)
|
||||
if (activeCategory === "my") return !plugin
|
||||
return plugin && `plugin:${plugin.pluginId}` === activeCategory
|
||||
})
|
||||
if (!searchQuery.trim()) return byCategory
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
return allSpaces.filter(
|
||||
(p) =>
|
||||
return byCategory.filter((p) => {
|
||||
const plugin = detectPluginSpace(p.containerTag)
|
||||
const projectName = pluginMetaMap.get(p.containerTag)?.projectName
|
||||
return (
|
||||
p.containerTag.toLowerCase().includes(query) ||
|
||||
(p.name ?? "").toLowerCase().includes(query),
|
||||
(p.name ?? "").toLowerCase().includes(query) ||
|
||||
(plugin?.label.toLowerCase().includes(query) ?? false) ||
|
||||
(plugin?.projectId?.toLowerCase().includes(query) ?? false) ||
|
||||
(projectName?.toLowerCase().includes(query) ?? false)
|
||||
)
|
||||
})
|
||||
}, [allSpaces, activeCategory, searchQuery, pluginMetaMap])
|
||||
|
||||
const recentProjects = useMemo<ContainerTagListType[]>(() => {
|
||||
if (!recents?.length) return []
|
||||
if (searchQuery.trim()) return []
|
||||
if (activeCategory !== "all") return []
|
||||
const byTag = new Map(allSpaces.map((p) => [p.containerTag, p]))
|
||||
const out: ContainerTagListType[] = []
|
||||
for (const tag of recents) {
|
||||
const p = byTag.get(tag)
|
||||
if (p) out.push(p)
|
||||
if (out.length >= 5) break
|
||||
}
|
||||
return out
|
||||
}, [recents, searchQuery, activeCategory, allSpaces])
|
||||
|
||||
const recentSet = useMemo(
|
||||
() => new Set(recentProjects.map((p) => p.containerTag)),
|
||||
[recentProjects],
|
||||
)
|
||||
|
||||
const mainList = useMemo(
|
||||
() =>
|
||||
recentSet.size > 0
|
||||
? filteredProjects.filter((p) => !recentSet.has(p.containerTag))
|
||||
: filteredProjects,
|
||||
[filteredProjects, recentSet],
|
||||
)
|
||||
|
||||
const renderRow = (project: ContainerTagListType) => {
|
||||
const isSelected = currentSelection === project.containerTag
|
||||
const plugin = detectPluginSpace(project.containerTag)
|
||||
const pluginProjectName = pluginMetaMap.get(
|
||||
project.containerTag,
|
||||
)?.projectName
|
||||
const pluginIdLabel = pluginProjectName || plugin?.projectId
|
||||
const isDefault = project.containerTag === DEFAULT_PROJECT_ID
|
||||
return (
|
||||
<div
|
||||
key={project.containerTag}
|
||||
className={cn(
|
||||
"group flex min-w-0 max-w-full items-center gap-3 w-full px-3 py-2.5 rounded-[12px] transition-colors",
|
||||
isSelected
|
||||
? "bg-[#14161A] shadow-inside-out"
|
||||
: "hover:bg-[#14161A]/50",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(project.containerTag)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left cursor-pointer focus:outline-none focus:ring-0"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
|
||||
isSelected ? "border-[#4BA0FA]" : "border-[#737373]",
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="w-2 h-2 rounded-full bg-[#4BA0FA]" />
|
||||
)}
|
||||
</div>
|
||||
{plugin ? (
|
||||
plugin.iconSrc ? (
|
||||
<Image
|
||||
src={plugin.iconSrc}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="shrink-0 rounded-[4px]"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="shrink-0 flex items-center justify-center w-5 h-5 rounded-[4px] bg-[#1E232B] text-[#FAFAFA] text-[11px] font-semibold uppercase"
|
||||
aria-hidden
|
||||
>
|
||||
{pluginInitial(plugin.label)}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<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.containerTag}
|
||||
>
|
||||
{plugin ? (
|
||||
<>
|
||||
{plugin.label}
|
||||
{pluginIdLabel && (
|
||||
<span className="ml-1.5 text-[12px] text-[#737373]">
|
||||
· {pluginIdLabel}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
spaceSelectorDisplayName(project, project.containerTag)
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{enableDelete && !isDefault && onDeleteRequest && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDeleteRequest({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
containerTag: project.containerTag,
|
||||
})
|
||||
}}
|
||||
aria-label="Delete space"
|
||||
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-full hover:bg-red-500/15 cursor-pointer focus:outline-none"
|
||||
>
|
||||
<Trash2 className="size-3.5 text-red-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}, [projects, searchQuery])
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"w-[90%]! max-w-[500px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-4 rounded-[22px]",
|
||||
"w-[92%]! max-w-[720px]! border-none bg-[#1B1F24] flex flex-col p-0 gap-0 rounded-[22px] overflow-hidden",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
|
|
@ -114,143 +473,362 @@ export function SelectSpacesModal({
|
|||
}}
|
||||
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">
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
Select Space{!singleSelect && "s"}
|
||||
</p>
|
||||
<p className="text-[#737373] font-medium text-[16px] leading-[1.35]">
|
||||
{singleSelect
|
||||
? "Choose a space for your memory"
|
||||
: "Choose one or more spaces to filter your memories"}
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
className="bg-[#0D121A] size-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"
|
||||
style={{
|
||||
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
>
|
||||
<XIcon stroke="#737373" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-[#737373]" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search spaces..."
|
||||
<div className="flex items-start justify-between gap-4 px-4 pt-4">
|
||||
<div className="pl-1 space-y-1 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
"w-full bg-[#14161A] border border-[rgba(82,89,102,0.2)] pl-10 pr-4 py-3 rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(115,115,115,0.3)]",
|
||||
dmSansClassName(),
|
||||
"font-semibold text-[#fafafa]",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
>
|
||||
Select Space
|
||||
</p>
|
||||
<p className="text-[#737373] font-medium text-[14px] leading-[1.35]">
|
||||
Filter your memories by space
|
||||
</p>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
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"
|
||||
style={{
|
||||
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
}}
|
||||
>
|
||||
<XIcon stroke="#737373" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[300px] overflow-y-auto space-y-1 scrollbar-thin">
|
||||
{filteredProjects.length === 0 ? (
|
||||
<p className="text-center text-[#737373] text-sm py-4">
|
||||
No spaces found
|
||||
</p>
|
||||
) : (
|
||||
filteredProjects.map((project) => {
|
||||
const isSelected = localSelection.includes(project.containerTag)
|
||||
<div className="mt-4 flex min-h-[420px] gap-3 px-4 pb-4">
|
||||
<div className="w-[200px] shrink-0 overflow-y-auto scrollbar-thin pr-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
{categories.map((category) => {
|
||||
const isActive = activeCategory === category.id
|
||||
return (
|
||||
<button
|
||||
key={project.containerTag}
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => handleToggle(project.containerTag)}
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
className={cn(
|
||||
"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",
|
||||
"flex items-center gap-2.5 px-3 py-2 rounded-[12px] text-left transition-colors cursor-pointer focus:outline-none focus:ring-0",
|
||||
isActive
|
||||
? "bg-[#14161A] shadow-inside-out text-[#fafafa]"
|
||||
: "text-[#A1A1AA] hover:bg-[#14161A]/50 hover:text-[#fafafa]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
{singleSelect ? (
|
||||
<div
|
||||
className={cn(
|
||||
"size-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
|
||||
isSelected ? "border-blue-500" : "border-[#737373]",
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="size-2.5 rounded-full bg-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"size-5 rounded-md border-2 flex items-center justify-center shrink-0 transition-colors",
|
||||
isSelected
|
||||
? "bg-blue-500 border-blue-500"
|
||||
: "border-[#737373]",
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="size-3 text-white" />}
|
||||
</div>
|
||||
)}
|
||||
<span className="shrink-0 text-lg">
|
||||
{project.emoji || "📁"}
|
||||
<span className="shrink-0 w-5 h-5 flex items-center justify-center">
|
||||
{category.id === "all" ? (
|
||||
<LayoutGrid
|
||||
className={cn(
|
||||
"size-4",
|
||||
isActive ? "text-[#fafafa]" : "text-[#737373]",
|
||||
)}
|
||||
/>
|
||||
) : category.iconSrc ? (
|
||||
<Image
|
||||
src={category.iconSrc}
|
||||
alt=""
|
||||
width={18}
|
||||
height={18}
|
||||
className="rounded-[3px]"
|
||||
aria-hidden
|
||||
/>
|
||||
) : category.emoji ? (
|
||||
<span className="text-base">{category.emoji}</span>
|
||||
) : category.id.startsWith("plugin:") ? (
|
||||
<span
|
||||
className="w-[18px] h-[18px] flex items-center justify-center rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase"
|
||||
aria-hidden
|
||||
>
|
||||
{pluginInitial(category.label)}
|
||||
</span>
|
||||
) : (
|
||||
<FolderIcon
|
||||
className={cn(
|
||||
"size-4",
|
||||
isActive ? "text-[#fafafa]" : "text-[#737373]",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</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 className="flex-1 min-w-0 truncate text-[14px] font-medium">
|
||||
{category.label}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
|
||||
{category.count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
})}
|
||||
|
||||
{discoverCategories.length > 0 && (
|
||||
<>
|
||||
<div className="mt-2 px-3 pt-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
|
||||
Discover
|
||||
</div>
|
||||
{discoverCategories.map((category) => {
|
||||
const isActive = activeCategory === category.id
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2 rounded-[12px] text-left transition-colors cursor-pointer focus:outline-none focus:ring-0",
|
||||
isActive
|
||||
? "bg-[#14161A] shadow-inside-out text-[#fafafa] opacity-100"
|
||||
: "opacity-55 hover:opacity-100 hover:bg-[#14161A]/50 text-[#A1A1AA] hover:text-[#fafafa]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 w-5 h-5 flex items-center justify-center">
|
||||
{category.iconSrc ? (
|
||||
<Image
|
||||
src={category.iconSrc}
|
||||
alt=""
|
||||
width={18}
|
||||
height={18}
|
||||
className="rounded-[3px]"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="w-[18px] h-[18px] flex items-center justify-center rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase"
|
||||
aria-hidden
|
||||
>
|
||||
{pluginInitial(category.label)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate text-[14px] font-medium">
|
||||
{category.label}
|
||||
</span>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-[#737373]" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[#737373] text-sm">
|
||||
{singleSelect
|
||||
? localSelection.length === 0
|
||||
? "No space selected"
|
||||
: "1 space selected"
|
||||
: localSelection.length === 0
|
||||
? "No spaces selected (showing all)"
|
||||
: `${localSelection.length} space${localSelection.length > 1 ? "s" : ""} selected`}
|
||||
</p>
|
||||
<div className="flex items-center gap-[22px]">
|
||||
<div className="flex-1 flex flex-col min-w-0 gap-3">
|
||||
{activeCategory.startsWith("discover:") ? (
|
||||
<DiscoverPanel
|
||||
catalogId={activeCategory.slice("discover:".length)}
|
||||
isConnecting={
|
||||
connectingPluginId ===
|
||||
activeCategory.slice("discover:".length)
|
||||
}
|
||||
newKey={
|
||||
newKey?.pluginId === activeCategory.slice("discover:".length)
|
||||
? newKey.key
|
||||
: null
|
||||
}
|
||||
onConnect={() =>
|
||||
connectMutation.mutate(
|
||||
activeCategory.slice("discover:".length),
|
||||
)
|
||||
}
|
||||
onDismissKey={() => setNewKey(null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-[#737373]" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search spaces..."
|
||||
className={cn(
|
||||
"w-full bg-[#14161A] shadow-inside-out pl-10 pr-4 py-2.5 rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#737373] focus:outline-none",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin max-h-[360px] pr-1">
|
||||
{filteredProjects.length === 0 ? (
|
||||
<p className="text-center text-[#737373] text-sm py-8">
|
||||
No spaces found
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{recentProjects.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
|
||||
<Clock className="size-3" />
|
||||
Recently used
|
||||
</div>
|
||||
{recentProjects.map(renderRow)}
|
||||
<div className="my-1.5 h-px bg-[rgba(82,89,102,0.18)]" />
|
||||
<div className="px-3 pt-0.5 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
|
||||
All spaces
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mainList.map(renderRow)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showNewSpace &&
|
||||
onNewSpace &&
|
||||
!activeCategory.startsWith("discover:") && (
|
||||
<div className="flex items-center justify-end border-t border-[rgba(82,89,102,0.18)] px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
onClick={onNewSpace}
|
||||
className={cn(
|
||||
"text-[#737373] font-medium text-[14px] cursor-pointer transition-colors hover:text-[#999]",
|
||||
"flex items-center gap-2 px-4 py-2 rounded-full text-[13px] font-medium text-[#fafafa] bg-[#14161A] shadow-inside-out hover:bg-[#121820] transition-colors cursor-pointer focus:outline-none focus:ring-0",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
<Plus className="size-4" />
|
||||
New space
|
||||
</button>
|
||||
<Button
|
||||
variant="insideOut"
|
||||
onClick={handleApply}
|
||||
className="px-4 py-[10px] rounded-full"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function DiscoverPanel({
|
||||
catalogId,
|
||||
isConnecting,
|
||||
newKey,
|
||||
onConnect,
|
||||
onDismissKey,
|
||||
}: {
|
||||
catalogId: string
|
||||
isConnecting: boolean
|
||||
newKey: string | null
|
||||
onConnect: () => void
|
||||
onDismissKey: () => void
|
||||
}) {
|
||||
const info = PLUGIN_CATALOG[catalogId]
|
||||
if (!info) {
|
||||
return (
|
||||
<p className="text-[#737373] text-sm py-8 text-center">
|
||||
Plugin info unavailable.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const pluginSteps = info.installSteps ?? []
|
||||
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
|
||||
const setupSteps = stepsEmbedKey
|
||||
? pluginSteps
|
||||
: [
|
||||
{
|
||||
title: "Copy your API key",
|
||||
description:
|
||||
"You won't be able to see it again — store it somewhere safe.",
|
||||
code: newKey ?? "sm_...",
|
||||
copyLabel: "API key",
|
||||
secret: true,
|
||||
},
|
||||
...pluginSteps,
|
||||
]
|
||||
const isConnected = !!newKey
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin pr-1 flex flex-col gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[10px] border border-[#1E293B] bg-[#080B0F]">
|
||||
<Image
|
||||
alt={info.name}
|
||||
className="size-7"
|
||||
height={28}
|
||||
src={info.icon}
|
||||
width={28}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"font-semibold text-[16px] text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
{info.name}
|
||||
</p>
|
||||
<p className="text-[13px] text-[#737373] leading-[1.4] mt-1">
|
||||
{info.tagline}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isConnected && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[13px] font-medium text-[#FAFAFA]">
|
||||
Plugin connected — finish setup
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismissKey}
|
||||
className="text-[#737373] hover:text-[#FAFAFA] cursor-pointer"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && (
|
||||
<p className="text-[12px] text-[#737373]">
|
||||
Your API key is shown once. Hover or focus the blurred command to
|
||||
reveal it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"transition-[filter] duration-200",
|
||||
!isConnected && "blur-[6px] pointer-events-none select-none",
|
||||
)}
|
||||
aria-hidden={!isConnected}
|
||||
>
|
||||
<InstallSteps steps={setupSteps} apiKey={newKey ?? undefined} />
|
||||
</div>
|
||||
|
||||
{!isConnected && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
|
||||
<PillButton onClick={onConnect} disabled={isConnecting}>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader className="size-3.5 animate-spin" /> Connecting…
|
||||
</>
|
||||
) : (
|
||||
`Connect ${info.name}`
|
||||
)}
|
||||
</PillButton>
|
||||
{info.docsUrl && (
|
||||
<a
|
||||
href={info.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
dmSans125ClassName(),
|
||||
"flex h-8 min-w-[94px] items-center justify-center gap-1.5 rounded-full px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
|
||||
"text-[12px] font-medium text-[#A1A1AA] sm:text-[14px]",
|
||||
"transition-colors hover:text-[#FAFAFA]",
|
||||
)}
|
||||
>
|
||||
<BookOpen className="size-3.5" /> Docs
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -258,13 +258,14 @@ const SocialButton = ({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="size-8 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
|
||||
className="size-8 shrink-0 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
|
||||
style={{
|
||||
background: "#0D121A",
|
||||
border: "0.875px solid rgba(115, 115, 115, 0.2)",
|
||||
boxShadow: "inset 1.3125px 1.3125px 3.9375px rgba(0, 0, 0, 0.7)",
|
||||
}}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
|
|
@ -387,7 +388,7 @@ export function ShareModal({
|
|||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"w-[90%]! max-w-[706px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-4 rounded-[22px]",
|
||||
"w-[calc(100vw-20px)]! max-w-[706px]! max-h-[min(92dvh,720px)] overflow-y-auto border-none bg-[#1B1F24] flex flex-col p-3 sm:p-4 gap-3 sm:gap-4 rounded-[20px] sm:rounded-[22px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
|
|
@ -396,13 +397,14 @@ export function ShareModal({
|
|||
}}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 min-w-0">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center px-2">
|
||||
<DialogHeader className="flex-1">
|
||||
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem] items-center gap-2 px-1 sm:px-2">
|
||||
<div aria-hidden />
|
||||
<DialogHeader className="min-w-0">
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
"font-semibold text-[#fafafa] text-base text-center",
|
||||
"font-semibold text-[#fafafa] text-sm sm:text-base text-center leading-tight text-balance",
|
||||
dmSans125ClassName(),
|
||||
)}
|
||||
>
|
||||
|
|
@ -411,7 +413,7 @@ export function ShareModal({
|
|||
</DialogHeader>
|
||||
<DialogPrimitive.Close
|
||||
onClick={handleClose}
|
||||
className="bg-[#0D121A] size-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"
|
||||
className="bg-[#0D121A] size-7 justify-self-end 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"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 0.711px 2.842px 0 rgba(0, 0, 0, 0.25), 0.178px 0.178px 0.178px 0 rgba(255, 255, 255, 0.10) inset",
|
||||
|
|
@ -425,7 +427,7 @@ export function ShareModal({
|
|||
{/* Preview area */}
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="relative w-full aspect-[674/505] rounded-[14px] overflow-hidden"
|
||||
className="relative w-full aspect-[674/505] max-h-[48dvh] rounded-[14px] overflow-hidden"
|
||||
style={{
|
||||
boxShadow: "inset 2.42px 2.42px 4.26316px rgba(11, 15, 21, 0.7)",
|
||||
}}
|
||||
|
|
@ -462,9 +464,9 @@ export function ShareModal({
|
|||
</div>
|
||||
|
||||
{/* Bottom controls */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* Theme selectors */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-center gap-2 sm:justify-start">
|
||||
<ThemeButton
|
||||
theme="gradient"
|
||||
isSelected={selectedTheme === "gradient"}
|
||||
|
|
@ -483,12 +485,12 @@ export function ShareModal({
|
|||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="grid w-full grid-cols-[minmax(0,1fr)_2rem_2rem_2rem_2rem] items-center gap-2 sm:flex sm:w-auto">
|
||||
<Button
|
||||
onClick={handleCopySnapshot}
|
||||
disabled={isCopying}
|
||||
className={cn(
|
||||
"h-8 px-3 rounded-full text-sm font-normal gap-1.5",
|
||||
"h-8 min-w-0 justify-center px-3 rounded-full text-sm font-normal gap-1.5",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
|
|
@ -500,11 +502,11 @@ export function ShareModal({
|
|||
{copied ? (
|
||||
<>
|
||||
<Check className="size-4 text-green-500" />
|
||||
<span className="text-green-500">Copied!</span>
|
||||
<span className="truncate text-green-500">Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-white">Copy snapshot</span>
|
||||
<span className="truncate text-white">Copy snapshot</span>
|
||||
<Copy className="size-4 text-[#737373]" />
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { useState, useMemo, useEffect, useCallback } from "react"
|
||||
import Image from "next/image"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { cn } from "@lib/utils"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
|
||||
import { DEFAULT_PROJECT_ID } from "@lib/constants"
|
||||
import { ChevronDown, Plus, Trash2, XIcon, Loader2, Layers } from "lucide-react"
|
||||
import { XIcon, Loader2 } from "lucide-react"
|
||||
import type { ContainerTagListType } from "@lib/types"
|
||||
import { AddSpaceModal } from "./add-space-modal"
|
||||
import { SelectSpacesModal } from "./select-spaces-modal"
|
||||
|
|
@ -12,13 +15,6 @@ import { useProjectMutations } from "@/hooks/use-project-mutations"
|
|||
import { useContainerTags } from "@/hooks/use-container-tags"
|
||||
import { motion } from "motion/react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -33,50 +29,79 @@ import {
|
|||
SelectValue,
|
||||
} from "@repo/ui/components/select"
|
||||
import { Button } from "@repo/ui/components/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import {
|
||||
compareSpacesUserFirst,
|
||||
spaceSelectorDisplayName,
|
||||
} from "@/lib/ingest-auto-space"
|
||||
import { detectPluginSpace, pluginInitial } from "@/lib/plugin-space"
|
||||
import { usePluginSpaceMeta } from "@/hooks/use-plugin-space-meta"
|
||||
|
||||
export interface SpaceSelectorProps {
|
||||
selectedProjects: string[]
|
||||
onValueChange: (containerTags: string[]) => void
|
||||
variant?: "default" | "insideOut"
|
||||
showChevron?: boolean
|
||||
triggerClassName?: string
|
||||
contentClassName?: string
|
||||
showNewSpace?: boolean
|
||||
enableDelete?: boolean
|
||||
compact?: boolean
|
||||
singleSelect?: boolean
|
||||
}
|
||||
|
||||
const triggerVariants = {
|
||||
default:
|
||||
"h-10 min-h-10 shrink-0 rounded-full border border-[#161F2C] bg-muted px-3 gap-2 " +
|
||||
"hover:bg-white/5 " +
|
||||
"data-[state=open]:border-[#2261CA33] data-[state=open]:bg-[#00173C]/35 " +
|
||||
"hover:bg-white/5 hover:border-[#2261CA33] " +
|
||||
"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]",
|
||||
}
|
||||
|
||||
const RECENTS_KEY = "nova:space-selector:recents"
|
||||
const RECENTS_MAX = 10
|
||||
|
||||
function readRecents(): string[] {
|
||||
if (typeof window === "undefined") return []
|
||||
try {
|
||||
const raw = window.localStorage.getItem(RECENTS_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((x) => typeof x === "string")
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeRecents(tags: string[]) {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
window.localStorage.setItem(RECENTS_KEY, JSON.stringify(tags))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n < 1000) return String(n)
|
||||
if (n < 10_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`
|
||||
if (n < 1_000_000) return `${Math.floor(n / 1000)}k`
|
||||
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`
|
||||
}
|
||||
|
||||
export function SpaceSelector({
|
||||
selectedProjects,
|
||||
onValueChange,
|
||||
variant = "default",
|
||||
showChevron = false,
|
||||
triggerClassName,
|
||||
contentClassName,
|
||||
showNewSpace = true,
|
||||
enableDelete = false,
|
||||
compact = false,
|
||||
singleSelect = false,
|
||||
}: SpaceSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [showSelectSpacesModal, setShowSelectSpacesModal] = useState(false)
|
||||
const [recents, setRecents] = useState<string[]>([])
|
||||
const [deleteDialog, setDeleteDialog] = useState<{
|
||||
open: boolean
|
||||
project: { id: string; name: string; containerTag: string } | null
|
||||
|
|
@ -90,90 +115,114 @@ export function SpaceSelector({
|
|||
})
|
||||
|
||||
const { deleteProjectMutation } = useProjectMutations()
|
||||
|
||||
const { allProjects, isLoading } = useContainerTags()
|
||||
|
||||
const sortedOtherSpaces = useMemo(
|
||||
useEffect(() => {
|
||||
setRecents(readRecents())
|
||||
}, [])
|
||||
|
||||
const activeTag = selectedProjects[0] ?? DEFAULT_PROJECT_ID
|
||||
const { data: spaceCountData } = useQuery({
|
||||
queryKey: ["space-selector-count", activeTag],
|
||||
queryFn: async (): Promise<number> => {
|
||||
const response = await $fetch("@post/documents/documents", {
|
||||
body: {
|
||||
page: 1,
|
||||
limit: 1,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
containerTags: [activeTag],
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
if (response.error) return 0
|
||||
const data = response.data as {
|
||||
pagination?: { totalItems?: number }
|
||||
} | null
|
||||
return data?.pagination?.totalItems ?? 0
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
enabled: !!activeTag,
|
||||
})
|
||||
|
||||
const pluginTags = useMemo(
|
||||
() =>
|
||||
allProjects
|
||||
.filter(
|
||||
(p: ContainerTagListType) => p.containerTag !== DEFAULT_PROJECT_ID,
|
||||
(p: ContainerTagListType) => !!detectPluginSpace(p.containerTag),
|
||||
)
|
||||
.sort(compareSpacesUserFirst),
|
||||
.map((p: ContainerTagListType) => p.containerTag),
|
||||
[allProjects],
|
||||
)
|
||||
const pluginMetaMap = usePluginSpaceMeta(pluginTags)
|
||||
|
||||
const displayInfo = useMemo(() => {
|
||||
if (selectedProjects.length === 1) {
|
||||
const containerTag = selectedProjects[0] ?? ""
|
||||
if (containerTag === DEFAULT_PROJECT_ID) {
|
||||
return { name: "My Space", emoji: "📁", isMultiple: false }
|
||||
}
|
||||
const found = allProjects.find(
|
||||
(p: ContainerTagListType) => p.containerTag === containerTag,
|
||||
)
|
||||
return {
|
||||
name: spaceSelectorDisplayName(found, containerTag),
|
||||
emoji: found?.emoji || "📁",
|
||||
isMultiple: false,
|
||||
}
|
||||
const displayInfo = useMemo<{
|
||||
name: string
|
||||
emoji: string | null
|
||||
plugin: ReturnType<typeof detectPluginSpace>
|
||||
}>(() => {
|
||||
const containerTag = selectedProjects[0] ?? ""
|
||||
if (!containerTag || containerTag === DEFAULT_PROJECT_ID) {
|
||||
return { name: "My Space", emoji: "📁", plugin: null }
|
||||
}
|
||||
|
||||
if (selectedProjects.length > 1) {
|
||||
return {
|
||||
name: `${selectedProjects.length} spaces`,
|
||||
emoji: null,
|
||||
isMultiple: true,
|
||||
}
|
||||
const found = allProjects.find(
|
||||
(p: ContainerTagListType) => p.containerTag === containerTag,
|
||||
)
|
||||
const plugin = detectPluginSpace(containerTag)
|
||||
const projectName = pluginMetaMap.get(containerTag)?.projectName
|
||||
const idForLabel = projectName || plugin?.projectId
|
||||
return {
|
||||
name: plugin
|
||||
? idForLabel
|
||||
? `${plugin.label} · ${idForLabel}`
|
||||
: plugin.label
|
||||
: spaceSelectorDisplayName(found, containerTag),
|
||||
emoji: found?.emoji || "📁",
|
||||
plugin,
|
||||
}
|
||||
}, [allProjects, selectedProjects, pluginMetaMap])
|
||||
|
||||
return { name: "My Space", emoji: "📁", isMultiple: false }
|
||||
}, [allProjects, selectedProjects])
|
||||
|
||||
const handleSelectSingleSpace = (containerTag: string) => {
|
||||
analytics.spaceSwitched({ space_id: containerTag })
|
||||
onValueChange([containerTag])
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleOpenSelectSpaces = () => {
|
||||
setIsOpen(false)
|
||||
setShowSelectSpacesModal(true)
|
||||
}
|
||||
|
||||
const handleSelectSpacesApply = (selected: string[]) => {
|
||||
if (selected.length > 0) {
|
||||
analytics.spaceSwitched({
|
||||
space_id:
|
||||
selected.length === 1 ? (selected[0] ?? "unknown") : "multiple",
|
||||
})
|
||||
}
|
||||
onValueChange(selected)
|
||||
setShowSelectSpacesModal(false)
|
||||
}
|
||||
|
||||
const handleNewSpace = () => {
|
||||
setIsOpen(false)
|
||||
setShowCreateDialog(true)
|
||||
}
|
||||
|
||||
const handleDeleteClick = (
|
||||
e: React.MouseEvent,
|
||||
project: { id: string; name: string; containerTag: string },
|
||||
) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
setDeleteDialog({
|
||||
open: true,
|
||||
project,
|
||||
action: "move",
|
||||
targetProjectId: "",
|
||||
const pushRecent = useCallback((tag: string) => {
|
||||
setRecents((prev) => {
|
||||
const next = [tag, ...prev.filter((t) => t !== tag)].slice(0, RECENTS_MAX)
|
||||
writeRecents(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSelectSpacesApply = useCallback(
|
||||
(selected: string[]) => {
|
||||
const next = selected.slice(0, 1)
|
||||
if (next[0]) {
|
||||
analytics.spaceSwitched({ space_id: next[0] })
|
||||
pushRecent(next[0])
|
||||
}
|
||||
onValueChange(next)
|
||||
setShowSelectSpacesModal(false)
|
||||
},
|
||||
[onValueChange, pushRecent],
|
||||
)
|
||||
|
||||
const handleNewSpace = useCallback(() => {
|
||||
setShowSelectSpacesModal(false)
|
||||
setShowCreateDialog(true)
|
||||
}, [])
|
||||
|
||||
const handleDeleteRequest = useCallback(
|
||||
(project: { id: string; name: string; containerTag: string }) => {
|
||||
setShowSelectSpacesModal(false)
|
||||
setDeleteDialog({
|
||||
open: true,
|
||||
project,
|
||||
action: "move",
|
||||
targetProjectId: "",
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!deleteDialog.project) return
|
||||
|
||||
deleteProjectMutation.mutate(
|
||||
{
|
||||
projectId: deleteDialog.project.id,
|
||||
|
|
@ -191,7 +240,6 @@ export function SpaceSelector({
|
|||
action: "move",
|
||||
targetProjectId: "",
|
||||
})
|
||||
setIsOpen(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
@ -212,36 +260,31 @@ export function SpaceSelector({
|
|||
p.id !== deleteDialog.project?.id &&
|
||||
p.containerTag !== deleteDialog.project?.containerTag,
|
||||
)
|
||||
|
||||
const defaultProject = allProjects.find(
|
||||
(p: ContainerTagListType) => p.containerTag === DEFAULT_PROJECT_ID,
|
||||
)
|
||||
|
||||
const isDefaultProjectBeingDeleted =
|
||||
deleteDialog.project?.containerTag === DEFAULT_PROJECT_ID
|
||||
|
||||
if (defaultProject && !isDefaultProjectBeingDeleted) {
|
||||
const defaultProjectIncluded = filtered.some(
|
||||
(p: ContainerTagListType) => p.containerTag === DEFAULT_PROJECT_ID,
|
||||
)
|
||||
if (!defaultProjectIncluded) {
|
||||
return [defaultProject, ...filtered]
|
||||
}
|
||||
if (!defaultProjectIncluded) return [defaultProject, ...filtered]
|
||||
}
|
||||
|
||||
return filtered
|
||||
return filtered.sort(compareSpacesUserFirst)
|
||||
}, [allProjects, deleteDialog.project])
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSelectSpacesModal(true)}
|
||||
aria-label={
|
||||
isLoading
|
||||
? "Loading spaces"
|
||||
: `Space: ${displayInfo.name}. Open menu to switch.`
|
||||
: `Space: ${displayInfo.name}. Open selector.`
|
||||
}
|
||||
className={cn(
|
||||
"flex min-w-0 max-w-full items-center cursor-pointer transition-colors",
|
||||
|
|
@ -251,15 +294,30 @@ export function SpaceSelector({
|
|||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
{displayInfo.isMultiple ? (
|
||||
<Layers
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
variant === "insideOut" ? "text-white" : "text-[#737373]",
|
||||
compact ? "size-3.5" : "size-4",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{displayInfo.plugin ? (
|
||||
displayInfo.plugin.iconSrc ? (
|
||||
<Image
|
||||
src={displayInfo.plugin.iconSrc}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className={cn(
|
||||
"shrink-0 rounded-[3px]",
|
||||
compact ? "size-3.5" : "size-4",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 flex items-center justify-center rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase",
|
||||
compact ? "size-3.5" : "size-4",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{pluginInitial(displayInfo.plugin.label)}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span
|
||||
className="shrink-0 text-sm font-bold tracking-[-0.98px]"
|
||||
|
|
@ -279,147 +337,30 @@ export function SpaceSelector({
|
|||
{isLoading ? "…" : displayInfo.name}
|
||||
</span>
|
||||
)}
|
||||
{!compact && spaceCountData !== undefined && spaceCountData > 0 && (
|
||||
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
|
||||
· {formatCount(spaceCountData)}
|
||||
</span>
|
||||
)}
|
||||
{compact && (
|
||||
<span className="sr-only">
|
||||
{isLoading ? "Loading" : displayInfo.name}
|
||||
</span>
|
||||
)}
|
||||
{showChevron && (
|
||||
<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={compact ? "end" : "start"}
|
||||
alignOffset={0}
|
||||
sideOffset={compact ? 8 : 4}
|
||||
collisionPadding={compact ? 16 : 8}
|
||||
className={cn(
|
||||
"min-w-[200px] overflow-hidden p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
|
||||
compact
|
||||
? "w-[min(calc(100vw-3rem),18rem)]"
|
||||
: "max-w-[min(calc(100vw-2rem),20rem)]",
|
||||
dmSansClassName(),
|
||||
contentClassName,
|
||||
)}
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSelectSingleSpace(DEFAULT_PROJECT_ID)}
|
||||
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",
|
||||
selectedProjects.length === 1 &&
|
||||
selectedProjects[0] === DEFAULT_PROJECT_ID
|
||||
? "bg-[#293952]/40"
|
||||
: "opacity-60 hover:opacity-100 hover:bg-[#293952]/40",
|
||||
)}
|
||||
>
|
||||
<span className="font-bold tracking-[-0.98px]">📁</span>
|
||||
<span className="min-w-0 flex-1 truncate">My Space</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{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}
|
||||
>
|
||||
{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="shrink-0 bg-[#2E3033]" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenSelectSpaces}
|
||||
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%)",
|
||||
}}
|
||||
>
|
||||
<Layers className="size-4" />
|
||||
<span>Select Space{!singleSelect && "s"}</span>
|
||||
</button>
|
||||
|
||||
{showNewSpace && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewSpace}
|
||||
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%)",
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
<span>New Space</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={dmSansClassName()}>
|
||||
Switch space
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<AddSpaceModal
|
||||
isOpen={showCreateDialog}
|
||||
onClose={() => setShowCreateDialog(false)}
|
||||
onCreated={(containerTag) => onValueChange([containerTag])}
|
||||
onCreated={(containerTag) => {
|
||||
pushRecent(containerTag)
|
||||
onValueChange([containerTag])
|
||||
}}
|
||||
/>
|
||||
|
||||
<SelectSpacesModal
|
||||
|
|
@ -428,7 +369,11 @@ export function SpaceSelector({
|
|||
selectedProjects={selectedProjects}
|
||||
onApply={handleSelectSpacesApply}
|
||||
projects={allProjects}
|
||||
singleSelect={singleSelect}
|
||||
recents={recents}
|
||||
showNewSpace={showNewSpace}
|
||||
onNewSpace={handleNewSpace}
|
||||
enableDelete={enableDelete}
|
||||
onDeleteRequest={handleDeleteRequest}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
|
|
@ -456,10 +401,7 @@ export function SpaceSelector({
|
|||
showCloseButton={false}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div
|
||||
id="delete-dialog-header"
|
||||
className="flex justify-between items-start gap-4"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="pl-1 space-y-1 flex-1">
|
||||
<DialogTitle
|
||||
className={cn(
|
||||
|
|
@ -478,7 +420,7 @@ export function SpaceSelector({
|
|||
</DialogDescription>
|
||||
</div>
|
||||
<DialogPrimitive.Close
|
||||
className="bg-[#0D121A] size-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"
|
||||
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"
|
||||
style={{
|
||||
boxShadow:
|
||||
"inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
|
||||
|
|
@ -489,9 +431,8 @@ export function SpaceSelector({
|
|||
</DialogPrimitive.Close>
|
||||
</div>
|
||||
|
||||
<div id="delete-dialog-content" className="space-y-3">
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
id="move-option"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDeleteDialog((prev) => ({ ...prev, action: "move" }))
|
||||
|
|
@ -499,26 +440,20 @@ export function SpaceSelector({
|
|||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-[12px] cursor-pointer transition-colors w-full text-left",
|
||||
deleteDialog.action === "move"
|
||||
? "bg-[#14161A] border border-[rgba(82,89,102,0.3)]"
|
||||
: "bg-[#14161A]/50 border border-transparent hover:border-[rgba(82,89,102,0.2)]",
|
||||
? "bg-[#14161A] shadow-inside-out"
|
||||
: "bg-[#14161A]/50 hover:bg-[#14161A]/70",
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
deleteDialog.action === "move"
|
||||
? "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(
|
||||
"size-4 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
deleteDialog.action === "move"
|
||||
? "border-blue-500"
|
||||
? "border-[#4BA0FA]"
|
||||
: "border-[#737373]",
|
||||
)}
|
||||
>
|
||||
{deleteDialog.action === "move" && (
|
||||
<div className="size-2 rounded-full bg-blue-500" />
|
||||
<div className="w-2 h-2 rounded-full bg-[#4BA0FA]" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[#fafafa] text-sm font-medium">
|
||||
|
|
@ -544,13 +479,9 @@ export function SpaceSelector({
|
|||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] h-[45px]",
|
||||
"bg-[#14161A] shadow-inside-out rounded-[12px] text-[#fafafa] text-[14px] h-[45px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
|
||||
}}
|
||||
>
|
||||
<SelectValue placeholder="Select target space" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -559,28 +490,58 @@ export function SpaceSelector({
|
|||
"bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px]",
|
||||
dmSansClassName(),
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08)",
|
||||
}}
|
||||
>
|
||||
{availableTargetProjects.map(
|
||||
(p: ContainerTagListType) => (
|
||||
<SelectItem
|
||||
key={p.id}
|
||||
value={p.id}
|
||||
className="text-[#fafafa] hover:bg-[#1B1F24] cursor-pointer rounded-md"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span>{p.emoji || "📁"}</span>
|
||||
<span className="truncate">
|
||||
{p.containerTag === DEFAULT_PROJECT_ID
|
||||
? "My Space"
|
||||
: spaceSelectorDisplayName(p, p.containerTag)}
|
||||
(p: ContainerTagListType) => {
|
||||
const plugin = detectPluginSpace(p.containerTag)
|
||||
return (
|
||||
<SelectItem
|
||||
key={p.id}
|
||||
value={p.id}
|
||||
className="text-[#fafafa] hover:bg-[#1B1F24] cursor-pointer rounded-md"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
{plugin ? (
|
||||
plugin.iconSrc ? (
|
||||
<Image
|
||||
src={plugin.iconSrc}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
className="shrink-0 rounded-[3px]"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="shrink-0 flex items-center justify-center w-4 h-4 rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase"
|
||||
aria-hidden
|
||||
>
|
||||
{pluginInitial(plugin.label)}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span>{p.emoji || "📁"}</span>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{p.containerTag === DEFAULT_PROJECT_ID ? (
|
||||
"My Space"
|
||||
) : plugin ? (
|
||||
<>
|
||||
{plugin.label}
|
||||
{plugin.projectId && (
|
||||
<span className="ml-1.5 text-[11px] text-[#737373]">
|
||||
· {plugin.projectId}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
spaceSelectorDisplayName(p, p.containerTag)
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
),
|
||||
</SelectItem>
|
||||
)
|
||||
},
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
@ -588,7 +549,6 @@ export function SpaceSelector({
|
|||
)}
|
||||
|
||||
<button
|
||||
id="delete-option"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setDeleteDialog((prev) => ({ ...prev, action: "delete" }))
|
||||
|
|
@ -596,26 +556,20 @@ export function SpaceSelector({
|
|||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-[12px] cursor-pointer transition-colors w-full text-left",
|
||||
deleteDialog.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)]",
|
||||
? "bg-[#14161A] shadow-inside-out"
|
||||
: "bg-[#14161A]/50 hover:bg-[#14161A]/70",
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
deleteDialog.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",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"size-4 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
deleteDialog.action === "delete"
|
||||
? "border-red-500"
|
||||
: "border-[#737373]",
|
||||
)}
|
||||
>
|
||||
{deleteDialog.action === "delete" && (
|
||||
<div className="size-2 rounded-full bg-red-500" />
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[#fafafa] text-sm font-medium">
|
||||
|
|
@ -634,10 +588,7 @@ export function SpaceSelector({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="delete-dialog-footer"
|
||||
className="flex items-center justify-end gap-[22px]"
|
||||
>
|
||||
<div className="flex items-center justify-end gap-[22px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteCancel}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,16 @@
|
|||
--color-surface-base: #0b1119;
|
||||
--color-surface-card: #101822;
|
||||
--color-surface-hover: #131b28;
|
||||
--color-surface-skeleton: #1a2030;
|
||||
--color-surface-border: #263348;
|
||||
|
||||
--color-pill-bg: #0d121a;
|
||||
--color-pill-bg-active: #00173c;
|
||||
--color-pill-border: #161f2c;
|
||||
--color-pill-border-active: #2261ca33;
|
||||
|
||||
--color-brand-accent: #4ba0fa;
|
||||
|
||||
--animate-file-upload-grow: file-upload-grow 6s cubic-bezier(0.22, 1, 0.36, 1)
|
||||
forwards;
|
||||
}
|
||||
|
|
|
|||
89
apps/web/hooks/use-plugin-space-meta.ts
Normal file
89
apps/web/hooks/use-plugin-space-meta.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useQueries } from "@tanstack/react-query"
|
||||
import { $fetch } from "@lib/api"
|
||||
import { useAuth } from "@lib/auth-context"
|
||||
|
||||
export type PluginSpaceMeta = {
|
||||
projectName?: string
|
||||
source?: string
|
||||
lastUpdatedAt?: string
|
||||
}
|
||||
|
||||
type RawDoc = {
|
||||
metadata?: Record<string, unknown> | null
|
||||
updatedAt?: string | null
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
function extractMeta(doc: RawDoc): PluginSpaceMeta {
|
||||
const md = doc?.metadata ?? {}
|
||||
const project =
|
||||
typeof md.project === "string" && md.project.trim()
|
||||
? md.project.trim()
|
||||
: undefined
|
||||
const source =
|
||||
typeof md.sm_source === "string" && md.sm_source.trim()
|
||||
? md.sm_source.trim()
|
||||
: undefined
|
||||
return {
|
||||
projectName: project,
|
||||
source,
|
||||
lastUpdatedAt: doc?.updatedAt ?? doc?.createdAt ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one recent doc per containerTag and pulls plugin metadata
|
||||
* (`metadata.project`, `metadata.sm_source`) so plugin-provisioned spaces
|
||||
* can show the real project name instead of the hash.
|
||||
*/
|
||||
export function usePluginSpaceMeta(
|
||||
containerTags: string[],
|
||||
): Map<string, PluginSpaceMeta> {
|
||||
const { user } = useAuth()
|
||||
|
||||
const uniqueTags = useMemo(
|
||||
() => Array.from(new Set(containerTags.filter(Boolean))).sort(),
|
||||
[containerTags],
|
||||
)
|
||||
|
||||
const results = useQueries({
|
||||
queries: uniqueTags.map((tag) => ({
|
||||
queryKey: ["plugin-space-meta", tag],
|
||||
queryFn: async (): Promise<PluginSpaceMeta | null> => {
|
||||
const response = await $fetch("@post/documents/documents", {
|
||||
body: {
|
||||
page: 1,
|
||||
limit: 1,
|
||||
sort: "createdAt",
|
||||
order: "desc",
|
||||
containerTags: [tag],
|
||||
},
|
||||
disableValidation: true,
|
||||
})
|
||||
if (response.error) return null
|
||||
const data = response.data as unknown as
|
||||
| { documents?: RawDoc[] }
|
||||
| undefined
|
||||
const doc = data?.documents?.[0]
|
||||
if (!doc) return null
|
||||
return extractMeta(doc)
|
||||
},
|
||||
enabled: !!user && !!tag,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
retry: 1,
|
||||
})),
|
||||
})
|
||||
|
||||
return useMemo(() => {
|
||||
const map = new Map<string, PluginSpaceMeta>()
|
||||
uniqueTags.forEach((tag, idx) => {
|
||||
const meta = results[idx]?.data
|
||||
if (meta) map.set(tag, meta)
|
||||
})
|
||||
return map
|
||||
}, [uniqueTags, results])
|
||||
}
|
||||
149
apps/web/lib/plugin-catalog.ts
Normal file
149
apps/web/lib/plugin-catalog.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
export interface InstallStep {
|
||||
title: string
|
||||
description?: string
|
||||
code?: string
|
||||
copyLabel?: string
|
||||
optional?: boolean
|
||||
/** Blur the code block until hovered/focused (e.g. it contains the key). */
|
||||
secret?: boolean
|
||||
}
|
||||
|
||||
export interface PluginInfo {
|
||||
id: string
|
||||
name: string
|
||||
tagline: string
|
||||
icon: string
|
||||
docsUrl?: string
|
||||
/** Steps shown after a key is minted. The literal `sm_...` is replaced
|
||||
* with the freshly generated key when rendered. */
|
||||
installSteps?: InstallStep[]
|
||||
}
|
||||
|
||||
/** Match `FREE_TIER_PLUGIN_IDS` in mono `packages/lib/plugins.ts`. */
|
||||
export const FREE_TIER_PLUGIN_IDS = ["hermes", "codex"]
|
||||
|
||||
export function isFreeTierPlugin(pluginId: string): boolean {
|
||||
return FREE_TIER_PLUGIN_IDS.includes(pluginId)
|
||||
}
|
||||
|
||||
export const PLUGIN_CATALOG: Record<string, PluginInfo> = {
|
||||
claude_code: {
|
||||
id: "claude_code",
|
||||
name: "Claude Code",
|
||||
tagline: "Remembers your conventions, decisions, and project context",
|
||||
icon: "/images/plugins/claude-code.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/claude-code",
|
||||
installSteps: [
|
||||
{
|
||||
title: "Save your API key",
|
||||
description:
|
||||
"Add this to your shell profile so Claude Code can authenticate. This key is shown only once — save it now.",
|
||||
code: 'export SUPERMEMORY_CC_API_KEY="sm_..."',
|
||||
copyLabel: "API key",
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
title: "Install the plugin",
|
||||
description: "Run these commands inside a Claude Code session:",
|
||||
code: "/plugin marketplace add supermemoryai/claude-supermemory\n/plugin install claude-supermemory",
|
||||
},
|
||||
],
|
||||
},
|
||||
codex: {
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
tagline: "Persistent memory for the Codex CLI — free on every plan",
|
||||
icon: "/images/plugins/codex.png",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/codex",
|
||||
installSteps: [
|
||||
{
|
||||
title: "Save your API key",
|
||||
description:
|
||||
"Add this to your shell profile. This key is shown only once — save it now.",
|
||||
code: 'export SUPERMEMORY_CODEX_API_KEY="sm_..."',
|
||||
copyLabel: "API key",
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
title: "Install the hooks",
|
||||
description: "Run this to wire Supermemory into Codex CLI:",
|
||||
code: "npx codex-supermemory@latest install",
|
||||
},
|
||||
],
|
||||
},
|
||||
opencode: {
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
tagline: "Long-term memory for your OpenCode sessions",
|
||||
icon: "/images/plugins/opencode.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/opencode",
|
||||
installSteps: [
|
||||
{
|
||||
title: "Save your API key",
|
||||
description:
|
||||
"Add this to your shell profile. This key is shown only once — save it now.",
|
||||
code: 'export SUPERMEMORY_API_KEY="sm_..."',
|
||||
copyLabel: "API key",
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
title: "Install the plugin",
|
||||
description: "Use --no-tui for non-interactive environments.",
|
||||
code: "bunx opencode-supermemory@latest install",
|
||||
},
|
||||
{
|
||||
title: "Verify your config",
|
||||
description:
|
||||
"Ensure ~/.config/opencode/opencode.jsonc includes the plugin:",
|
||||
code: '{\n "plugin": ["opencode-supermemory"]\n}',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
openclaw: {
|
||||
id: "openclaw",
|
||||
name: "OpenClaw",
|
||||
tagline: "Cross-platform memory across Telegram, Discord, Slack",
|
||||
icon: "/images/plugins/openclaw.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/openclaw",
|
||||
installSteps: [
|
||||
{
|
||||
title: "Install the plugin",
|
||||
description: "Run this in your OpenClaw project:",
|
||||
code: "openclaw plugins install @supermemory/openclaw-supermemory",
|
||||
},
|
||||
{
|
||||
title: "Configure Supermemory",
|
||||
description:
|
||||
"Run the setup command and paste your API key when prompted:",
|
||||
code: "openclaw supermemory setup",
|
||||
},
|
||||
],
|
||||
},
|
||||
hermes: {
|
||||
id: "hermes",
|
||||
name: "Hermes",
|
||||
tagline: "Persistent memory for the Hermes agent — free on every plan",
|
||||
icon: "/images/plugins/hermes.svg",
|
||||
docsUrl: "https://docs.supermemory.ai/integrations/hermes",
|
||||
installSteps: [
|
||||
{
|
||||
title: "Run Hermes memory setup",
|
||||
description:
|
||||
"On the machine where Hermes is deployed, start the memory wizard, choose Supermemory as the provider, and paste your API key when prompted:",
|
||||
code: "hermes memory setup",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const SPACE_TO_CATALOG_ID: Record<string, string> = {
|
||||
"claude-code": "claude_code",
|
||||
codex: "codex",
|
||||
opencode: "opencode",
|
||||
openclaw: "openclaw",
|
||||
}
|
||||
|
||||
export function spacePluginIdToCatalogId(spacePluginId: string): string | null {
|
||||
return SPACE_TO_CATALOG_ID[spacePluginId] ?? null
|
||||
}
|
||||
543
apps/web/lib/plugin-document.ts
Normal file
543
apps/web/lib/plugin-document.ts
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
|
||||
import type { z } from "zod"
|
||||
import { detectPluginSource, pluginIconByLabel } from "@/lib/plugin-space"
|
||||
|
||||
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
|
||||
type DocumentWithMemories = DocumentsResponse["documents"][0]
|
||||
|
||||
export type PluginDocumentKind =
|
||||
| "codex-session"
|
||||
| "codex-save"
|
||||
| "amp-thread"
|
||||
| "openclaw-session"
|
||||
| "claude-code-doc"
|
||||
|
||||
export interface PluginArtifact {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface PluginDocumentMessage {
|
||||
id: string
|
||||
role: "user" | "assistant" | "tool" | "system" | "unknown"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface PluginDocumentSection {
|
||||
label: string
|
||||
value: string
|
||||
tone?: "default" | "accent" | "muted"
|
||||
}
|
||||
|
||||
export interface ParsedPluginDocument {
|
||||
kind: PluginDocumentKind
|
||||
pluginLabel: string
|
||||
pluginIconSrc?: string
|
||||
formatLabel: string
|
||||
title: string
|
||||
preview: string
|
||||
summary: string
|
||||
identifierLabel?: string
|
||||
identifierValue?: string
|
||||
clientLabel?: string
|
||||
clientValue?: string
|
||||
artifacts: PluginArtifact[]
|
||||
messages: PluginDocumentMessage[]
|
||||
sections: PluginDocumentSection[]
|
||||
rawContent: string
|
||||
}
|
||||
|
||||
const TRANSCRIPT_ROLE_PATTERN = "user|assistant|tool|system"
|
||||
|
||||
function normalizeContent(content: string | null | undefined): string {
|
||||
if (!content) return ""
|
||||
|
||||
return (
|
||||
content
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(/\\n/g, "\n")
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: we need to remove null bytes
|
||||
.replace(/\x00/g, "")
|
||||
.trim()
|
||||
)
|
||||
}
|
||||
|
||||
function formatClientName(value: string | null | undefined): string | null {
|
||||
if (!value) return null
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const normalized = trimmed.replace(/[_-]+/g, " ")
|
||||
const lower = normalized.toLowerCase()
|
||||
|
||||
if (lower === "codex") return "Codex"
|
||||
if (lower === "claude desktop") return "Claude Desktop"
|
||||
if (lower === "claude code") return "Claude Code"
|
||||
if (lower === "opencode") return "OpenCode"
|
||||
if (lower === "openclaw") return "OpenClaw"
|
||||
if (lower === "amp") return "Amp"
|
||||
|
||||
return normalized.replace(/\b\w/g, (match) => match.toUpperCase())
|
||||
}
|
||||
|
||||
function extractArtifacts(text: string): {
|
||||
cleanText: string
|
||||
artifacts: PluginArtifact[]
|
||||
} {
|
||||
const artifacts: PluginArtifact[] = []
|
||||
const cleanLines: string[] = []
|
||||
|
||||
for (const line of text.split("\n")) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
cleanLines.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
const memoryIdMatch = trimmed.match(/^memory id:\s*(.+)$/i)
|
||||
if (memoryIdMatch?.[1]) {
|
||||
artifacts.push({ label: "Memory ID", value: memoryIdMatch[1].trim() })
|
||||
continue
|
||||
}
|
||||
|
||||
cleanLines.push(line)
|
||||
}
|
||||
|
||||
return {
|
||||
cleanText: cleanLines.join("\n").trim(),
|
||||
artifacts,
|
||||
}
|
||||
}
|
||||
|
||||
function parseTranscriptMessages(content: string): {
|
||||
messages: PluginDocumentMessage[]
|
||||
artifacts: PluginArtifact[]
|
||||
} {
|
||||
const messages: PluginDocumentMessage[] = []
|
||||
const artifacts: PluginArtifact[] = []
|
||||
const regex = new RegExp(
|
||||
`^\\s*(\\d+)\\.\\s+\\[(${TRANSCRIPT_ROLE_PATTERN})\\]\\s*([\\s\\S]*?)(?=^\\s*\\d+\\.\\s+\\[(?:${TRANSCRIPT_ROLE_PATTERN})\\]\\s*|$)`,
|
||||
"gm",
|
||||
)
|
||||
|
||||
for (const match of content.matchAll(regex)) {
|
||||
const ordinal = match[1] ?? `${messages.length + 1}`
|
||||
const role = match[2] ?? "unknown"
|
||||
const rawText = match[3] ?? ""
|
||||
const { cleanText, artifacts: messageArtifacts } = extractArtifacts(
|
||||
rawText.trim(),
|
||||
)
|
||||
artifacts.push(...messageArtifacts)
|
||||
|
||||
if (!cleanText) continue
|
||||
|
||||
messages.push({
|
||||
id: `${ordinal}-${role}`,
|
||||
role: role as PluginDocumentMessage["role"],
|
||||
text: cleanText,
|
||||
})
|
||||
}
|
||||
|
||||
return { messages, artifacts }
|
||||
}
|
||||
|
||||
function parseRoleBlockMessages(content: string): {
|
||||
messages: PluginDocumentMessage[]
|
||||
artifacts: PluginArtifact[]
|
||||
} {
|
||||
const messages: PluginDocumentMessage[] = []
|
||||
const artifacts: PluginArtifact[] = []
|
||||
const regex =
|
||||
/\[role:\s*(user|assistant|tool|system)\]\s*([\s\S]*?)\s*\[\1:end\]/gi
|
||||
|
||||
let index = 0
|
||||
for (const match of content.matchAll(regex)) {
|
||||
const role = match[1] ?? "unknown"
|
||||
const rawText = match[2] ?? ""
|
||||
const { cleanText, artifacts: messageArtifacts } = extractArtifacts(
|
||||
rawText.trim(),
|
||||
)
|
||||
artifacts.push(...messageArtifacts)
|
||||
|
||||
if (!cleanText) continue
|
||||
|
||||
messages.push({
|
||||
id: `${role}-${index}`,
|
||||
role: role.toLowerCase() as PluginDocumentMessage["role"],
|
||||
text: cleanText,
|
||||
})
|
||||
index++
|
||||
}
|
||||
|
||||
return { messages, artifacts }
|
||||
}
|
||||
|
||||
function takePreview(text: string, maxLength = 180): string {
|
||||
const normalized = text.replace(/\s+/g, " ").trim()
|
||||
if (!normalized) return ""
|
||||
if (normalized.length <= maxLength) return normalized
|
||||
return `${normalized.slice(0, maxLength - 1).trimEnd()}...`
|
||||
}
|
||||
|
||||
function parseSaveSections(content: string): ParsedPluginDocument | null {
|
||||
const match = content.match(/\[SAVE:([^\]]+)\]([\s\S]*?)\[\/SAVE\]/i)
|
||||
if (!match) return null
|
||||
|
||||
const savedAt = match[1] ?? ""
|
||||
const rawBody = match[2] ?? ""
|
||||
const sections: PluginDocumentSection[] = []
|
||||
const artifacts: PluginArtifact[] = []
|
||||
|
||||
const overviewLines: string[] = []
|
||||
|
||||
for (const line of rawBody.split("\n")) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
if (/^Decision:/i.test(trimmed)) {
|
||||
sections.push({
|
||||
label: "Decision",
|
||||
value: trimmed.replace(/^Decision:\s*/i, "").trim(),
|
||||
tone: "accent",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^Context:/i.test(trimmed)) {
|
||||
sections.push({
|
||||
label: "Context",
|
||||
value: trimmed.replace(/^Context:\s*/i, "").trim(),
|
||||
tone: "muted",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^Files:/i.test(trimmed)) {
|
||||
sections.push({
|
||||
label: "Files",
|
||||
value: trimmed.replace(/^Files:\s*/i, "").trim(),
|
||||
tone: "default",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
overviewLines.push(trimmed)
|
||||
}
|
||||
|
||||
if (overviewLines.length > 0) {
|
||||
sections.unshift({
|
||||
label: "Summary",
|
||||
value: overviewLines.join("\n\n"),
|
||||
})
|
||||
}
|
||||
|
||||
const summary =
|
||||
sections.find((section) => section.label === "Decision")?.value ??
|
||||
sections[0]?.value ??
|
||||
"Saved project note"
|
||||
|
||||
return {
|
||||
kind: "codex-save",
|
||||
pluginLabel: "Codex",
|
||||
formatLabel: "Saved note",
|
||||
title: "Saved memory note",
|
||||
preview: takePreview(
|
||||
sections[0]?.value ?? "Saved project knowledge from Codex",
|
||||
140,
|
||||
),
|
||||
summary: takePreview(summary, 220),
|
||||
identifierLabel: "Saved",
|
||||
identifierValue: savedAt.trim(),
|
||||
artifacts,
|
||||
messages: [],
|
||||
sections,
|
||||
rawContent: content,
|
||||
}
|
||||
}
|
||||
|
||||
function parseSessionTranscript(
|
||||
content: string,
|
||||
config: {
|
||||
kind: "codex-session" | "amp-thread"
|
||||
headerLabel: "Session" | "Amp thread"
|
||||
pluginLabel: string
|
||||
formatLabel: string
|
||||
},
|
||||
): ParsedPluginDocument | null {
|
||||
const headerRegex = new RegExp(`\\[${config.headerLabel} ([^\\]]+)\\]`, "i")
|
||||
const headerMatch = content.match(headerRegex)
|
||||
if (!headerMatch?.[1]) return null
|
||||
|
||||
const identifierValue = headerMatch[1].trim()
|
||||
const withoutHeader = content.replace(
|
||||
new RegExp(`\\[${config.headerLabel} [^\\]]+\\]\\s*`, "gi"),
|
||||
"",
|
||||
)
|
||||
const { messages, artifacts } = parseTranscriptMessages(withoutHeader)
|
||||
if (messages.length === 0) return null
|
||||
|
||||
const userCount = messages.filter((message) => message.role === "user").length
|
||||
const assistantCount = messages.filter(
|
||||
(message) => message.role === "assistant",
|
||||
).length
|
||||
const previewSource =
|
||||
messages.find((message) => message.role === "user")?.text ??
|
||||
messages[0]?.text ??
|
||||
"Conversation"
|
||||
|
||||
return {
|
||||
kind: config.kind,
|
||||
pluginLabel: config.pluginLabel,
|
||||
formatLabel: config.formatLabel,
|
||||
title: `${config.pluginLabel} conversation`,
|
||||
preview: takePreview(previewSource, 140),
|
||||
summary: `${userCount} user message${userCount === 1 ? "" : "s"} and ${assistantCount} assistant message${assistantCount === 1 ? "" : "s"} captured from ${config.pluginLabel}.`,
|
||||
identifierLabel: config.headerLabel,
|
||||
identifierValue,
|
||||
artifacts,
|
||||
messages,
|
||||
sections: [],
|
||||
rawContent: content,
|
||||
}
|
||||
}
|
||||
|
||||
function parseOpenClawTranscript(content: string): ParsedPluginDocument | null {
|
||||
const { messages, artifacts } = parseRoleBlockMessages(content)
|
||||
if (messages.length === 0) return null
|
||||
|
||||
const previewSource =
|
||||
messages.find((message) => message.role === "user")?.text ??
|
||||
messages[0]?.text ??
|
||||
"Conversation"
|
||||
|
||||
return {
|
||||
kind: "openclaw-session",
|
||||
pluginLabel: "OpenClaw",
|
||||
formatLabel: "Conversation",
|
||||
title: "OpenClaw conversation",
|
||||
preview: takePreview(previewSource, 140),
|
||||
summary: `${messages.length} message${messages.length === 1 ? "" : "s"} captured from OpenClaw.`,
|
||||
artifacts,
|
||||
messages,
|
||||
sections: [],
|
||||
rawContent: content,
|
||||
}
|
||||
}
|
||||
|
||||
function withIcon(
|
||||
parsed: ParsedPluginDocument | null,
|
||||
): ParsedPluginDocument | null {
|
||||
if (!parsed) return parsed
|
||||
if (!parsed.pluginIconSrc) {
|
||||
const icon = pluginIconByLabel(parsed.pluginLabel)
|
||||
if (icon) parsed.pluginIconSrc = icon
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function getDocumentPluginSource(
|
||||
document: DocumentWithMemories,
|
||||
metadata: Record<string, unknown>,
|
||||
): string | null {
|
||||
if (typeof document.source === "string" && document.source) {
|
||||
return document.source
|
||||
}
|
||||
if (typeof metadata.sm_source === "string" && metadata.sm_source) {
|
||||
return metadata.sm_source
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const CLAUDE_CODE_CONTENT_RE =
|
||||
/<\|turn_start\|>|<\|start\|>(?:user|assistant)<\|message\|>/
|
||||
|
||||
function firstMemoryEntryText(document: DocumentWithMemories): string | null {
|
||||
const entries = document.memoryEntries
|
||||
if (!Array.isArray(entries)) return null
|
||||
for (const entry of entries) {
|
||||
const memory = entry?.memory
|
||||
if (typeof memory === "string" && memory.trim()) return memory.trim()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mapClaudeCodeRole(raw: string): PluginDocumentMessage["role"] {
|
||||
const lower = raw.toLowerCase()
|
||||
if (lower === "user") return "user"
|
||||
if (lower === "assistant") return "assistant"
|
||||
if (lower === "assistant:tool" || lower === "assistant:tool_result") {
|
||||
return "tool"
|
||||
}
|
||||
if (lower === "system") return "system"
|
||||
if (lower === "tool") return "tool"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function parseClaudeCodeTurns(content: string): PluginDocumentMessage[] {
|
||||
if (!content) return []
|
||||
const messages: PluginDocumentMessage[] = []
|
||||
const regex =
|
||||
/<\|start\|>([a-z_:]+)<\|message\|>([\s\S]*?)(?=<\|end\|>|<\|start\|>|<\|turn_end\|>|<\|turn_start\|>|$)/gi
|
||||
let index = 0
|
||||
for (const match of content.matchAll(regex)) {
|
||||
const rawRole = match[1] ?? ""
|
||||
const role = mapClaudeCodeRole(rawRole)
|
||||
let text = match[2] ?? ""
|
||||
text = text
|
||||
.replace(/<system_instruction>[\s\S]*?<\/system_instruction>/gi, "")
|
||||
.trim()
|
||||
if (!text) continue
|
||||
messages.push({ id: `${role}-${index++}`, role, text })
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
function stripClaudeCodeTranscript(content: string): string {
|
||||
if (!content) return ""
|
||||
let text = content
|
||||
.replace(/<system_instruction>[\s\S]*?<\/system_instruction>/gi, "")
|
||||
.replace(/<\|turn_start\|>[^\n<]*/g, "")
|
||||
.replace(/<\|turn_end\|>/g, "")
|
||||
.replace(/<\|start\|>(?:user|assistant|tool|system)<\|message\|>/g, "")
|
||||
.replace(/<\|end\|>/g, "")
|
||||
.replace(/<\|[^|>]*\|>/g, "")
|
||||
text = text.replace(/\s+/g, " ").trim()
|
||||
return text
|
||||
}
|
||||
|
||||
export function formatTokenCount(n: number): string {
|
||||
if (n >= 1_000_000)
|
||||
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`
|
||||
return `${n}`
|
||||
}
|
||||
|
||||
export function claudeCodeTokenBadge(
|
||||
document: DocumentWithMemories,
|
||||
): string | null {
|
||||
const meta = (document.metadata ?? {}) as Record<string, unknown>
|
||||
if (getDocumentPluginSource(document, meta) !== "claude-code-plugin") {
|
||||
return null
|
||||
}
|
||||
const tokens = document.tokenCount
|
||||
if (typeof tokens !== "number" || tokens <= 0) return null
|
||||
return `${formatTokenCount(tokens)} tokens`
|
||||
}
|
||||
|
||||
function parseClaudeCodeByMetadata(
|
||||
document: DocumentWithMemories,
|
||||
metadata: Record<string, unknown>,
|
||||
): ParsedPluginDocument | null {
|
||||
const docSource = getDocumentPluginSource(document, metadata)
|
||||
let source = detectPluginSource(metadata, docSource)
|
||||
|
||||
const rawContent =
|
||||
typeof document.content === "string" ? document.content : ""
|
||||
|
||||
if (!source) {
|
||||
if (CLAUDE_CODE_CONTENT_RE.test(rawContent)) {
|
||||
const md = metadata ?? {}
|
||||
const project =
|
||||
typeof md.project === "string" && md.project.trim()
|
||||
? md.project.trim()
|
||||
: undefined
|
||||
source = {
|
||||
pluginId: "claude-code",
|
||||
label: "Claude Code",
|
||||
iconSrc: "/images/plugins/claude-code.svg",
|
||||
projectName: project,
|
||||
formatLabel: "Session",
|
||||
type: "session_turn",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!source) return null
|
||||
|
||||
const summary = typeof document.summary === "string" ? document.summary : ""
|
||||
const title =
|
||||
(typeof document.title === "string" && document.title.trim()) ||
|
||||
(source.projectName
|
||||
? `${source.formatLabel} · ${source.projectName}`
|
||||
: source.formatLabel)
|
||||
|
||||
const memoryText = firstMemoryEntryText(document)
|
||||
const cleanedTranscript = stripClaudeCodeTranscript(rawContent)
|
||||
const preview = takePreview(memoryText || cleanedTranscript || summary, 220)
|
||||
|
||||
const parsed: ParsedPluginDocument = {
|
||||
kind: "claude-code-doc",
|
||||
pluginLabel: source.label,
|
||||
pluginIconSrc: source.iconSrc,
|
||||
formatLabel: source.formatLabel,
|
||||
title,
|
||||
preview,
|
||||
summary,
|
||||
artifacts: [],
|
||||
messages: parseClaudeCodeTurns(rawContent),
|
||||
sections: [],
|
||||
rawContent,
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function parsePluginDocument(
|
||||
document: DocumentWithMemories | null,
|
||||
): ParsedPluginDocument | null {
|
||||
if (!document) return null
|
||||
|
||||
const metadata = (document.metadata ?? {}) as Record<string, unknown>
|
||||
|
||||
if (getDocumentPluginSource(document, metadata) === "claude-code-plugin") {
|
||||
return withIcon(parseClaudeCodeByMetadata(document, metadata))
|
||||
}
|
||||
|
||||
const content = normalizeContent(
|
||||
typeof document.content === "string" ? document.content : "",
|
||||
)
|
||||
|
||||
const clientName = formatClientName(
|
||||
typeof metadata.sm_internal_mcp_client_name === "string"
|
||||
? metadata.sm_internal_mcp_client_name
|
||||
: null,
|
||||
)
|
||||
|
||||
if (content) {
|
||||
const codexSave = parseSaveSections(content)
|
||||
if (codexSave) {
|
||||
if (clientName) {
|
||||
codexSave.clientLabel = "Client"
|
||||
codexSave.clientValue = clientName
|
||||
}
|
||||
return withIcon(codexSave)
|
||||
}
|
||||
|
||||
const codexSession = parseSessionTranscript(content, {
|
||||
kind: "codex-session",
|
||||
headerLabel: "Session",
|
||||
pluginLabel: "Codex",
|
||||
formatLabel: "Conversation",
|
||||
})
|
||||
if (codexSession) {
|
||||
if (clientName) {
|
||||
codexSession.clientLabel = "Client"
|
||||
codexSession.clientValue = clientName
|
||||
}
|
||||
return withIcon(codexSession)
|
||||
}
|
||||
|
||||
const ampThread = parseSessionTranscript(content, {
|
||||
kind: "amp-thread",
|
||||
headerLabel: "Amp thread",
|
||||
pluginLabel: "Amp",
|
||||
formatLabel: "Conversation",
|
||||
})
|
||||
if (ampThread) return withIcon(ampThread)
|
||||
|
||||
const openClawSession = parseOpenClawTranscript(content)
|
||||
if (openClawSession) return withIcon(openClawSession)
|
||||
}
|
||||
|
||||
return withIcon(parseClaudeCodeByMetadata(document, metadata))
|
||||
}
|
||||
167
apps/web/lib/plugin-space.ts
Normal file
167
apps/web/lib/plugin-space.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
export type PluginSpaceInfo = {
|
||||
pluginId: "claude-code" | "openclaw" | "opencode" | "codex" | "amp"
|
||||
label: string
|
||||
iconSrc: string | null
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
type PluginDef = {
|
||||
id: PluginSpaceInfo["pluginId"]
|
||||
label: string
|
||||
iconSrc: string | null
|
||||
prefixes: string[]
|
||||
}
|
||||
|
||||
const PLUGINS: PluginDef[] = [
|
||||
{
|
||||
id: "claude-code",
|
||||
label: "Claude Code",
|
||||
iconSrc: "/images/plugins/claude-code.svg",
|
||||
prefixes: ["claudecode"],
|
||||
},
|
||||
{
|
||||
id: "openclaw",
|
||||
label: "OpenClaw",
|
||||
iconSrc: "/images/plugins/openclaw.svg",
|
||||
prefixes: ["openclaw"],
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
label: "OpenCode",
|
||||
iconSrc: "/images/plugins/opencode.svg",
|
||||
prefixes: ["opencode"],
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
iconSrc: "/images/plugins/codex.png",
|
||||
prefixes: ["codex"],
|
||||
},
|
||||
{
|
||||
id: "amp",
|
||||
label: "Amp",
|
||||
iconSrc: null,
|
||||
prefixes: ["amp"],
|
||||
},
|
||||
]
|
||||
|
||||
function parsePluginRest(rest: string): { projectId?: string } {
|
||||
if (!rest || rest === "default" || rest === "global") {
|
||||
return { projectId: "Global" }
|
||||
}
|
||||
const userMatch = rest.match(/^user[_-]([0-9a-f]{6,64})$/i)
|
||||
if (userMatch?.[1]) return { projectId: `User · ${userMatch[1].slice(0, 6)}` }
|
||||
const projectMatch = rest.match(/^project[_-]([0-9a-f]{6,64})$/i)
|
||||
if (projectMatch?.[1]) return { projectId: projectMatch[1].slice(0, 6) }
|
||||
const hexOnly = rest.match(/^([0-9a-f]{6,64})$/i)
|
||||
if (hexOnly?.[1]) return { projectId: hexOnly[1].slice(0, 6) }
|
||||
return { projectId: rest.slice(0, 24) }
|
||||
}
|
||||
|
||||
const PLUGIN_ICON_BY_LABEL: Record<string, string> = {
|
||||
"Claude Code": "/images/plugins/claude-code.svg",
|
||||
OpenClaw: "/images/plugins/openclaw.svg",
|
||||
OpenCode: "/images/plugins/opencode.svg",
|
||||
Codex: "/images/plugins/codex.png",
|
||||
}
|
||||
|
||||
export function pluginIconByLabel(
|
||||
label: string | null | undefined,
|
||||
): string | null {
|
||||
if (!label) return null
|
||||
return PLUGIN_ICON_BY_LABEL[label] ?? null
|
||||
}
|
||||
|
||||
export function pluginInitial(label: string | null | undefined): string {
|
||||
if (!label) return "?"
|
||||
return label.trim().charAt(0).toUpperCase() || "?"
|
||||
}
|
||||
|
||||
export type PluginDocSource = {
|
||||
pluginId: "claude-code"
|
||||
label: string
|
||||
iconSrc: string
|
||||
projectName?: string
|
||||
formatLabel: string
|
||||
type: "session_turn" | "project-knowledge" | "manual" | "unknown"
|
||||
}
|
||||
|
||||
function formatLabelForType(t: string | undefined): {
|
||||
formatLabel: string
|
||||
type: PluginDocSource["type"]
|
||||
} {
|
||||
switch (t) {
|
||||
case "session_turn":
|
||||
return { formatLabel: "Session", type: "session_turn" }
|
||||
case "project-knowledge":
|
||||
return { formatLabel: "Project knowledge", type: "project-knowledge" }
|
||||
case "manual":
|
||||
return { formatLabel: "Note", type: "manual" }
|
||||
default:
|
||||
return { formatLabel: "Note", type: "unknown" }
|
||||
}
|
||||
}
|
||||
|
||||
export function detectPluginSource(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
documentSource?: string | null,
|
||||
): PluginDocSource | null {
|
||||
const sourceFromMeta =
|
||||
metadata && typeof metadata.sm_source === "string"
|
||||
? metadata.sm_source
|
||||
: null
|
||||
const source = documentSource ?? sourceFromMeta
|
||||
if (source !== "claude-code-plugin") return null
|
||||
|
||||
const md = metadata ?? {}
|
||||
const project =
|
||||
typeof md.project === "string" && md.project.trim()
|
||||
? md.project.trim()
|
||||
: undefined
|
||||
const t = typeof md.type === "string" ? md.type : undefined
|
||||
const { formatLabel, type } = formatLabelForType(t)
|
||||
|
||||
return {
|
||||
pluginId: "claude-code",
|
||||
label: "Claude Code",
|
||||
iconSrc: "/images/plugins/claude-code.svg",
|
||||
projectName: project,
|
||||
formatLabel,
|
||||
type,
|
||||
}
|
||||
}
|
||||
|
||||
export function detectPluginSpace(
|
||||
containerTag: string,
|
||||
): PluginSpaceInfo | null {
|
||||
if (!containerTag) return null
|
||||
|
||||
for (const plugin of PLUGINS) {
|
||||
for (const prefix of plugin.prefixes) {
|
||||
if (containerTag === prefix) {
|
||||
return {
|
||||
pluginId: plugin.id,
|
||||
label: plugin.label,
|
||||
iconSrc: plugin.iconSrc,
|
||||
projectId: "Global",
|
||||
}
|
||||
}
|
||||
const separator = containerTag[prefix.length]
|
||||
if (
|
||||
containerTag.startsWith(prefix) &&
|
||||
(separator === "_" || separator === "-")
|
||||
) {
|
||||
const rest = containerTag.slice(prefix.length + 1)
|
||||
const parsed = parsePluginRest(rest)
|
||||
return {
|
||||
pluginId: plugin.id,
|
||||
label: plugin.label,
|
||||
iconSrc: plugin.iconSrc,
|
||||
projectId: parsed.projectId,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -79,6 +79,10 @@ export function MemoryGraph({
|
|||
containerSize.height,
|
||||
colors,
|
||||
)
|
||||
const isCompactViewport = containerSize.width > 0 && containerSize.width < 640
|
||||
const graphFitHeight = isCompactViewport
|
||||
? Math.max(containerSize.height - 170, 240)
|
||||
: containerSize.height
|
||||
|
||||
// Rebuild version chain index during render (not in an effect) so that
|
||||
// the chain data is up-to-date when getChain() is called in useMemo below.
|
||||
|
|
@ -127,7 +131,8 @@ export function MemoryGraph({
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Auto-fit when data first loads
|
||||
// Auto-fit when data first loads. Mobile needs a few passes because the
|
||||
// force simulation can move nodes after the first layout frame.
|
||||
const hasAutoFittedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
|
@ -136,22 +141,47 @@ export function MemoryGraph({
|
|||
viewportRef.current &&
|
||||
containerSize.width > 0
|
||||
) {
|
||||
const timer = setTimeout(() => {
|
||||
viewportRef.current?.fitToNodes(
|
||||
nodes,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
hasAutoFittedRef.current = true
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
const fitDelays = isCompactViewport ? [100, 450, 900] : [100]
|
||||
const timers = fitDelays.map((delay, index) =>
|
||||
setTimeout(() => {
|
||||
viewportRef.current?.fitToNodes(
|
||||
nodes,
|
||||
containerSize.width,
|
||||
graphFitHeight,
|
||||
)
|
||||
if (index === fitDelays.length - 1) {
|
||||
hasAutoFittedRef.current = true
|
||||
}
|
||||
}, delay),
|
||||
)
|
||||
return () => {
|
||||
for (const timer of timers) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [nodes, containerSize.width, containerSize.height])
|
||||
}, [nodes, containerSize.width, graphFitHeight, isCompactViewport])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCompactViewport || nodes.length === 0 || !viewportRef.current) return
|
||||
const timer = setTimeout(() => {
|
||||
viewportRef.current?.fitToNodes(
|
||||
nodes,
|
||||
containerSize.width,
|
||||
graphFitHeight,
|
||||
)
|
||||
}, 120)
|
||||
return () => clearTimeout(timer)
|
||||
}, [isCompactViewport, nodes, containerSize.width, graphFitHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (nodes.length === 0) hasAutoFittedRef.current = false
|
||||
}, [nodes.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCompactViewport) {
|
||||
hasAutoFittedRef.current = false
|
||||
}
|
||||
}, [isCompactViewport])
|
||||
|
||||
// Container resize observer
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
|
|
@ -190,7 +220,6 @@ export function MemoryGraph({
|
|||
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const loadMoreRef = useRef({ hasMore, isLoadingMore, onLoadMore })
|
||||
loadMoreRef.current = { hasMore, isLoadingMore, onLoadMore }
|
||||
|
||||
const handleViewportChange = useCallback(
|
||||
(zoom: number, popoverVisible: boolean) => {
|
||||
setZoomDisplay(Math.round(zoom * 100))
|
||||
|
|
@ -248,12 +277,8 @@ export function MemoryGraph({
|
|||
// Navigation
|
||||
const handleAutoFit = useCallback(() => {
|
||||
if (nodes.length === 0 || !viewportRef.current) return
|
||||
viewportRef.current.fitToNodes(
|
||||
nodes,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
)
|
||||
}, [nodes, containerSize.width, containerSize.height])
|
||||
viewportRef.current.fitToNodes(nodes, containerSize.width, graphFitHeight)
|
||||
}, [nodes, containerSize.width, graphFitHeight])
|
||||
|
||||
const handleCenter = useCallback(() => {
|
||||
if (nodes.length === 0 || !viewportRef.current) return
|
||||
|
|
@ -267,21 +292,21 @@ export function MemoryGraph({
|
|||
sx / nodes.length,
|
||||
sy / nodes.length,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
graphFitHeight,
|
||||
)
|
||||
}, [nodes, containerSize.width, containerSize.height])
|
||||
}, [nodes, containerSize.width, graphFitHeight])
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
const vp = viewportRef.current
|
||||
if (!vp) return
|
||||
vp.zoomTo(vp.zoom * 1.3, containerSize.width / 2, containerSize.height / 2)
|
||||
}, [containerSize.width, containerSize.height])
|
||||
vp.zoomTo(vp.zoom * 1.3, containerSize.width / 2, graphFitHeight / 2)
|
||||
}, [containerSize.width, graphFitHeight])
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
const vp = viewportRef.current
|
||||
if (!vp) return
|
||||
vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, containerSize.height / 2)
|
||||
}, [containerSize.width, containerSize.height])
|
||||
vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, graphFitHeight / 2)
|
||||
}, [containerSize.width, graphFitHeight])
|
||||
|
||||
// Wrap onOpenDocument to dismiss the popover before opening the modal.
|
||||
// Without this, the popover (z-index: 100) stays mounted on top of the
|
||||
|
|
@ -619,7 +644,7 @@ export function MemoryGraph({
|
|||
|
||||
const navControlsStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
bottom: 72,
|
||||
bottom: isCompactViewport ? 148 : 72,
|
||||
left: 16,
|
||||
zIndex: 15,
|
||||
}
|
||||
|
|
@ -683,6 +708,7 @@ export function MemoryGraph({
|
|||
<div style={navControlsStyle}>
|
||||
<NavigationControls
|
||||
nodes={nodes}
|
||||
compact={isCompactViewport}
|
||||
onAutoFit={handleAutoFit}
|
||||
onCenter={handleCenter}
|
||||
onZoomIn={handleZoomIn}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ interface NavigationControlsProps {
|
|||
onAutoFit: () => void
|
||||
nodes: GraphNode[]
|
||||
className?: string
|
||||
compact?: boolean
|
||||
zoomLevel: number
|
||||
colors: GraphThemeColors
|
||||
}
|
||||
|
|
@ -87,6 +88,7 @@ export const NavigationControls = memo<NavigationControlsProps>(
|
|||
onAutoFit,
|
||||
nodes,
|
||||
className = "",
|
||||
compact = false,
|
||||
zoomLevel,
|
||||
colors,
|
||||
}) => {
|
||||
|
|
@ -94,8 +96,12 @@ export const NavigationControls = memo<NavigationControlsProps>(
|
|||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
flexDirection: compact ? "row" : "column",
|
||||
alignItems: compact ? "center" : "stretch",
|
||||
gap: compact ? 6 : 4,
|
||||
maxWidth: compact ? "calc(100vw - 32px)" : undefined,
|
||||
overflowX: compact ? "auto" : undefined,
|
||||
overflowY: "hidden",
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
|
|
@ -147,12 +153,12 @@ export const NavigationControls = memo<NavigationControlsProps>(
|
|||
<div className={className} style={containerStyle}>
|
||||
<NavBtn onClick={onAutoFit} colors={colors}>
|
||||
<span style={labelStyle}>Fit</span>
|
||||
<KeyBadge keys="Z" colors={colors} />
|
||||
{!compact && <KeyBadge keys="Z" colors={colors} />}
|
||||
</NavBtn>
|
||||
|
||||
<NavBtn onClick={onCenter} colors={colors}>
|
||||
<span style={labelStyle}>Center</span>
|
||||
<KeyBadge keys="C" colors={colors} />
|
||||
{!compact && <KeyBadge keys="C" colors={colors} />}
|
||||
</NavBtn>
|
||||
|
||||
<div style={zoomRowStyle}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.2/schema.json"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@source "../../apps/**/*.tsx";
|
||||
@source "../../packages/**/*.tsx";
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue