diff --git a/apps/docs/add-memories.mdx b/apps/docs/add-memories.mdx index 2102dfa7..62b53ea0 100644 --- a/apps/docs/add-memories.mdx +++ b/apps/docs/add-memories.mdx @@ -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) | @@ -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. + + +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. + + + + + ```typescript + await client.add({ + content: "New research findings on transformer architectures...", + containerTag: "user_123", + metadata: { category: "ml", source: "arxiv" }, + filterByMetadata: { category: "ml" } + }); + ``` + + + ```python + client.add( + content="New research findings on transformer architectures...", + container_tag="user_123", + metadata={"category": "ml", "source": "arxiv"}, + filter_by_metadata={"category": "ml"} + ) + ``` + + + ```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"} + }' + ``` + + + +### 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` | 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: diff --git a/apps/docs/connectors/s3.mdx b/apps/docs/connectors/s3.mdx index 90d99905..fde1bed7 100644 --- a/apps/docs/connectors/s3.mdx +++ b/apps/docs/connectors/s3.mdx @@ -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) | + + +In the Python SDK, use `container_tags` for the top-level option, but keep S3 metadata keys in camelCase: `accessKeyId`, `secretAccessKey`, and `containerTagRegex`. + ## 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://.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://.r2.cloudflarestorage.com' + }, + containerTags: ['r2-sync'] +}); +``` + + +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`. + + ## 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/(?[^/]+)/', + metadata: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + bucket: 'user-files', + region: 'us-east-1', + containerTagRegex: 'users/(?[^/]+)/' + }, containerTags: ['user-files'] }); diff --git a/apps/mcp/.dev.vars.example b/apps/mcp/.dev.vars.example new file mode 100644 index 00000000..dfc969e5 --- /dev/null +++ b/apps/mcp/.dev.vars.example @@ -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= \ No newline at end of file diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index 94a0bc74..846064e1 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -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", { diff --git a/apps/web/app/(app)/onboarding/[...slug]/page.tsx b/apps/web/app/(app)/onboarding/[...slug]/page.tsx new file mode 100644 index 00000000..dfd9a711 --- /dev/null +++ b/apps/web/app/(app)/onboarding/[...slug]/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation" + +export default function OnboardingCatchAll() { + redirect("/onboarding") +} diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 7c7210c6..24e986d7 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -159,6 +159,9 @@ export default function NewPage() { const [fullscreenInitialContent, setFullscreenInitialContent] = useState("") const [queuedChatSeed, setQueuedChatSeed] = useState(null) const [queuedChatModel, setQueuedChatModel] = useState(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} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 9b4553a8..3a2af064 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -47,6 +47,7 @@ export default function RootLayout({ defaultTheme="dark" enableSystem={false} disableTransitionOnChange + forcedTheme="dark" > )}
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(null) const isScrolledToBottomRef = useRef(true) const sentQueuedMessageRef = useRef(null) + const pendingHighlightReplyRef = useRef(null) + const awaitingHighlightInjectionRef = useRef(false) + const pendingHighlightMessageRef = useRef(null) + const targetHighlightChatIdRef = useRef(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) { diff --git a/apps/web/components/dashboard-view.tsx b/apps/web/components/dashboard-view.tsx index ab8b037d..295c8f26 100644 --- a/apps/web/components/dashboard-view.tsx +++ b/apps/web/components/dashboard-view.tsx @@ -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 diff --git a/apps/web/components/document-cards/mcp-preview.tsx b/apps/web/components/document-cards/mcp-preview.tsx index f241353d..19cdeec4 100644 --- a/apps/web/components/document-cards/mcp-preview.tsx +++ b/apps/web/components/document-cards/mcp-preview.tsx @@ -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 type DocumentWithMemories = DocumentsResponse["documents"][0] -export function McpPreview({ document }: { document: DocumentWithMemories }) { +export function McpPreview({ + document, + parsed, +}: { + document: DocumentWithMemories + parsed?: ParsedPluginDocument | null +}) { + if (parsed) { + return + } + 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 (
@@ -20,7 +38,7 @@ export function McpPreview({ document }: { document: DocumentWithMemories }) { )} > - Claude Desktop + {clientName}

diff --git a/apps/web/components/document-cards/note-preview.tsx b/apps/web/components/document-cards/note-preview.tsx index 34bcadba..e9623497 100644 --- a/apps/web/components/document-cards/note-preview.tsx +++ b/apps/web/components/document-cards/note-preview.tsx @@ -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 type DocumentWithMemories = DocumentsResponse["documents"][0] -export function NotePreview({ document }: { document: DocumentWithMemories }) { +export function NotePreview({ + document, + parsed, +}: { + document: DocumentWithMemories + parsed?: ParsedPluginDocument | null +}) { + if (parsed) { + return + } + return (
diff --git a/apps/web/components/document-cards/plugin-preview.tsx b/apps/web/components/document-cards/plugin-preview.tsx new file mode 100644 index 00000000..e4b90419 --- /dev/null +++ b/apps/web/components/document-cards/plugin-preview.tsx @@ -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 ( +
+
+
+ + {parsed.pluginIconSrc && ( + + )} + {parsed.pluginLabel} + +

+ {parsed.formatLabel} +

+
+ {parsed.identifierValue && ( +

+ {parsed.identifierValue} +

+ )} +
+
+

+ {parsed.title} +

+

+ {parsed.preview || parsed.summary} +

+
+
+ ) +} diff --git a/apps/web/components/document-modal/content/index.tsx b/apps/web/components/document-modal/content/index.tsx index 7d637c0c..b3df9fe7 100644 --- a/apps/web/components/document-modal/content/index.tsx +++ b/apps/web/components/document-modal/content/index.tsx @@ -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 + } + if (!contentType) return null switch (contentType) { case "image": diff --git a/apps/web/components/document-modal/content/plugin-content.tsx b/apps/web/components/document-modal/content/plugin-content.tsx new file mode 100644 index 00000000..ef1fdce4 --- /dev/null +++ b/apps/web/components/document-modal/content/plugin-content.tsx @@ -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 ( +
+
+ + {parsed.pluginLabel} + + + {parsed.formatLabel} + + {parsed.identifierLabel && parsed.identifierValue && ( + + {parsed.identifierLabel}: {parsed.identifierValue} + + )} +
+

+ {parsed.title} +

+

+ {parsed.summary} +

+
+ ) +} + +function ConversationView({ parsed }: { parsed: ParsedPluginDocument }) { + return ( +
+ {parsed.messages.map((message) => ( +
+

+ {roleLabel(message.role)} +

+

+ {message.text} +

+
+ ))} +
+ ) +} + +function SectionsView({ parsed }: { parsed: ParsedPluginDocument }) { + return ( +
+ {parsed.sections.map((section, index) => ( +
+

+ {section.label} +

+

+ {section.value} +

+
+ ))} +
+ ) +} + +function RawView({ parsed }: { parsed: ParsedPluginDocument }) { + return ( +
+
+				{parsed.rawContent}
+			
+
+ ) +} + +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 ( +
+ {!hideHeader && } +
+
+ + +
+
+ {mode === "raw" ? ( + + ) : hasMessages ? ( + + ) : ( + + )} +
+ ) +} diff --git a/apps/web/components/document-modal/index.tsx b/apps/web/components/document-modal/index.tsx index ba3ac069..6b8e5d37 100644 --- a/apps/web/components/document-modal/index.tsx +++ b/apps/web/components/document-modal/index.tsx @@ -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 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 ( + + ) +} + 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} />
+ {pluginDocument?.kind === "claude-code-doc" && + _document?.customId && ( + + )}
- {_document?.summary && ( + {pluginDocument && + pluginDocument.kind !== "claude-code-doc" && + pluginDocument.kind !== "openclaw-session" && ( + + )} + {_document && (_document.summary || pluginDocument?.summary) && ( )} diff --git a/apps/web/components/document-modal/plugin-details.tsx b/apps/web/components/document-modal/plugin-details.tsx new file mode 100644 index 00000000..29e5630d --- /dev/null +++ b/apps/web/components/document-modal/plugin-details.tsx @@ -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 ( +
+

+ {label} +

+

{value}

+
+ ) +} + +export function PluginDetails({ parsed }: { parsed: ParsedPluginDocument }) { + return ( +
+
+

+ Details +

+ + {parsed.pluginIconSrc && ( + + )} + {parsed.pluginLabel} + +
+
+ + {parsed.identifierLabel && parsed.identifierValue && ( + + )} + {parsed.clientLabel && parsed.clientValue && ( + + )} +
+ {parsed.artifacts.length > 0 && ( +
+

+ Outputs +

+
+ {parsed.artifacts.map((artifact, index) => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/apps/web/components/document-modal/title.tsx b/apps/web/components/document-modal/title.tsx index 9f2b0255..01e4a30c 100644 --- a/apps/web/components/document-modal/title.tsx +++ b/apps/web/components/document-modal/title.tsx @@ -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({ )} >
- + {pluginIconSrc ? ( + + ) : ( + + )} {extension && (

{!isMobile && ( - + <> + + + )}

{!isMobile && ( @@ -259,7 +265,6 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) { diff --git a/apps/web/components/highlights-card.tsx b/apps/web/components/highlights-card.tsx index 5f2d6f82..f51adad8 100644 --- a/apps/web/components/highlights-card.tsx +++ b/apps/web/components/highlights-card.tsx @@ -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 ( -

+

"{content}"

) @@ -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(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({ )} >
-
-
+
+
-
-
-
-
+
+
+
+
-
-
+
+
) @@ -136,10 +176,10 @@ export function HighlightsCard({
- + powered by - + supermemory
@@ -165,10 +205,10 @@ export function HighlightsCard({
- + powered by - + supermemory
@@ -185,33 +225,61 @@ export function HighlightsCard({
+ {isReplyOpen && ( +
+
+ 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" + /> + +
+ +
+ )} +
@@ -220,7 +288,7 @@ export function HighlightsCard({ + ) +} + +export function CopyButton({ text, label }: { text: string; label?: string }) { + const [copied, setCopied] = useState(false) + return ( + + ) +} + +export function CodeBlock({ + code, + copyLabel = "Command", + secret, +}: { + code: string + copyLabel?: string + secret?: boolean +}) { + return ( +
+
+				{code}
+			
+ +
+ ) +} + +export function InstallSteps({ + steps, + apiKey, +}: { + steps: InstallStep[] + apiKey?: string +}) { + return ( +
    + {steps.map((step, i) => ( +
  1. +
    + + {i + 1} + + {i < steps.length - 1 && ( + + )} +
    +
    +
    +
    +

    + {step.title} +

    + {step.optional && ( + + Optional + + )} +
    + {step.description && ( +

    + {step.description} +

    + )} +
    + {step.code && ( + + )} +
    +
  2. + ))} +
+ ) +} diff --git a/apps/web/components/integrations/plugins-detail.tsx b/apps/web/components/integrations/plugins-detail.tsx index ade962ae..053e7156 100644 --- a/apps/web/components/integrations/plugins-detail.tsx +++ b/apps/web/components/integrations/plugins-detail.tsx @@ -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 = { - 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 ( - - ) -} - function DocsLink({ href }: { href: string }) { return ( - Docs + {" "} + Docs ) } @@ -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 ( -
+
-
+
{isConnected && ( )} {plugin.name} @@ -400,13 +231,13 @@ function PluginRow({

{plugin.tagline}

-
+
{plugin.docsUrl && } {isConnected ? ( @@ -470,132 +301,6 @@ function TierFilterToggle({ ) } -function CopyButton({ text, label }: { text: string; label?: string }) { - const [copied, setCopied] = useState(false) - return ( - - ) -} - -function CodeBlock({ - code, - copyLabel = "Command", - secret, -}: { - code: string - copyLabel?: string - secret?: boolean -}) { - return ( -
-
-				{code}
-			
- -
- ) -} - -function InstallSteps({ - steps, - apiKey, -}: { - steps: InstallStep[] - apiKey: string -}) { - return ( -
    - {steps.map((step, i) => ( -
  1. -
    - - {i + 1} - - {i < steps.length - 1 && ( - - )} -
    -
    -
    -
    -

    - {step.title} -

    - {step.optional && ( - - Optional - - )} -
    - {step.description && ( -

    - {step.description} -

    - )} -
    - {step.code && ( - - )} -
    -
  2. - ))} -
- ) -} - export function PluginsDetail() { const { org } = useAuth() const autumn = useCustomer() @@ -782,7 +487,7 @@ export function PluginsDetail() { <>
diff --git a/apps/web/components/memories-grid.tsx b/apps/web/components/memories-grid.tsx index d1174b6e..d2f93d54 100644 --- a/apps/web/components/memories-grid.tsx +++ b/apps/web/components/memories-grid.tsx @@ -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(null) const [ogData, setOgData] = useState(null) @@ -1054,7 +1063,11 @@ const DocumentCard = memo( {isSelectionMode && isSelected && (
)} - + {!( 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 + })()}

@@ -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 + return } if (document.source === "mcp") { - return + return } if (isYouTubeUrl(document.url)) { @@ -1204,5 +1228,5 @@ function ContentPreview({ } // Default to Note - return + return } diff --git a/apps/web/components/onboarding/x-bookmarks-detail-view.tsx b/apps/web/components/onboarding/x-bookmarks-detail-view.tsx index 7a95b069..d65d35e2 100644 --- a/apps/web/components/onboarding/x-bookmarks-detail-view.tsx +++ b/apps/web/components/onboarding/x-bookmarks-detail-view.tsx @@ -34,7 +34,7 @@ export function XBookmarksDetailView({ onBack }: XBookmarksDetailViewProps) { } return ( -
+
+ {enableDelete && !isDefault && onDeleteRequest && ( + + )} +
) - }, [projects, searchQuery]) + } return ( -
-
-
-

- Select Space{!singleSelect && "s"} -

-

- {singleSelect - ? "Choose a space for your memory" - : "Choose one or more spaces to filter your memories"} -

-
- - - Close - -
- -
- - setSearchQuery(e.target.value)} - placeholder="Search spaces..." +
+
+

+ > + Select Space +

+

+ Filter your memories by space +

+ + + Close + +
-
- {filteredProjects.length === 0 ? ( -

- No spaces found -

- ) : ( - filteredProjects.map((project) => { - const isSelected = localSelection.includes(project.containerTag) +
+
+
+ {categories.map((category) => { + const isActive = activeCategory === category.id return ( ) - }) - )} + })} + + {discoverCategories.length > 0 && ( + <> +
+ Discover +
+ {discoverCategories.map((category) => { + const isActive = activeCategory === category.id + return ( + + ) + })} + + )} +
-
-

- {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`} -

-
+
+ {activeCategory.startsWith("discover:") ? ( + + connectMutation.mutate( + activeCategory.slice("discover:".length), + ) + } + onDismissKey={() => setNewKey(null)} + /> + ) : ( + <> +
+ + 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 + /> +
+ +
+ {filteredProjects.length === 0 ? ( +

+ No spaces found +

+ ) : ( +
+ {recentProjects.length > 0 && ( + <> +
+ + Recently used +
+ {recentProjects.map(renderRow)} +
+
+ All spaces +
+ + )} + {mainList.map(renderRow)} +
+ )} +
+ + )} +
+
+ + {showNewSpace && + onNewSpace && + !activeCategory.startsWith("discover:") && ( +
-
-
-
+ )}
) } + +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 ( +

+ Plugin info unavailable. +

+ ) + } + + 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 ( +
+
+
+ {info.name} +
+
+

+ {info.name} +

+

+ {info.tagline} +

+
+
+ + {isConnected && ( +
+

+ Plugin connected — finish setup +

+ +
+ )} + + {isConnected && ( +

+ Your API key is shown once. Hover or focus the blurred command to + reveal it. +

+ )} + +
+
+ +
+ + {!isConnected && ( +
+ + {isConnecting ? ( + <> + Connecting… + + ) : ( + `Connect ${info.name}` + )} + + {info.docsUrl && ( + + Docs + + )} +
+ )} +
+
+ ) +} diff --git a/apps/web/components/share-modal.tsx b/apps/web/components/share-modal.tsx index cfe65d71..877d82ac 100644 --- a/apps/web/components/share-modal.tsx +++ b/apps/web/components/share-modal.tsx @@ -258,13 +258,14 @@ const SocialButton = ({ @@ -387,7 +388,7 @@ export function ShareModal({ !open && handleClose()}> -
+
{/* Header */} -
- +
+
+ @@ -411,7 +413,7 @@ export function ShareModal({ {/* Bottom controls */} -
+
{/* Theme selectors */} -
+
{/* Action buttons */} -
+
- - -
-
- - My Spaces - -
- -
- 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", - )} - > - 📁 - My Space - - - {sortedOtherSpaces.map((project: ContainerTagListType) => ( - 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", - )} - > - - {project.emoji || "📁"} - - - {spaceSelectorDisplayName(project, project.containerTag)} - - {enableDelete && ( - - )} - - ))} -
- - - - - - {showNewSpace && ( - - )} -
-
- + + + Switch space + + setShowCreateDialog(false)} - onCreated={(containerTag) => onValueChange([containerTag])} + onCreated={(containerTag) => { + pushRecent(containerTag) + onValueChange([containerTag]) + }} />
-
+
-
+