From 4e607f9fd7e2c0e71bb875ca0324297fc6b5698c Mon Sep 17 00:00:00 2001 From: ved015 <122012786+ved015@users.noreply.github.com> Date: Fri, 15 May 2026 18:26:37 +0000 Subject: [PATCH] fix: Add plugin document rendering and MCP preview support (#938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit

Implement comprehensive plugin document rendering support including MCP previews and plugin specific content handling.



Screenshot 2026-05-12 at 8 24 49 PM

Screenshot 2026-05-12 at 8 28 25 PM --- apps/web/components/add-document/index.tsx | 1 - .../components/document-cards/mcp-preview.tsx | 22 +- .../document-cards/note-preview.tsx | 14 +- .../document-cards/plugin-preview.tsx | 56 ++ .../document-modal/content/index.tsx | 10 +- .../document-modal/content/plugin-content.tsx | 200 ++++ apps/web/components/document-modal/index.tsx | 57 +- .../document-modal/plugin-details.tsx | 83 ++ apps/web/components/document-modal/title.tsx | 16 +- apps/web/components/header.tsx | 19 +- .../components/integrations/install-steps.tsx | 166 ++++ .../integrations/plugins-detail.tsx | 313 +----- apps/web/components/memories-grid.tsx | 42 +- apps/web/components/select-spaces-modal.tsx | 906 ++++++++++++++---- apps/web/components/space-selector.tsx | 543 +++++------ apps/web/hooks/use-plugin-space-meta.ts | 89 ++ apps/web/lib/plugin-catalog.ts | 149 +++ apps/web/lib/plugin-document.ts | 543 +++++++++++ apps/web/lib/plugin-space.ts | 167 ++++ 19 files changed, 2607 insertions(+), 789 deletions(-) create mode 100644 apps/web/components/document-cards/plugin-preview.tsx create mode 100644 apps/web/components/document-modal/content/plugin-content.tsx create mode 100644 apps/web/components/document-modal/plugin-details.tsx create mode 100644 apps/web/components/integrations/install-steps.tsx create mode 100644 apps/web/hooks/use-plugin-space-meta.ts create mode 100644 apps/web/lib/plugin-catalog.ts create mode 100644 apps/web/lib/plugin-document.ts create mode 100644 apps/web/lib/plugin-space.ts diff --git a/apps/web/components/add-document/index.tsx b/apps/web/components/add-document/index.tsx index 6a4775ee..fe74f61a 100644 --- a/apps/web/components/add-document/index.tsx +++ b/apps/web/components/add-document/index.tsx @@ -519,7 +519,6 @@ export function AddDocument({ setLocalSelectedProject(projects[0] ?? localSelectedProject) } variant="insideOut" - singleSelect /> )}
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/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]) + }} />
-
+
-
+