From 62ac31ecaa73c7a47a8c9330529dcfa7af938e7d Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Wed, 13 May 2026 14:17:51 +0000 Subject: [PATCH 1/9] chore: theme default fix (#929) --- apps/web/app/layout.tsx | 1 + packages/ui/biome.json | 1 + packages/ui/globals.css | 2 ++ 3 files changed, 4 insertions(+) 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" > Date: Wed, 13 May 2026 12:40:22 -0700 Subject: [PATCH 2/9] docs: fix s3 connector config shape (#941) --- apps/docs/connectors/s3.mdx | 128 +++++++++++++++++++++++------------- 1 file changed, 84 insertions(+), 44 deletions(-) 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'] }); From aa83c0bc12a85836b87846fc5e46b94fb70764ed Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Thu, 14 May 2026 14:37:05 +0000 Subject: [PATCH 3/9] fix: onboarding bounce rate (#942) --- apps/web/app/(app)/onboarding/[...slug]/page.tsx | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 apps/web/app/(app)/onboarding/[...slug]/page.tsx 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") +} From 1f2b5dfca2783c1dff404bd95b6cd7d43f3b2197 Mon Sep 17 00:00:00 2001 From: vorflux <249966464+vorflux@users.noreply.github.com> Date: Fri, 15 May 2026 14:53:54 +0000 Subject: [PATCH 4/9] docs: add filtered writes documentation (#834) ### TL;DR Documents the new `filterByMetadata` parameter for the memory ingestion API, added in [supermemoryai/mono#1283](https://github.com/supermemoryai/mono/pull/1283). ### What changed? - Added a new "Filtered Writes" section to the `add-memories.mdx` page explaining how to scope memory context during ingestion - Added `filterByMetadata` to the Parameters table with a link to the new section - Included TypeScript, Python, and cURL examples - Documented scalar vs array value matching semantics (AND/OR logic) ### Key documentation points - The metadata itself is still written to the document, but memories are only built on top of existing memories matching the filter - Scalar values match exactly, array values create OR conditions - Multiple keys are combined with AND logic ### Related - Implementation PR: [supermemoryai/mono#1283](https://github.com/supermemoryai/mono/pull/1283) --- **Session Details** - Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/14d33783-50b0-4fc8-8e0f-abc6346336f1) - Requested by: Dhravya Shah (dhravya@supermemory.com) - Address comments on this PR. Add `(aside)` to your comment to have me ignore it. --- apps/docs/add-memories.mdx | 78 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) 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: From 06d5bbfefa8e3ccddb4e4d7395978d72617db29d Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Fri, 15 May 2026 20:55:43 +0530 Subject: [PATCH 5/9] add codex plugin logo and add in description (#943) --- apps/web/components/integrations-view.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/components/integrations-view.tsx b/apps/web/components/integrations-view.tsx index 70424e2e..bc9711c0 100644 --- a/apps/web/components/integrations-view.tsx +++ b/apps/web/components/integrations-view.tsx @@ -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: (
+ Codex OpenCode Date: Fri, 15 May 2026 21:07:01 +0530 Subject: [PATCH 6/9] add responsivness in share graph, bookmark section, navigation controls (#940) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 --- .../integrations/plugins-detail.tsx | 24 +++--- .../onboarding/x-bookmarks-detail-view.tsx | 14 ++-- apps/web/components/share-modal.tsx | 30 ++++---- .../src/components/memory-graph.tsx | 76 +++++++++++++------ .../src/components/navigation-controls.tsx | 14 +++- 5 files changed, 97 insertions(+), 61 deletions(-) diff --git a/apps/web/components/integrations/plugins-detail.tsx b/apps/web/components/integrations/plugins-detail.tsx index ade962ae..083f13aa 100644 --- a/apps/web/components/integrations/plugins-detail.tsx +++ b/apps/web/components/integrations/plugins-detail.tsx @@ -238,8 +238,8 @@ function PillButton({ 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]", + "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", @@ -253,15 +253,17 @@ function PillButton({ function DocsLink({ href }: { href: string }) { return ( - Docs + {" "} + Docs ) } @@ -304,7 +306,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 +378,21 @@ function PluginRow({ }) { const isConnected = connectedKeys.length > 0 return ( -
+
-
+
{isConnected && ( )} {plugin.name} @@ -400,13 +402,13 @@ function PluginRow({

{plugin.tagline}

-
+
{plugin.docsUrl && } {isConnected ? ( @@ -782,7 +784,7 @@ export function PluginsDetail() { <>
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 ( -
+
@@ -387,7 +388,7 @@ export function ShareModal({ !open && handleClose()}> -
+
{/* Header */} -
- +
+
+ @@ -411,7 +413,7 @@ export function ShareModal({ {/* Bottom controls */} -
+
{/* Theme selectors */} -
+
{/* Action buttons */} -
+
+ +
+
+ {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/integrations/install-steps.tsx b/apps/web/components/integrations/install-steps.tsx new file mode 100644 index 00000000..4616b07c --- /dev/null +++ b/apps/web/components/integrations/install-steps.tsx @@ -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 ( + + ) +} + +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 083f13aa..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,34 +79,6 @@ function ProChip() { ) } -function PillButton({ - children, - onClick, - disabled, -}: { - children: ReactNode - onClick?: () => void - disabled?: boolean -}) { - return ( - - ) -} - function DocsLink({ href }: { href: string }) { return ( { - 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 ? ( - - ) : ( - - )} - - ) -} - -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() diff --git a/apps/web/components/memories-grid.tsx b/apps/web/components/memories-grid.tsx index d1174b6e..61a84408 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" @@ -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/select-spaces-modal.tsx b/apps/web/components/select-spaces-modal.tsx index d7846602..e8fff2fa 100644 --- a/apps/web/components/select-spaces-modal.tsx +++ b/apps/web/components/select-spaces-modal.tsx @@ -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(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 + }>(() => { + 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() + 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(() => { + if (!currentSelection) return "all" + const plugin = detectPluginSpace(currentSelection) + if (plugin) return `plugin:${plugin.pluginId}` + return "my" + }, [currentSelection]) + + const [activeCategory, setActiveCategory] = + useState(defaultCategory) + + useEffect(() => { + if (isOpen) setActiveCategory(defaultCategory) + }, [isOpen, defaultCategory]) + + const { org } = useAuth() + const queryClient = useQueryClient() + const [connectingPluginId, setConnectingPluginId] = useState( + 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() + 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(() => { + 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(() => { + 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 ( +
+ + {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 ( +
+ ) +} diff --git a/apps/web/components/space-selector.tsx b/apps/web/components/space-selector.tsx index 65ab6ea1..bc6ddc2e 100644 --- a/apps/web/components/space-selector.tsx +++ b/apps/web/components/space-selector.tsx @@ -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([]) 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 => { + 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 + }>(() => { + 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 ( <> - - + + - - -
-
- - 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]) + }} />
-
+
-
+
+
+ +
+ )} +
@@ -220,7 +288,7 @@ export function HighlightsCard({