fix: Add plugin document rendering and MCP preview support (#938)

<h3>Implement comprehensive plugin document rendering support including MCP previews and plugin specific content handling.</h3>
<br>
<br>

<img width="1680" height="471" alt="Screenshot 2026-05-12 at 8 24 49 PM" src="https://github.com/user-attachments/assets/f1294bc2-2841-4833-9f01-ac47b8c52c01" />

<br>
<br>

<img width="1680" height="963" alt="Screenshot 2026-05-12 at 8 28 25 PM" src="https://github.com/user-attachments/assets/9436c7ab-3b9b-4366-86fd-1465407ff0f9" />
This commit is contained in:
ved015 2026-05-15 18:26:37 +00:00
parent 5065d66989
commit 4e607f9fd7
19 changed files with 2607 additions and 789 deletions

View file

@ -519,7 +519,6 @@ export function AddDocument({
setLocalSelectedProject(projects[0] ?? localSelectedProject)
}
variant="insideOut"
singleSelect
/>
)}
<div

View file

@ -5,11 +5,29 @@ import type { z } from "zod"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { ClaudeDesktopIcon, MCPIcon } from "@ui/assets/icons"
import type { ParsedPluginDocument } from "@/lib/plugin-document"
import { PluginPreview } from "./plugin-preview"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
export function McpPreview({ document }: { document: DocumentWithMemories }) {
export function McpPreview({
document,
parsed,
}: {
document: DocumentWithMemories
parsed?: ParsedPluginDocument | null
}) {
if (parsed) {
return <PluginPreview parsed={parsed} />
}
const clientName =
typeof document.metadata?.sm_internal_mcp_client_name === "string"
? document.metadata.sm_internal_mcp_client_name
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (match) => match.toUpperCase())
: "MCP Client"
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
<div className="flex items-center justify-between gap-1">
@ -20,7 +38,7 @@ export function McpPreview({ document }: { document: DocumentWithMemories }) {
)}
>
<ClaudeDesktopIcon className="size-3" />
Claude Desktop
{clientName}
</p>
<MCPIcon className="size-6" />
</div>

View file

@ -5,11 +5,23 @@ import type { z } from "zod"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import { DocumentIcon } from "@/components/document-icon"
import type { ParsedPluginDocument } from "@/lib/plugin-document"
import { PluginPreview } from "./plugin-preview"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
export function NotePreview({ document }: { document: DocumentWithMemories }) {
export function NotePreview({
document,
parsed,
}: {
document: DocumentWithMemories
parsed?: ParsedPluginDocument | null
}) {
if (parsed) {
return <PluginPreview parsed={parsed} />
}
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
<div className="flex items-center gap-1">

View file

@ -0,0 +1,56 @@
"use client"
import Image from "next/image"
import { dmSansClassName } from "@/lib/fonts"
import { cn } from "@lib/utils"
import type { ParsedPluginDocument } from "@/lib/plugin-document"
export function PluginPreview({ parsed }: { parsed: ParsedPluginDocument }) {
return (
<div className="bg-[#0B1017] p-3 rounded-[18px] space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span
className={cn(
dmSansClassName(),
"inline-flex items-center gap-1.5 text-[11px] font-medium text-[#FAFAFA]",
)}
>
{parsed.pluginIconSrc && (
<Image
src={parsed.pluginIconSrc}
alt=""
width={14}
height={14}
className="rounded-[3px]"
aria-hidden
/>
)}
{parsed.pluginLabel}
</span>
<p className="text-[11px] text-[#737373] truncate">
{parsed.formatLabel}
</p>
</div>
{parsed.identifierValue && (
<p className="text-[10px] text-[#737373] truncate max-w-[45%]">
{parsed.identifierValue}
</p>
)}
</div>
<div className="space-y-[6px]">
<p
className={cn(
dmSansClassName(),
"text-[13px] font-semibold line-clamp-2 leading-[125%]",
)}
>
{parsed.title}
</p>
<p className="text-[11px] text-[#737373] line-clamp-4">
{parsed.preview || parsed.summary}
</p>
</div>
</div>
)
}

View file

@ -12,6 +12,8 @@ import { WebPageContent } from "./web-page"
import { TextEditorContent } from "./text-editor-content"
import { GoogleDocViewer } from "./google-doc"
import type { TextEditorProps } from "./text-editor-content"
import type { ParsedPluginDocument } from "@/lib/plugin-document"
import { PluginContent } from "./plugin-content"
export type { TextEditorProps }
@ -33,6 +35,7 @@ type DocumentWithMemories = DocumentsResponse["documents"][0]
interface DocumentContentProps {
document: DocumentWithMemories | null
textEditorProps: TextEditorProps
pluginDocument?: ParsedPluginDocument | null
}
type ContentType =
@ -73,10 +76,15 @@ function getContentType(document: DocumentWithMemories | null): ContentType {
export function DocumentContent({
document,
textEditorProps,
pluginDocument,
}: DocumentContentProps) {
const contentType = getContentType(document)
if (!document || !contentType) return null
if (!document) return null
if (pluginDocument) {
return <PluginContent parsed={pluginDocument} />
}
if (!contentType) return null
switch (contentType) {
case "image":

View file

@ -0,0 +1,200 @@
"use client"
import { useState } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import type {
ParsedPluginDocument,
PluginDocumentMessage,
PluginDocumentSection,
} from "@/lib/plugin-document"
function roleLabel(role: PluginDocumentMessage["role"]): string {
switch (role) {
case "user":
return "User"
case "assistant":
return "Assistant"
case "tool":
return "Tool"
case "system":
return "System"
default:
return "Message"
}
}
function sectionClasses(tone: PluginDocumentSection["tone"]): string {
switch (tone) {
case "accent":
return "border-[#2261CA33] bg-[#0C1829]"
case "muted":
return "border-[#252A31] bg-[#11151A]"
default:
return "border-[#1E232B] bg-[#0F1318]"
}
}
function PluginHeader({ parsed }: { parsed: ParsedPluginDocument }) {
return (
<div className="px-4 pt-4 pb-3 border-b border-white/6 bg-[#14181E]">
<div className="flex flex-wrap items-center gap-2">
<span
className={cn(
dmSansClassName(),
"inline-flex items-center rounded-full border border-[#2261CA33] bg-[#0D1A2E] px-2.5 py-1 text-[11px] font-medium text-[#8FC8FF]",
)}
>
{parsed.pluginLabel}
</span>
<span
className={cn(
dmSansClassName(),
"inline-flex items-center rounded-full border border-[#1F242C] bg-[#0F1318] px-2.5 py-1 text-[11px] font-medium text-[#B7BDC7]",
)}
>
{parsed.formatLabel}
</span>
{parsed.identifierLabel && parsed.identifierValue && (
<span
className={cn(
dmSansClassName(),
"inline-flex items-center rounded-full border border-[#1F242C] bg-[#0F1318] px-2.5 py-1 text-[11px] font-medium text-[#8E97A3]",
)}
>
{parsed.identifierLabel}: {parsed.identifierValue}
</span>
)}
</div>
<p
className={cn(
dmSansClassName(),
"mt-3 text-[18px] font-semibold text-[#FAFAFA] leading-[120%]",
)}
>
{parsed.title}
</p>
<p className="mt-1 text-sm text-[#8E97A3] leading-[150%]">
{parsed.summary}
</p>
</div>
)
}
function ConversationView({ parsed }: { parsed: ParsedPluginDocument }) {
return (
<div className="flex-1 overflow-y-auto scrollbar-thin px-4 py-4 space-y-2">
{parsed.messages.map((message) => (
<div
key={message.id}
className="rounded-[12px] border border-[#1E232B] bg-[#0F1318] px-3 py-2"
>
<p
className={cn(
dmSansClassName(),
"text-[10px] uppercase tracking-[0.08em] text-[#737373]",
)}
>
{roleLabel(message.role)}
</p>
<p className="mt-1 whitespace-pre-wrap text-[13px] leading-[1.55] text-[#F3F4F6]">
{message.text}
</p>
</div>
))}
</div>
)
}
function SectionsView({ parsed }: { parsed: ParsedPluginDocument }) {
return (
<div className="flex-1 overflow-y-auto scrollbar-thin px-4 py-4 space-y-3">
{parsed.sections.map((section, index) => (
<div
key={`${section.label}-${index}`}
className={cn(
"rounded-[16px] border px-4 py-4",
sectionClasses(section.tone),
)}
>
<p
className={cn(
dmSansClassName(),
"text-[12px] font-medium uppercase tracking-[0.08em] text-[#8E97A3]",
)}
>
{section.label}
</p>
<p className="mt-2 whitespace-pre-wrap text-[15px] leading-[1.6] text-[#F1F3F5]">
{section.value}
</p>
</div>
))}
</div>
)
}
function RawView({ parsed }: { parsed: ParsedPluginDocument }) {
return (
<div className="flex-1 overflow-y-auto scrollbar-thin p-4">
<pre className="rounded-[16px] border border-[#1E232B] bg-[#0D1116] p-4 whitespace-pre-wrap break-words text-[13px] leading-[1.6] text-[#C7CDD6]">
{parsed.rawContent}
</pre>
</div>
)
}
export function PluginContent({ parsed }: { parsed: ParsedPluginDocument }) {
const [mode, setMode] = useState<"structured" | "raw">("structured")
const hasMessages = parsed.messages.length > 0
const hideHeader = parsed.kind === "claude-code-doc"
return (
<div className="flex h-full flex-col">
{!hideHeader && <PluginHeader parsed={parsed} />}
<div className={cn("px-4", hideHeader ? "pt-4" : "pt-3")}>
<div
className={cn(
dmSansClassName(),
"inline-flex h-9 items-center gap-0.5 rounded-full border border-[#161F2C] bg-[#0D121A] p-0.5",
)}
>
<button
type="button"
aria-pressed={mode === "structured"}
className={cn(
"inline-flex h-full items-center justify-center rounded-full border px-3 text-xs font-medium cursor-pointer transition-colors",
mode === "structured"
? "border-[#2261CA33] bg-[#00173C] text-white"
: "border-transparent text-[#737373] hover:bg-white/5",
)}
onClick={() => setMode("structured")}
>
{hasMessages ? "Conversation" : "Structured"}
</button>
<button
type="button"
aria-pressed={mode === "raw"}
className={cn(
"inline-flex h-full items-center justify-center rounded-full border px-3 text-xs font-medium cursor-pointer transition-colors",
mode === "raw"
? "border-[#2261CA33] bg-[#00173C] text-white"
: "border-transparent text-[#737373] hover:bg-white/5",
)}
onClick={() => setMode("raw")}
>
Raw
</button>
</div>
</div>
{mode === "raw" ? (
<RawView parsed={parsed} />
) : hasMessages ? (
<ConversationView parsed={parsed} />
) : (
<SectionsView parsed={parsed} />
)}
</div>
)
}

View file

@ -8,6 +8,7 @@ import {
Loader2,
Trash2Icon,
CheckIcon,
CopyIcon,
} from "lucide-react"
import type { z } from "zod"
import * as DialogPrimitive from "@radix-ui/react-dialog"
@ -23,6 +24,8 @@ import { useDocumentMutations } from "@/hooks/use-document-mutations"
import type { UseMutationResult } from "@tanstack/react-query"
import { toast } from "sonner"
import { useIsMobile } from "@hooks/use-mobile"
import { parsePluginDocument } from "@/lib/plugin-document"
import { PluginDetails } from "./plugin-details"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
@ -153,6 +156,39 @@ function DeleteButton({
)
}
function CopySessionIdButton({ sessionId }: { sessionId: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(sessionId)
setCopied(true)
toast.success("Copy session id")
setTimeout(() => setCopied(false), 1500)
} catch {
toast.error("Failed to copy session id")
}
}, [sessionId])
return (
<button
type="button"
onClick={handleCopy}
tabIndex={-1}
title="Copy session id"
aria-label="Copy session id"
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center rounded-full transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none cursor-pointer shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
>
{copied ? (
<CheckIcon className="w-4 h-4 text-green-500" />
) : (
<CopyIcon className="w-4 h-4 text-[#737373]" />
)}
<span className="sr-only">Copy session id</span>
</button>
)
}
export function DocumentModal({
document: _document,
isOpen,
@ -168,6 +204,10 @@ export function DocumentModal({
initialEditorString: content ?? "",
}
}, [_document?.content])
const pluginDocument = useMemo(
() => parsePluginDocument(_document),
[_document],
)
const [draftContentString, setDraftContentString] =
useState(initialEditorString)
@ -253,9 +293,14 @@ export function DocumentModal({
title={_document?.title}
documentType={_document?.type ?? "text"}
url={_document?.url}
pluginIconSrc={pluginDocument?.pluginIconSrc}
/>
</div>
<div className="flex items-center gap-1.5 md:gap-2 shrink-0">
{pluginDocument?.kind === "claude-code-doc" &&
_document?.customId && (
<CopySessionIdButton sessionId={_document.customId} />
)}
<DeleteButton
documentId={_document?.id}
customId={_document?.customId}
@ -298,6 +343,7 @@ export function DocumentModal({
<DocumentContent
document={_document}
textEditorProps={textEditorProps}
pluginDocument={pluginDocument}
/>
</div>
<div
@ -307,10 +353,17 @@ export function DocumentModal({
dmSansClassName(),
)}
>
{_document?.summary && (
{pluginDocument &&
pluginDocument.kind !== "claude-code-doc" &&
pluginDocument.kind !== "openclaw-session" && (
<PluginDetails parsed={pluginDocument} />
)}
{_document && (_document.summary || pluginDocument?.summary) && (
<DocumentSummary
memoryEntries={_document.memoryEntries}
summary={_document.summary}
summary={
(pluginDocument?.summary ?? _document.summary) as string
}
createdAt={_document.createdAt}
/>
)}

View file

@ -0,0 +1,83 @@
import Image from "next/image"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import type { ParsedPluginDocument } from "@/lib/plugin-document"
function DetailPill({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-[12px] border border-[#1E232B] bg-[#0F1318] px-3 py-2">
<p
className={cn(
dmSansClassName(),
"text-[10px] uppercase tracking-[0.08em] text-[#737373]",
)}
>
{label}
</p>
<p className="mt-1 text-[13px] text-[#F3F4F6] break-all">{value}</p>
</div>
)
}
export function PluginDetails({ parsed }: { parsed: ParsedPluginDocument }) {
return (
<div className="bg-[#14161A] p-3 rounded-[14px] space-y-3 shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]">
<div className="flex items-center justify-between gap-2">
<p className="text-[16px] font-semibold text-[#FAFAFA] leading-[125%]">
Details
</p>
<span
className={cn(
dmSansClassName(),
"inline-flex items-center gap-1.5 text-[12px] font-medium text-[#FAFAFA]",
)}
>
{parsed.pluginIconSrc && (
<Image
src={parsed.pluginIconSrc}
alt=""
width={16}
height={16}
className="rounded-[3px]"
aria-hidden
/>
)}
{parsed.pluginLabel}
</span>
</div>
<div className="grid grid-cols-1 gap-2">
<DetailPill label="Format" value={parsed.formatLabel} />
{parsed.identifierLabel && parsed.identifierValue && (
<DetailPill
label={parsed.identifierLabel}
value={parsed.identifierValue}
/>
)}
{parsed.clientLabel && parsed.clientValue && (
<DetailPill label={parsed.clientLabel} value={parsed.clientValue} />
)}
</div>
{parsed.artifacts.length > 0 && (
<div className="space-y-2">
<p
className={cn(
dmSansClassName(),
"text-[11px] font-medium uppercase tracking-[0.08em] text-[#737373]",
)}
>
Outputs
</p>
<div className="space-y-2">
{parsed.artifacts.map((artifact, index) => (
<DetailPill
key={`${artifact.label}-${artifact.value}-${index}`}
label={artifact.label}
value={artifact.value}
/>
))}
</div>
</div>
)}
</div>
)
}

View file

@ -1,3 +1,4 @@
import Image from "next/image"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { DocumentIcon } from "@/components/document-icon"
@ -15,10 +16,12 @@ export function Title({
title,
documentType,
url,
pluginIconSrc,
}: {
title: string | null | undefined
documentType: string
url?: string | null
pluginIconSrc?: string
}) {
const extension = getFileExtension(documentType)
@ -30,7 +33,18 @@ export function Title({
)}
>
<div className="pl-1 flex items-center gap-1 shrink-0">
<DocumentIcon type={documentType} url={url} className="size-5" />
{pluginIconSrc ? (
<Image
src={pluginIconSrc}
alt=""
width={20}
height={20}
className="rounded-[4px]"
aria-hidden
/>
) : (
<DocumentIcon type={documentType} url={url} className="size-5" />
)}
{extension && (
<p
className={cn(dmSansClassName(), "text-[12px] font-semibold")}

View file

@ -14,6 +14,7 @@ import {
MessageCircleIcon,
LifeBuoy,
LayoutGrid,
ChevronRight,
} from "lucide-react"
import { Button } from "@ui/components/button"
import { cn } from "@lib/utils"
@ -131,12 +132,17 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
</DropdownMenuContent>
</DropdownMenu>
{!isMobile && (
<SpaceSelector
selectedProjects={selectedProjects}
onValueChange={setSelectedProjects}
showChevron
enableDelete
/>
<>
<ChevronRight
className="size-4 shrink-0 text-[#3F4853]"
aria-hidden
/>
<SpaceSelector
selectedProjects={selectedProjects}
onValueChange={setSelectedProjects}
enableDelete
/>
</>
)}
</div>
{!isMobile && (
@ -259,7 +265,6 @@ export function Header({ onAddMemory, onOpenSearch }: HeaderProps) {
<SpaceSelector
selectedProjects={selectedProjects}
onValueChange={setSelectedProjects}
showChevron
enableDelete
compact
/>

View file

@ -0,0 +1,166 @@
"use client"
import { useState, type ReactNode } from "react"
import { Check, Copy } from "lucide-react"
import { toast } from "sonner"
import { cn } from "@lib/utils"
import { dmSans125ClassName } from "@/lib/fonts"
import type { InstallStep } from "@/lib/plugin-catalog"
/** Recessed "inside-out" inset shadow used across Supermemory surfaces. */
export const INSET =
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
export function PillButton({
children,
onClick,
disabled,
}: {
children: ReactNode
onClick?: () => void
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
dmSans125ClassName(),
"relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
"text-[12px] font-medium text-[#FAFAFA] sm:text-[14px]",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
"cursor-pointer transition-opacity hover:opacity-80",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{children}
</button>
)
}
export function CopyButton({ text, label }: { text: string; label?: string }) {
const [copied, setCopied] = useState(false)
return (
<button
type="button"
aria-label={`Copy ${label ?? "to clipboard"}`}
onClick={async () => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
toast.success(label ? `${label} copied!` : "Copied!")
} catch {
toast.error("Failed to copy")
}
}}
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80",
INSET,
)}
>
{copied ? (
<Check className="size-3.5 text-[#4BA0FA]" />
) : (
<Copy className="size-3.5 text-[#737373]" />
)}
</button>
)
}
export function CodeBlock({
code,
copyLabel = "Command",
secret,
}: {
code: string
copyLabel?: string
secret?: boolean
}) {
return (
<div className="group flex min-w-0 items-center gap-2 rounded-[10px] border border-white/[0.07] bg-[#0B0E13] px-3 py-2.5">
<pre
className={cn(
"scrollbar-none min-w-0 flex-1 overflow-x-auto whitespace-pre font-mono text-[12px] leading-[1.6] text-[#E4E4E7] transition-[filter] duration-150",
secret &&
"select-none blur-[5px] group-focus-within:select-text group-focus-within:blur-none group-hover:select-text group-hover:blur-none",
)}
>
{code}
</pre>
<CopyButton text={code} label={copyLabel} />
</div>
)
}
export function InstallSteps({
steps,
apiKey,
}: {
steps: InstallStep[]
apiKey?: string
}) {
return (
<ol className="flex min-w-0 flex-col gap-4">
{steps.map((step, i) => (
<li key={step.title} className="flex min-w-0 gap-3">
<div className="flex flex-col items-center gap-1.5">
<span
className={cn(
"flex size-[22px] shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[11px] font-semibold text-[#4BA0FA]",
INSET,
)}
>
{i + 1}
</span>
{i < steps.length - 1 && (
<span className="w-px flex-1 bg-white/[0.14]" />
)}
</div>
<div className="min-w-0 flex-1 space-y-2 pb-1">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<p
className={cn(
dmSans125ClassName(),
"text-[13px] font-medium text-[#FAFAFA]",
)}
>
{step.title}
</p>
{step.optional && (
<span
className={cn(
dmSans125ClassName(),
"shrink-0 rounded-[4px] bg-white/[0.08] px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-[#A1A1AA]",
)}
>
Optional
</span>
)}
</div>
{step.description && (
<p
className={cn(
dmSans125ClassName(),
"text-[12px] leading-relaxed text-[#A1A1AA]",
)}
>
{step.description}
</p>
)}
</div>
{step.code && (
<CodeBlock
code={apiKey ? step.code.replace("sm_...", apiKey) : step.code}
copyLabel={step.copyLabel}
secret={step.secret}
/>
)}
</div>
</li>
))}
</ol>
)
}

View file

@ -8,162 +8,19 @@ import { hasActivePlan } from "@lib/queries"
import { useCustomer } from "autumn-js/react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import {
BookOpen,
Check,
ChevronDown,
Copy,
Loader,
X,
Zap,
} from "lucide-react"
import { BookOpen, Check, ChevronDown, Loader, X, Zap } from "lucide-react"
import Image from "next/image"
import { type ReactNode, useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import { Dialog, DialogContent, DialogTitle } from "@ui/components/dialog"
import { Popover, PopoverContent, PopoverTrigger } from "@ui/components/popover"
/** Recessed "inside-out" inset shadow used across Supermemory surfaces. */
const INSET =
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.3),inset_0_1px_2px_rgba(0,0,0,0.1)]"
/** Match `FREE_TIER_PLUGIN_IDS` in mono `packages/lib/plugins.ts`. */
const FREE_TIER_PLUGIN_IDS = ["hermes", "codex"]
function isFreeTierPlugin(pluginId: string): boolean {
return FREE_TIER_PLUGIN_IDS.includes(pluginId)
}
interface InstallStep {
title: string
description?: string
code?: string
copyLabel?: string
optional?: boolean
/** Blur the code block until hovered/focused (e.g. it contains the key). */
secret?: boolean
}
interface PluginInfo {
id: string
name: string
tagline: string
icon: string
docsUrl?: string
/** Steps shown after a key is minted. The literal `sm_...` is replaced
* with the freshly generated key when rendered. */
installSteps?: InstallStep[]
}
const PLUGIN_CATALOG: Record<string, PluginInfo> = {
claude_code: {
id: "claude_code",
name: "Claude Code",
tagline: "Remembers your conventions, decisions, and project context",
icon: "/images/plugins/claude-code.svg",
docsUrl: "https://docs.supermemory.ai/integrations/claude-code",
installSteps: [
{
title: "Save your API key",
description:
"Add this to your shell profile so Claude Code can authenticate. This key is shown only once — save it now.",
code: 'export SUPERMEMORY_CC_API_KEY="sm_..."',
copyLabel: "API key",
secret: true,
},
{
title: "Install the plugin",
description: "Run these commands inside a Claude Code session:",
code: "/plugin marketplace add supermemoryai/claude-supermemory\n/plugin install claude-supermemory",
},
],
},
codex: {
id: "codex",
name: "Codex",
tagline: "Persistent memory for the Codex CLI — free on every plan",
icon: "/images/plugins/codex.png",
docsUrl: "https://docs.supermemory.ai/integrations/codex",
installSteps: [
{
title: "Save your API key",
description:
"Add this to your shell profile. This key is shown only once — save it now.",
code: 'export SUPERMEMORY_CODEX_API_KEY="sm_..."',
copyLabel: "API key",
secret: true,
},
{
title: "Install the hooks",
description: "Run this to wire Supermemory into Codex CLI:",
code: "npx codex-supermemory@latest install",
},
],
},
opencode: {
id: "opencode",
name: "OpenCode",
tagline: "Long-term memory for your OpenCode sessions",
icon: "/images/plugins/opencode.svg",
docsUrl: "https://docs.supermemory.ai/integrations/opencode",
installSteps: [
{
title: "Save your API key",
description:
"Add this to your shell profile. This key is shown only once — save it now.",
code: 'export SUPERMEMORY_API_KEY="sm_..."',
copyLabel: "API key",
secret: true,
},
{
title: "Install the plugin",
description: "Use --no-tui for non-interactive environments.",
code: "bunx opencode-supermemory@latest install",
},
{
title: "Verify your config",
description:
"Ensure ~/.config/opencode/opencode.jsonc includes the plugin:",
code: '{\n "plugin": ["opencode-supermemory"]\n}',
optional: true,
},
],
},
openclaw: {
id: "openclaw",
name: "OpenClaw",
tagline: "Cross-platform memory across Telegram, Discord, Slack",
icon: "/images/plugins/openclaw.svg",
docsUrl: "https://docs.supermemory.ai/integrations/openclaw",
installSteps: [
{
title: "Install the plugin",
description: "Run this in your OpenClaw project:",
code: "openclaw plugins install @supermemory/openclaw-supermemory",
},
{
title: "Configure Supermemory",
description:
"Run the setup command and paste your API key when prompted:",
code: "openclaw supermemory setup",
},
],
},
hermes: {
id: "hermes",
name: "Hermes",
tagline: "Persistent memory for the Hermes agent — free on every plan",
icon: "/images/plugins/hermes.svg",
docsUrl: "https://docs.supermemory.ai/integrations/hermes",
installSteps: [
{
title: "Run Hermes memory setup",
description:
"On the machine where Hermes is deployed, start the memory wizard, choose Supermemory as the provider, and paste your API key when prompted:",
code: "hermes memory setup",
},
],
},
}
import {
PLUGIN_CATALOG,
isFreeTierPlugin,
type InstallStep,
type PluginInfo,
} from "@/lib/plugin-catalog"
import { INSET, InstallSteps, PillButton } from "./install-steps"
interface ConnectedPlugin {
id: string
@ -222,34 +79,6 @@ function ProChip() {
)
}
function PillButton({
children,
onClick,
disabled,
}: {
children: ReactNode
onClick?: () => void
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
dmSans125ClassName(),
"relative flex h-8 min-w-[94px] shrink-0 items-center justify-center gap-1.5 rounded-full bg-[#0D121A] px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
"text-[12px] font-medium text-[#FAFAFA] sm:text-[14px]",
"shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.7)]",
"cursor-pointer transition-opacity hover:opacity-80",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
>
{children}
</button>
)
}
function DocsLink({ href }: { href: string }) {
return (
<a
@ -472,132 +301,6 @@ function TierFilterToggle({
)
}
function CopyButton({ text, label }: { text: string; label?: string }) {
const [copied, setCopied] = useState(false)
return (
<button
type="button"
aria-label={`Copy ${label ?? "to clipboard"}`}
onClick={async () => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
toast.success(label ? `${label} copied!` : "Copied!")
} catch {
toast.error("Failed to copy")
}
}}
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-full bg-[#0D121A] transition-opacity hover:opacity-80",
INSET,
)}
>
{copied ? (
<Check className="size-3.5 text-[#4BA0FA]" />
) : (
<Copy className="size-3.5 text-[#737373]" />
)}
</button>
)
}
function CodeBlock({
code,
copyLabel = "Command",
secret,
}: {
code: string
copyLabel?: string
secret?: boolean
}) {
return (
<div className="group flex min-w-0 items-center gap-2 rounded-[10px] border border-white/[0.07] bg-[#0B0E13] px-3 py-2.5">
<pre
className={cn(
"scrollbar-none min-w-0 flex-1 overflow-x-auto whitespace-pre font-mono text-[12px] leading-[1.6] text-[#E4E4E7] transition-[filter] duration-150",
secret &&
"select-none blur-[5px] group-focus-within:select-text group-focus-within:blur-none group-hover:select-text group-hover:blur-none",
)}
>
{code}
</pre>
<CopyButton text={code} label={copyLabel} />
</div>
)
}
function InstallSteps({
steps,
apiKey,
}: {
steps: InstallStep[]
apiKey: string
}) {
return (
<ol className="flex min-w-0 flex-col gap-4">
{steps.map((step, i) => (
<li key={step.title} className="flex min-w-0 gap-3">
<div className="flex flex-col items-center gap-1.5">
<span
className={cn(
"flex size-[22px] shrink-0 items-center justify-center rounded-full bg-[#0D121A] text-[11px] font-semibold text-[#4BA0FA]",
INSET,
)}
>
{i + 1}
</span>
{i < steps.length - 1 && (
<span className="w-px flex-1 bg-white/[0.14]" />
)}
</div>
<div className="min-w-0 flex-1 space-y-2 pb-1">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<p
className={cn(
dmSans125ClassName(),
"text-[13px] font-medium text-[#FAFAFA]",
)}
>
{step.title}
</p>
{step.optional && (
<span
className={cn(
dmSans125ClassName(),
"shrink-0 rounded-[4px] bg-white/[0.08] px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-[#A1A1AA]",
)}
>
Optional
</span>
)}
</div>
{step.description && (
<p
className={cn(
dmSans125ClassName(),
"text-[12px] leading-relaxed text-[#A1A1AA]",
)}
>
{step.description}
</p>
)}
</div>
{step.code && (
<CodeBlock
code={apiKey ? step.code.replace("sm_...", apiKey) : step.code}
copyLabel={step.copyLabel}
secret={step.secret}
/>
)}
</div>
</li>
))}
</ol>
)
}
export function PluginsDetail() {
const { org } = useAuth()
const autumn = useCustomer()

View file

@ -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<HTMLButtonElement>(null)
const [ogData, setOgData] = useState<OgData | null>(null)
@ -1054,7 +1063,11 @@ const DocumentCard = memo(
{isSelectionMode && isSelected && (
<div className="absolute inset-0 bg-[rgba(75,160,250,0.25)] rounded-[22px] z-1 pointer-events-none" />
)}
<ContentPreview document={document} ogData={ogData} />
<ContentPreview
document={document}
ogData={ogData}
parsed={pluginDocument}
/>
{!(
document.type === "image" ||
document.type === "notion_doc" ||
@ -1129,11 +1142,20 @@ const DocumentCard = memo(
"text-[11px] text-[#737373] line-clamp-1",
)}
>
{new Date(document.createdAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
{(() => {
const badge =
pluginDocument?.kind === "claude-code-doc"
? claudeCodeTokenBadge(document)
: null
const date = new Date(
document.createdAt,
).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
return badge ? `${badge} · ${date}` : date
})()}
</p>
</div>
</div>
@ -1149,9 +1171,11 @@ DocumentCard.displayName = "DocumentCard"
function ContentPreview({
document,
ogData,
parsed,
}: {
document: DocumentWithMemories
ogData?: OgData | null
parsed?: ParsedPluginDocument | null
}) {
if (
document.url?.includes("https://docs.googleapis.com/v1/documents") ||
@ -1175,11 +1199,11 @@ function ContentPreview({
document.url?.includes("x.com/") ||
document.url?.includes("twitter.com/")
) {
return <NotePreview document={document} />
return <NotePreview document={document} parsed={parsed} />
}
if (document.source === "mcp") {
return <McpPreview document={document} />
return <McpPreview document={document} parsed={parsed} />
}
if (isYouTubeUrl(document.url)) {
@ -1204,5 +1228,5 @@ function ContentPreview({
}
// Default to Note
return <NotePreview document={document} />
return <NotePreview document={document} parsed={parsed} />
}

View file

@ -1,18 +1,45 @@
"use client"
import { useState, useMemo, useEffect } from "react"
import Image from "next/image"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
import { cn } from "@lib/utils"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon, Search, Check } from "lucide-react"
import { Button } from "@ui/components/button"
import {
XIcon,
Search,
FolderIcon,
LayoutGrid,
Plus,
Trash2,
Clock,
ArrowRight,
BookOpen,
Loader,
} from "lucide-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import { authClient } from "@lib/auth"
import { useAuth } from "@lib/auth-context"
import type { ContainerTagListType } from "@lib/types"
import {
compareSpacesUserFirst,
spaceSelectorDisplayName,
} from "@/lib/ingest-auto-space"
import {
detectPluginSpace,
pluginInitial,
type PluginSpaceInfo,
} from "@/lib/plugin-space"
import { usePluginSpaceMeta } from "@/hooks/use-plugin-space-meta"
import {
PLUGIN_CATALOG,
spacePluginIdToCatalogId,
type PluginInfo,
} from "@/lib/plugin-catalog"
import { InstallSteps, PillButton } from "./integrations/install-steps"
interface SelectSpacesModalProps {
isOpen: boolean
@ -20,7 +47,29 @@ interface SelectSpacesModalProps {
selectedProjects: string[]
onApply: (selected: string[]) => void
projects: ContainerTagListType[]
singleSelect?: boolean
recents?: string[]
showNewSpace?: boolean
onNewSpace?: () => void
enableDelete?: boolean
onDeleteRequest?: (project: {
id: string
name: string
containerTag: string
}) => void
}
type CategoryId =
| "all"
| "my"
| `plugin:${PluginSpaceInfo["pluginId"]}`
| `discover:${string}`
type Category = {
id: CategoryId
label: string
iconSrc: string | null
emoji: string | null
count: number
}
export function SelectSpacesModal({
@ -29,51 +78,26 @@ export function SelectSpacesModal({
selectedProjects,
onApply,
projects,
singleSelect = false,
recents,
showNewSpace = false,
onNewSpace,
enableDelete = false,
onDeleteRequest,
}: SelectSpacesModalProps) {
const [searchQuery, setSearchQuery] = useState("")
const [localSelection, setLocalSelection] =
useState<string[]>(selectedProjects)
const currentSelection = selectedProjects[0] ?? ""
useEffect(() => {
if (isOpen) {
setLocalSelection(selectedProjects)
}
}, [isOpen, selectedProjects])
const pluginTags = useMemo(
() =>
projects
.filter((p) => !!detectPluginSpace(p.containerTag))
.map((p) => p.containerTag),
[projects],
)
const handleOpenChange = (open: boolean) => {
if (!open) {
onClose()
setSearchQuery("")
setLocalSelection(selectedProjects)
}
}
const pluginMetaMap = usePluginSpaceMeta(pluginTags)
const handleToggle = (containerTag: string) => {
if (singleSelect) {
setLocalSelection([containerTag])
return
}
setLocalSelection((prev) => {
if (prev.includes(containerTag)) {
return prev.filter((tag) => tag !== containerTag)
}
return [...prev, containerTag]
})
}
const handleApply = () => {
onApply(localSelection)
setSearchQuery("")
}
const handleCancel = () => {
onClose()
setSearchQuery("")
setLocalSelection(selectedProjects)
}
const filteredProjects = useMemo(() => {
const allSpaces = useMemo(() => {
const defaultSpace = {
id: "default",
name: "My Space",
@ -84,28 +108,363 @@ export function SelectSpacesModal({
createdAt: "",
updatedAt: "",
} as ContainerTagListType
const rest = projects
.filter((p) => p.containerTag !== DEFAULT_PROJECT_ID)
.sort(compareSpacesUserFirst)
return [defaultSpace, ...rest]
}, [projects])
const allSpaces = [defaultSpace, ...rest]
if (!searchQuery.trim()) {
return allSpaces
const { categories, connectedCatalogIds } = useMemo<{
categories: Category[]
connectedCatalogIds: Set<string>
}>(() => {
const pluginCounts = new Map<
PluginSpaceInfo["pluginId"],
{ label: string; iconSrc: string | null; count: number }
>()
let myCount = 0
for (const p of allSpaces) {
const plugin = detectPluginSpace(p.containerTag)
if (plugin) {
const prev = pluginCounts.get(plugin.pluginId)
pluginCounts.set(plugin.pluginId, {
label: plugin.label,
iconSrc: plugin.iconSrc,
count: (prev?.count ?? 0) + 1,
})
} else {
myCount += 1
}
}
const pluginCats: Category[] = Array.from(pluginCounts.entries())
.map(([id, info]) => ({
id: `plugin:${id}` as CategoryId,
label: info.label,
iconSrc: info.iconSrc,
emoji: null,
count: info.count,
}))
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label))
const connectedIds = new Set<string>()
for (const pluginId of pluginCounts.keys()) {
const catalogId = spacePluginIdToCatalogId(pluginId)
if (catalogId) connectedIds.add(catalogId)
}
return {
categories: [
{
id: "all",
label: "All Spaces",
iconSrc: null,
emoji: null,
count: allSpaces.length,
},
{
id: "my",
label: "My Spaces",
iconSrc: null,
emoji: "📁",
count: myCount,
},
...pluginCats,
],
connectedCatalogIds: connectedIds,
}
}, [allSpaces])
const defaultCategory = useMemo<CategoryId>(() => {
if (!currentSelection) return "all"
const plugin = detectPluginSpace(currentSelection)
if (plugin) return `plugin:${plugin.pluginId}`
return "my"
}, [currentSelection])
const [activeCategory, setActiveCategory] =
useState<CategoryId>(defaultCategory)
useEffect(() => {
if (isOpen) setActiveCategory(defaultCategory)
}, [isOpen, defaultCategory])
const { org } = useAuth()
const queryClient = useQueryClient()
const [connectingPluginId, setConnectingPluginId] = useState<string | null>(
null,
)
const [newKey, setNewKey] = useState<{
pluginId: string
key: string
} | null>(null)
const { data: availablePluginsData } = useQuery({
queryKey: ["plugins"],
queryFn: async () => {
const API_URL =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const res = await fetch(`${API_URL}/v3/auth/plugins`, {
credentials: "include",
})
if (!res.ok) throw new Error("Failed to fetch plugins")
return (await res.json()) as { plugins: string[] }
},
staleTime: 5 * 60 * 1000,
enabled: isOpen,
})
const { data: apiKeys = [] } = useQuery({
queryKey: ["api-keys", org?.id],
enabled: isOpen && !!org?.id,
queryFn: async () => {
if (!org?.id) return []
const data = await authClient.apiKey.list({
fetchOptions: { query: { metadata: { organizationId: org.id } } },
})
return data.filter((key) => key.metadata?.organizationId === org.id)
},
})
const apiKeyConnectedIds = useMemo(() => {
const ids = new Set<string>()
for (const key of apiKeys) {
if (!key.metadata) continue
try {
const metadata =
typeof key.metadata === "string"
? (JSON.parse(key.metadata) as {
sm_type?: string
sm_client?: string
})
: (key.metadata as { sm_type?: string; sm_client?: string })
if (metadata.sm_type === "plugin_auth" && metadata.sm_client) {
ids.add(metadata.sm_client)
}
} catch {}
}
return ids
}, [apiKeys])
const discoverCategories = useMemo<Category[]>(() => {
const availableIds =
availablePluginsData?.plugins ?? Object.keys(PLUGIN_CATALOG)
return availableIds
.filter((id) => !!PLUGIN_CATALOG[id])
.filter(
(id) => !apiKeyConnectedIds.has(id) && !connectedCatalogIds.has(id),
)
.map((id) => {
const info = PLUGIN_CATALOG[id] as PluginInfo
return {
id: `discover:${id}` as CategoryId,
label: info.name,
iconSrc: info.icon,
emoji: null,
count: 0,
}
})
}, [availablePluginsData, apiKeyConnectedIds, connectedCatalogIds])
const connectMutation = useMutation({
mutationFn: async (pluginId: string) => {
const API_URL =
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai"
const params = new URLSearchParams({ client: pluginId })
const res = await fetch(`${API_URL}/v3/auth/key?${params}`, {
credentials: "include",
})
if (!res.ok) {
if (res.status === 403) {
throw new Error(
"This plugin requires a Pro plan. Hermes is available on the Free plan.",
)
}
const errorData = (await res.json().catch(() => ({}))) as {
message?: string
}
throw new Error(errorData.message || "Failed to create plugin key")
}
return (await res.json()) as { key: string }
},
onMutate: (pluginId) => setConnectingPluginId(pluginId),
onError: (err) => {
toast.error("Failed to connect plugin", {
description: err instanceof Error ? err.message : "Unknown error",
})
},
onSettled: () => {
setConnectingPluginId(null)
queryClient.invalidateQueries({ queryKey: ["api-keys", org?.id] })
},
onSuccess: (data, pluginId) => {
setNewKey({ pluginId, key: data.key })
toast.success("Plugin connected!")
},
})
useEffect(() => {
if (!isOpen) {
setNewKey(null)
}
}, [isOpen])
const handleOpenChange = (open: boolean) => {
if (!open) {
onClose()
setSearchQuery("")
}
}
const handleSelect = (containerTag: string) => {
onApply([containerTag])
setSearchQuery("")
}
const filteredProjects = useMemo(() => {
const byCategory = allSpaces.filter((p) => {
if (activeCategory === "all") return true
const plugin = detectPluginSpace(p.containerTag)
if (activeCategory === "my") return !plugin
return plugin && `plugin:${plugin.pluginId}` === activeCategory
})
if (!searchQuery.trim()) return byCategory
const query = searchQuery.trim().toLowerCase()
return allSpaces.filter(
(p) =>
return byCategory.filter((p) => {
const plugin = detectPluginSpace(p.containerTag)
const projectName = pluginMetaMap.get(p.containerTag)?.projectName
return (
p.containerTag.toLowerCase().includes(query) ||
(p.name ?? "").toLowerCase().includes(query),
(p.name ?? "").toLowerCase().includes(query) ||
(plugin?.label.toLowerCase().includes(query) ?? false) ||
(plugin?.projectId?.toLowerCase().includes(query) ?? false) ||
(projectName?.toLowerCase().includes(query) ?? false)
)
})
}, [allSpaces, activeCategory, searchQuery, pluginMetaMap])
const recentProjects = useMemo<ContainerTagListType[]>(() => {
if (!recents?.length) return []
if (searchQuery.trim()) return []
if (activeCategory !== "all") return []
const byTag = new Map(allSpaces.map((p) => [p.containerTag, p]))
const out: ContainerTagListType[] = []
for (const tag of recents) {
const p = byTag.get(tag)
if (p) out.push(p)
if (out.length >= 5) break
}
return out
}, [recents, searchQuery, activeCategory, allSpaces])
const recentSet = useMemo(
() => new Set(recentProjects.map((p) => p.containerTag)),
[recentProjects],
)
const mainList = useMemo(
() =>
recentSet.size > 0
? filteredProjects.filter((p) => !recentSet.has(p.containerTag))
: filteredProjects,
[filteredProjects, recentSet],
)
const renderRow = (project: ContainerTagListType) => {
const isSelected = currentSelection === project.containerTag
const plugin = detectPluginSpace(project.containerTag)
const pluginProjectName = pluginMetaMap.get(
project.containerTag,
)?.projectName
const pluginIdLabel = pluginProjectName || plugin?.projectId
const isDefault = project.containerTag === DEFAULT_PROJECT_ID
return (
<div
key={project.containerTag}
className={cn(
"group flex min-w-0 max-w-full items-center gap-3 w-full px-3 py-2.5 rounded-[12px] transition-colors",
isSelected
? "bg-[#14161A] shadow-inside-out"
: "hover:bg-[#14161A]/50",
)}
>
<button
type="button"
onClick={() => handleSelect(project.containerTag)}
className="flex min-w-0 flex-1 items-center gap-3 text-left cursor-pointer focus:outline-none focus:ring-0"
>
<div
className={cn(
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
isSelected ? "border-[#4BA0FA]" : "border-[#737373]",
)}
>
{isSelected && (
<div className="w-2 h-2 rounded-full bg-[#4BA0FA]" />
)}
</div>
{plugin ? (
plugin.iconSrc ? (
<Image
src={plugin.iconSrc}
alt=""
width={20}
height={20}
className="shrink-0 rounded-[4px]"
aria-hidden
/>
) : (
<span
className="shrink-0 flex items-center justify-center w-5 h-5 rounded-[4px] bg-[#1E232B] text-[#FAFAFA] text-[11px] font-semibold uppercase"
aria-hidden
>
{pluginInitial(plugin.label)}
</span>
)
) : (
<span className="shrink-0 text-lg">{project.emoji || "📁"}</span>
)}
<span
className="min-w-0 flex-1 truncate text-[#fafafa] text-sm font-medium"
title={project.containerTag}
>
{plugin ? (
<>
{plugin.label}
{pluginIdLabel && (
<span className="ml-1.5 text-[12px] text-[#737373]">
· {pluginIdLabel}
</span>
)}
</>
) : (
spaceSelectorDisplayName(project, project.containerTag)
)}
</span>
</button>
{enableDelete && !isDefault && onDeleteRequest && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDeleteRequest({
id: project.id,
name: project.name,
containerTag: project.containerTag,
})
}}
aria-label="Delete space"
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity p-1.5 rounded-full hover:bg-red-500/15 cursor-pointer focus:outline-none"
>
<Trash2 className="size-3.5 text-red-400" />
</button>
)}
</div>
)
}, [projects, searchQuery])
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent
className={cn(
"w-[90%]! max-w-[500px]! border-none bg-[#1B1F24] flex flex-col p-4 gap-4 rounded-[22px]",
"w-[92%]! max-w-[720px]! border-none bg-[#1B1F24] flex flex-col p-0 gap-0 rounded-[22px] overflow-hidden",
dmSansClassName(),
)}
style={{
@ -114,143 +473,362 @@ export function SelectSpacesModal({
}}
showCloseButton={false}
>
<div className="flex flex-col gap-4">
<div className="flex justify-between items-start gap-4">
<div className="pl-1 space-y-1 flex-1">
<p
className={cn(
"font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Select Space{!singleSelect && "s"}
</p>
<p className="text-[#737373] font-medium text-[16px] leading-[1.35]">
{singleSelect
? "Choose a space for your memory"
: "Choose one or more spaces to filter your memories"}
</p>
</div>
<DialogPrimitive.Close
className="bg-[#0D121A] size-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-[#737373]" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search spaces..."
<div className="flex items-start justify-between gap-4 px-4 pt-4">
<div className="pl-1 space-y-1 flex-1">
<p
className={cn(
"w-full bg-[#14161A] border border-[rgba(82,89,102,0.2)] pl-10 pr-4 py-3 rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(115,115,115,0.3)]",
dmSansClassName(),
"font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
style={{
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
}}
autoFocus
/>
>
Select Space
</p>
<p className="text-[#737373] font-medium text-[14px] leading-[1.35]">
Filter your memories by space
</p>
</div>
<DialogPrimitive.Close
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
style={{
boxShadow: "inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<XIcon stroke="#737373" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</div>
<div className="max-h-[300px] overflow-y-auto space-y-1 scrollbar-thin">
{filteredProjects.length === 0 ? (
<p className="text-center text-[#737373] text-sm py-4">
No spaces found
</p>
) : (
filteredProjects.map((project) => {
const isSelected = localSelection.includes(project.containerTag)
<div className="mt-4 flex min-h-[420px] gap-3 px-4 pb-4">
<div className="w-[200px] shrink-0 overflow-y-auto scrollbar-thin pr-1">
<div className="flex flex-col gap-1">
{categories.map((category) => {
const isActive = activeCategory === category.id
return (
<button
key={project.containerTag}
key={category.id}
type="button"
onClick={() => handleToggle(project.containerTag)}
onClick={() => setActiveCategory(category.id)}
className={cn(
"flex min-w-0 max-w-full items-center gap-3 w-full px-3 py-2.5 rounded-[12px] cursor-pointer transition-colors text-left",
isSelected
? "bg-[#14161A] border border-[rgba(82,89,102,0.3)]"
: "bg-transparent border border-transparent hover:bg-[#14161A]/50",
"flex items-center gap-2.5 px-3 py-2 rounded-[12px] text-left transition-colors cursor-pointer focus:outline-none focus:ring-0",
isActive
? "bg-[#14161A] shadow-inside-out text-[#fafafa]"
: "text-[#A1A1AA] hover:bg-[#14161A]/50 hover:text-[#fafafa]",
dmSansClassName(),
)}
>
{singleSelect ? (
<div
className={cn(
"size-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
isSelected ? "border-blue-500" : "border-[#737373]",
)}
>
{isSelected && (
<div className="size-2.5 rounded-full bg-blue-500" />
)}
</div>
) : (
<div
className={cn(
"size-5 rounded-md border-2 flex items-center justify-center shrink-0 transition-colors",
isSelected
? "bg-blue-500 border-blue-500"
: "border-[#737373]",
)}
>
{isSelected && <Check className="size-3 text-white" />}
</div>
)}
<span className="shrink-0 text-lg">
{project.emoji || "📁"}
<span className="shrink-0 w-5 h-5 flex items-center justify-center">
{category.id === "all" ? (
<LayoutGrid
className={cn(
"size-4",
isActive ? "text-[#fafafa]" : "text-[#737373]",
)}
/>
) : category.iconSrc ? (
<Image
src={category.iconSrc}
alt=""
width={18}
height={18}
className="rounded-[3px]"
aria-hidden
/>
) : category.emoji ? (
<span className="text-base">{category.emoji}</span>
) : category.id.startsWith("plugin:") ? (
<span
className="w-[18px] h-[18px] flex items-center justify-center rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase"
aria-hidden
>
{pluginInitial(category.label)}
</span>
) : (
<FolderIcon
className={cn(
"size-4",
isActive ? "text-[#fafafa]" : "text-[#737373]",
)}
/>
)}
</span>
<span
className="min-w-0 flex-1 truncate text-[#fafafa] text-sm font-medium"
title={project.name ?? project.containerTag}
>
{spaceSelectorDisplayName(project, project.containerTag)}
<span className="flex-1 min-w-0 truncate text-[14px] font-medium">
{category.label}
</span>
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
{category.count}
</span>
</button>
)
})
)}
})}
{discoverCategories.length > 0 && (
<>
<div className="mt-2 px-3 pt-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
Discover
</div>
{discoverCategories.map((category) => {
const isActive = activeCategory === category.id
return (
<button
key={category.id}
type="button"
onClick={() => setActiveCategory(category.id)}
className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-[12px] text-left transition-colors cursor-pointer focus:outline-none focus:ring-0",
isActive
? "bg-[#14161A] shadow-inside-out text-[#fafafa] opacity-100"
: "opacity-55 hover:opacity-100 hover:bg-[#14161A]/50 text-[#A1A1AA] hover:text-[#fafafa]",
dmSansClassName(),
)}
>
<span className="shrink-0 w-5 h-5 flex items-center justify-center">
{category.iconSrc ? (
<Image
src={category.iconSrc}
alt=""
width={18}
height={18}
className="rounded-[3px]"
aria-hidden
/>
) : (
<span
className="w-[18px] h-[18px] flex items-center justify-center rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase"
aria-hidden
>
{pluginInitial(category.label)}
</span>
)}
</span>
<span className="flex-1 min-w-0 truncate text-[14px] font-medium">
{category.label}
</span>
<ArrowRight className="size-3.5 shrink-0 text-[#737373]" />
</button>
)
})}
</>
)}
</div>
</div>
<div className="flex items-center justify-between">
<p className="text-[#737373] text-sm">
{singleSelect
? localSelection.length === 0
? "No space selected"
: "1 space selected"
: localSelection.length === 0
? "No spaces selected (showing all)"
: `${localSelection.length} space${localSelection.length > 1 ? "s" : ""} selected`}
</p>
<div className="flex items-center gap-[22px]">
<div className="flex-1 flex flex-col min-w-0 gap-3">
{activeCategory.startsWith("discover:") ? (
<DiscoverPanel
catalogId={activeCategory.slice("discover:".length)}
isConnecting={
connectingPluginId ===
activeCategory.slice("discover:".length)
}
newKey={
newKey?.pluginId === activeCategory.slice("discover:".length)
? newKey.key
: null
}
onConnect={() =>
connectMutation.mutate(
activeCategory.slice("discover:".length),
)
}
onDismissKey={() => setNewKey(null)}
/>
) : (
<>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-[#737373]" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search spaces..."
className={cn(
"w-full bg-[#14161A] shadow-inside-out pl-10 pr-4 py-2.5 rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#737373] focus:outline-none",
dmSansClassName(),
)}
autoFocus
/>
</div>
<div className="flex-1 overflow-y-auto scrollbar-thin max-h-[360px] pr-1">
{filteredProjects.length === 0 ? (
<p className="text-center text-[#737373] text-sm py-8">
No spaces found
</p>
) : (
<div className="flex flex-col gap-1">
{recentProjects.length > 0 && (
<>
<div className="flex items-center gap-1.5 px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
<Clock className="size-3" />
Recently used
</div>
{recentProjects.map(renderRow)}
<div className="my-1.5 h-px bg-[rgba(82,89,102,0.18)]" />
<div className="px-3 pt-0.5 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
All spaces
</div>
</>
)}
{mainList.map(renderRow)}
</div>
)}
</div>
</>
)}
</div>
</div>
{showNewSpace &&
onNewSpace &&
!activeCategory.startsWith("discover:") && (
<div className="flex items-center justify-end border-t border-[rgba(82,89,102,0.18)] px-4 py-3">
<button
type="button"
onClick={handleCancel}
onClick={onNewSpace}
className={cn(
"text-[#737373] font-medium text-[14px] cursor-pointer transition-colors hover:text-[#999]",
"flex items-center gap-2 px-4 py-2 rounded-full text-[13px] font-medium text-[#fafafa] bg-[#14161A] shadow-inside-out hover:bg-[#121820] transition-colors cursor-pointer focus:outline-none focus:ring-0",
dmSansClassName(),
)}
>
Cancel
<Plus className="size-4" />
New space
</button>
<Button
variant="insideOut"
onClick={handleApply}
className="px-4 py-[10px] rounded-full"
>
Apply
</Button>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}
function DiscoverPanel({
catalogId,
isConnecting,
newKey,
onConnect,
onDismissKey,
}: {
catalogId: string
isConnecting: boolean
newKey: string | null
onConnect: () => void
onDismissKey: () => void
}) {
const info = PLUGIN_CATALOG[catalogId]
if (!info) {
return (
<p className="text-[#737373] text-sm py-8 text-center">
Plugin info unavailable.
</p>
)
}
const pluginSteps = info.installSteps ?? []
const stepsEmbedKey = pluginSteps.some((s) => s.code?.includes("sm_..."))
const setupSteps = stepsEmbedKey
? pluginSteps
: [
{
title: "Copy your API key",
description:
"You won't be able to see it again — store it somewhere safe.",
code: newKey ?? "sm_...",
copyLabel: "API key",
secret: true,
},
...pluginSteps,
]
const isConnected = !!newKey
return (
<div className="flex-1 overflow-y-auto scrollbar-thin pr-1 flex flex-col gap-4">
<div className="flex items-start gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-[10px] border border-[#1E293B] bg-[#080B0F]">
<Image
alt={info.name}
className="size-7"
height={28}
src={info.icon}
width={28}
/>
</div>
<div className="flex-1 min-w-0">
<p
className={cn(
dmSans125ClassName(),
"font-semibold text-[16px] text-[#FAFAFA]",
)}
>
{info.name}
</p>
<p className="text-[13px] text-[#737373] leading-[1.4] mt-1">
{info.tagline}
</p>
</div>
</div>
{isConnected && (
<div className="flex items-center justify-between gap-2">
<p className="text-[13px] font-medium text-[#FAFAFA]">
Plugin connected finish setup
</p>
<button
type="button"
onClick={onDismissKey}
className="text-[#737373] hover:text-[#FAFAFA] cursor-pointer"
aria-label="Dismiss"
>
<XIcon className="size-4" />
</button>
</div>
)}
{isConnected && (
<p className="text-[12px] text-[#737373]">
Your API key is shown once. Hover or focus the blurred command to
reveal it.
</p>
)}
<div className="relative">
<div
className={cn(
"transition-[filter] duration-200",
!isConnected && "blur-[6px] pointer-events-none select-none",
)}
aria-hidden={!isConnected}
>
<InstallSteps steps={setupSteps} apiKey={newKey ?? undefined} />
</div>
{!isConnected && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3">
<PillButton onClick={onConnect} disabled={isConnecting}>
{isConnecting ? (
<>
<Loader className="size-3.5 animate-spin" /> Connecting
</>
) : (
`Connect ${info.name}`
)}
</PillButton>
{info.docsUrl && (
<a
href={info.docsUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
dmSans125ClassName(),
"flex h-8 min-w-[94px] items-center justify-center gap-1.5 rounded-full px-3 sm:h-9 sm:min-w-[116px] sm:px-5",
"text-[12px] font-medium text-[#A1A1AA] sm:text-[14px]",
"transition-colors hover:text-[#FAFAFA]",
)}
>
<BookOpen className="size-3.5" /> Docs
</a>
)}
</div>
)}
</div>
</div>
)
}

View file

@ -1,10 +1,13 @@
"use client"
import { useState, useMemo } from "react"
import { useState, useMemo, useEffect, useCallback } from "react"
import Image from "next/image"
import { useQuery } from "@tanstack/react-query"
import { cn } from "@lib/utils"
import { $fetch } from "@lib/api"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { DEFAULT_PROJECT_ID } from "@lib/constants"
import { ChevronDown, Plus, Trash2, XIcon, Loader2, Layers } from "lucide-react"
import { XIcon, Loader2 } from "lucide-react"
import type { ContainerTagListType } from "@lib/types"
import { AddSpaceModal } from "./add-space-modal"
import { SelectSpacesModal } from "./select-spaces-modal"
@ -12,13 +15,6 @@ import { useProjectMutations } from "@/hooks/use-project-mutations"
import { useContainerTags } from "@/hooks/use-container-tags"
import { motion } from "motion/react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@ui/components/dropdown-menu"
import {
Dialog,
DialogContent,
@ -33,50 +29,79 @@ import {
SelectValue,
} from "@repo/ui/components/select"
import { Button } from "@repo/ui/components/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
import { analytics } from "@/lib/analytics"
import {
compareSpacesUserFirst,
spaceSelectorDisplayName,
} from "@/lib/ingest-auto-space"
import { detectPluginSpace, pluginInitial } from "@/lib/plugin-space"
import { usePluginSpaceMeta } from "@/hooks/use-plugin-space-meta"
export interface SpaceSelectorProps {
selectedProjects: string[]
onValueChange: (containerTags: string[]) => void
variant?: "default" | "insideOut"
showChevron?: boolean
triggerClassName?: string
contentClassName?: string
showNewSpace?: boolean
enableDelete?: boolean
compact?: boolean
singleSelect?: boolean
}
const triggerVariants = {
default:
"h-10 min-h-10 shrink-0 rounded-full border border-[#161F2C] bg-muted px-3 gap-2 " +
"hover:bg-white/5 " +
"data-[state=open]:border-[#2261CA33] data-[state=open]:bg-[#00173C]/35 " +
"hover:bg-white/5 hover:border-[#2261CA33] " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2261CA33]/35",
insideOut:
"h-10 min-h-10 gap-2 px-3 rounded-full bg-[#0D121A] shadow-inside-out hover:bg-[#121820]",
}
const RECENTS_KEY = "nova:space-selector:recents"
const RECENTS_MAX = 10
function readRecents(): string[] {
if (typeof window === "undefined") return []
try {
const raw = window.localStorage.getItem(RECENTS_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed)
? parsed.filter((x) => typeof x === "string")
: []
} catch {
return []
}
}
function writeRecents(tags: string[]) {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(RECENTS_KEY, JSON.stringify(tags))
} catch {
// ignore
}
}
function formatCount(n: number): string {
if (n < 1000) return String(n)
if (n < 10_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`
if (n < 1_000_000) return `${Math.floor(n / 1000)}k`
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`
}
export function SpaceSelector({
selectedProjects,
onValueChange,
variant = "default",
showChevron = false,
triggerClassName,
contentClassName,
showNewSpace = true,
enableDelete = false,
compact = false,
singleSelect = false,
}: SpaceSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [showSelectSpacesModal, setShowSelectSpacesModal] = useState(false)
const [recents, setRecents] = useState<string[]>([])
const [deleteDialog, setDeleteDialog] = useState<{
open: boolean
project: { id: string; name: string; containerTag: string } | null
@ -90,90 +115,114 @@ export function SpaceSelector({
})
const { deleteProjectMutation } = useProjectMutations()
const { allProjects, isLoading } = useContainerTags()
const sortedOtherSpaces = useMemo(
useEffect(() => {
setRecents(readRecents())
}, [])
const activeTag = selectedProjects[0] ?? DEFAULT_PROJECT_ID
const { data: spaceCountData } = useQuery({
queryKey: ["space-selector-count", activeTag],
queryFn: async (): Promise<number> => {
const response = await $fetch("@post/documents/documents", {
body: {
page: 1,
limit: 1,
sort: "createdAt",
order: "desc",
containerTags: [activeTag],
},
disableValidation: true,
})
if (response.error) return 0
const data = response.data as {
pagination?: { totalItems?: number }
} | null
return data?.pagination?.totalItems ?? 0
},
staleTime: 30 * 1000,
enabled: !!activeTag,
})
const pluginTags = useMemo(
() =>
allProjects
.filter(
(p: ContainerTagListType) => p.containerTag !== DEFAULT_PROJECT_ID,
(p: ContainerTagListType) => !!detectPluginSpace(p.containerTag),
)
.sort(compareSpacesUserFirst),
.map((p: ContainerTagListType) => p.containerTag),
[allProjects],
)
const pluginMetaMap = usePluginSpaceMeta(pluginTags)
const displayInfo = useMemo(() => {
if (selectedProjects.length === 1) {
const containerTag = selectedProjects[0] ?? ""
if (containerTag === DEFAULT_PROJECT_ID) {
return { name: "My Space", emoji: "📁", isMultiple: false }
}
const found = allProjects.find(
(p: ContainerTagListType) => p.containerTag === containerTag,
)
return {
name: spaceSelectorDisplayName(found, containerTag),
emoji: found?.emoji || "📁",
isMultiple: false,
}
const displayInfo = useMemo<{
name: string
emoji: string | null
plugin: ReturnType<typeof detectPluginSpace>
}>(() => {
const containerTag = selectedProjects[0] ?? ""
if (!containerTag || containerTag === DEFAULT_PROJECT_ID) {
return { name: "My Space", emoji: "📁", plugin: null }
}
if (selectedProjects.length > 1) {
return {
name: `${selectedProjects.length} spaces`,
emoji: null,
isMultiple: true,
}
const found = allProjects.find(
(p: ContainerTagListType) => p.containerTag === containerTag,
)
const plugin = detectPluginSpace(containerTag)
const projectName = pluginMetaMap.get(containerTag)?.projectName
const idForLabel = projectName || plugin?.projectId
return {
name: plugin
? idForLabel
? `${plugin.label} · ${idForLabel}`
: plugin.label
: spaceSelectorDisplayName(found, containerTag),
emoji: found?.emoji || "📁",
plugin,
}
}, [allProjects, selectedProjects, pluginMetaMap])
return { name: "My Space", emoji: "📁", isMultiple: false }
}, [allProjects, selectedProjects])
const handleSelectSingleSpace = (containerTag: string) => {
analytics.spaceSwitched({ space_id: containerTag })
onValueChange([containerTag])
setIsOpen(false)
}
const handleOpenSelectSpaces = () => {
setIsOpen(false)
setShowSelectSpacesModal(true)
}
const handleSelectSpacesApply = (selected: string[]) => {
if (selected.length > 0) {
analytics.spaceSwitched({
space_id:
selected.length === 1 ? (selected[0] ?? "unknown") : "multiple",
})
}
onValueChange(selected)
setShowSelectSpacesModal(false)
}
const handleNewSpace = () => {
setIsOpen(false)
setShowCreateDialog(true)
}
const handleDeleteClick = (
e: React.MouseEvent,
project: { id: string; name: string; containerTag: string },
) => {
e.stopPropagation()
e.preventDefault()
setDeleteDialog({
open: true,
project,
action: "move",
targetProjectId: "",
const pushRecent = useCallback((tag: string) => {
setRecents((prev) => {
const next = [tag, ...prev.filter((t) => t !== tag)].slice(0, RECENTS_MAX)
writeRecents(next)
return next
})
}
}, [])
const handleSelectSpacesApply = useCallback(
(selected: string[]) => {
const next = selected.slice(0, 1)
if (next[0]) {
analytics.spaceSwitched({ space_id: next[0] })
pushRecent(next[0])
}
onValueChange(next)
setShowSelectSpacesModal(false)
},
[onValueChange, pushRecent],
)
const handleNewSpace = useCallback(() => {
setShowSelectSpacesModal(false)
setShowCreateDialog(true)
}, [])
const handleDeleteRequest = useCallback(
(project: { id: string; name: string; containerTag: string }) => {
setShowSelectSpacesModal(false)
setDeleteDialog({
open: true,
project,
action: "move",
targetProjectId: "",
})
},
[],
)
const handleDeleteConfirm = () => {
if (!deleteDialog.project) return
deleteProjectMutation.mutate(
{
projectId: deleteDialog.project.id,
@ -191,7 +240,6 @@ export function SpaceSelector({
action: "move",
targetProjectId: "",
})
setIsOpen(false)
},
},
)
@ -212,36 +260,31 @@ export function SpaceSelector({
p.id !== deleteDialog.project?.id &&
p.containerTag !== deleteDialog.project?.containerTag,
)
const defaultProject = allProjects.find(
(p: ContainerTagListType) => p.containerTag === DEFAULT_PROJECT_ID,
)
const isDefaultProjectBeingDeleted =
deleteDialog.project?.containerTag === DEFAULT_PROJECT_ID
if (defaultProject && !isDefaultProjectBeingDeleted) {
const defaultProjectIncluded = filtered.some(
(p: ContainerTagListType) => p.containerTag === DEFAULT_PROJECT_ID,
)
if (!defaultProjectIncluded) {
return [defaultProject, ...filtered]
}
if (!defaultProjectIncluded) return [defaultProject, ...filtered]
}
return filtered
return filtered.sort(compareSpacesUserFirst)
}, [allProjects, deleteDialog.project])
return (
<>
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setShowSelectSpacesModal(true)}
aria-label={
isLoading
? "Loading spaces"
: `Space: ${displayInfo.name}. Open menu to switch.`
: `Space: ${displayInfo.name}. Open selector.`
}
className={cn(
"flex min-w-0 max-w-full items-center cursor-pointer transition-colors",
@ -251,15 +294,30 @@ export function SpaceSelector({
triggerClassName,
)}
>
{displayInfo.isMultiple ? (
<Layers
className={cn(
"shrink-0",
variant === "insideOut" ? "text-white" : "text-[#737373]",
compact ? "size-3.5" : "size-4",
)}
aria-hidden
/>
{displayInfo.plugin ? (
displayInfo.plugin.iconSrc ? (
<Image
src={displayInfo.plugin.iconSrc}
alt=""
width={16}
height={16}
className={cn(
"shrink-0 rounded-[3px]",
compact ? "size-3.5" : "size-4",
)}
aria-hidden
/>
) : (
<span
className={cn(
"shrink-0 flex items-center justify-center rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase",
compact ? "size-3.5" : "size-4",
)}
aria-hidden
>
{pluginInitial(displayInfo.plugin.label)}
</span>
)
) : (
<span
className="shrink-0 text-sm font-bold tracking-[-0.98px]"
@ -279,147 +337,30 @@ export function SpaceSelector({
{isLoading ? "…" : displayInfo.name}
</span>
)}
{!compact && spaceCountData !== undefined && spaceCountData > 0 && (
<span className="shrink-0 text-[11px] text-[#737373] tabular-nums">
· {formatCount(spaceCountData)}
</span>
)}
{compact && (
<span className="sr-only">
{isLoading ? "Loading" : displayInfo.name}
</span>
)}
{showChevron && (
<ChevronDown
className={cn(
"shrink-0 opacity-90",
variant === "insideOut" ? "text-white/80" : "text-[#737373]",
compact ? "size-3.5" : "size-4",
)}
aria-hidden
/>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={compact ? "end" : "start"}
alignOffset={0}
sideOffset={compact ? 8 : 4}
collisionPadding={compact ? 16 : 8}
className={cn(
"min-w-[200px] overflow-hidden p-1.5 rounded-xl border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]",
compact
? "w-[min(calc(100vw-3rem),18rem)]"
: "max-w-[min(calc(100vw-2rem),20rem)]",
dmSansClassName(),
contentClassName,
)}
style={{
background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)",
}}
>
<div className="flex min-w-0 max-w-full flex-col gap-2">
<div className="shrink-0 px-3 py-1">
<span className="text-[10px] uppercase tracking-wider text-[#737373] font-medium">
My Spaces
</span>
</div>
<div
className={cn(
"flex min-h-0 max-h-[min(40vh,18rem)] min-w-0 flex-col overflow-y-auto overflow-x-hidden overscroll-contain",
"scrollbar-thin pr-0.5",
)}
>
<DropdownMenuItem
onClick={() => handleSelectSingleSpace(DEFAULT_PROJECT_ID)}
className={cn(
"flex min-w-0 max-w-full items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium",
selectedProjects.length === 1 &&
selectedProjects[0] === DEFAULT_PROJECT_ID
? "bg-[#293952]/40"
: "opacity-60 hover:opacity-100 hover:bg-[#293952]/40",
)}
>
<span className="font-bold tracking-[-0.98px]">📁</span>
<span className="min-w-0 flex-1 truncate">My Space</span>
</DropdownMenuItem>
{sortedOtherSpaces.map((project: ContainerTagListType) => (
<DropdownMenuItem
key={project.id}
onClick={() => handleSelectSingleSpace(project.containerTag)}
className={cn(
"flex min-w-0 max-w-full items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer text-white text-sm font-medium group",
selectedProjects.length === 1 &&
selectedProjects[0] === project.containerTag
? "bg-[#293952]/40"
: "opacity-60 hover:opacity-100 hover:bg-[#293952]/40",
)}
>
<span className="shrink-0 font-bold tracking-[-0.98px]">
{project.emoji || "📁"}
</span>
<span
className="min-w-0 flex-1 truncate"
title={project.name ?? project.containerTag}
>
{spaceSelectorDisplayName(project, project.containerTag)}
</span>
{enableDelete && (
<button
type="button"
onClick={(e) =>
handleDeleteClick(e, {
id: project.id,
name: project.name,
containerTag: project.containerTag,
})
}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded-full hover:bg-red-500/20"
>
<Trash2 className="size-3.5 text-red-500" />
</button>
)}
</DropdownMenuItem>
))}
</div>
<DropdownMenuSeparator className="shrink-0 bg-[#2E3033]" />
<button
type="button"
onClick={handleOpenSelectSpaces}
className={cn(
"flex min-w-0 w-full max-w-full shrink-0 items-center justify-center gap-2 px-3 py-2 rounded-md cursor-pointer text-white text-sm font-medium border border-[#161F2C] hover:bg-[#0D121A]/80 transition-colors",
)}
style={{
background: "linear-gradient(180deg, #0D121A 0%, #000000 100%)",
}}
>
<Layers className="size-4" />
<span>Select Space{!singleSelect && "s"}</span>
</button>
{showNewSpace && (
<button
type="button"
onClick={handleNewSpace}
className={cn(
"flex min-w-0 w-full max-w-full shrink-0 items-center justify-center gap-2 px-3 py-2 rounded-md cursor-pointer text-white text-sm font-medium border border-[#161F2C] hover:bg-[#0D121A]/80 transition-colors",
)}
style={{
background:
"linear-gradient(180deg, #0D121A 0%, #000000 100%)",
}}
>
<Plus className="size-4" />
<span>New Space</span>
</button>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</TooltipTrigger>
<TooltipContent side="bottom" className={dmSansClassName()}>
Switch space
</TooltipContent>
</Tooltip>
<AddSpaceModal
isOpen={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
onCreated={(containerTag) => onValueChange([containerTag])}
onCreated={(containerTag) => {
pushRecent(containerTag)
onValueChange([containerTag])
}}
/>
<SelectSpacesModal
@ -428,7 +369,11 @@ export function SpaceSelector({
selectedProjects={selectedProjects}
onApply={handleSelectSpacesApply}
projects={allProjects}
singleSelect={singleSelect}
recents={recents}
showNewSpace={showNewSpace}
onNewSpace={handleNewSpace}
enableDelete={enableDelete}
onDeleteRequest={handleDeleteRequest}
/>
<Dialog
@ -456,10 +401,7 @@ export function SpaceSelector({
showCloseButton={false}
>
<div className="flex flex-col gap-4">
<div
id="delete-dialog-header"
className="flex justify-between items-start gap-4"
>
<div className="flex justify-between items-start gap-4">
<div className="pl-1 space-y-1 flex-1">
<DialogTitle
className={cn(
@ -478,7 +420,7 @@ export function SpaceSelector({
</DialogDescription>
</div>
<DialogPrimitive.Close
className="bg-[#0D121A] size-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
className="bg-[#0D121A] w-7 h-7 flex items-center justify-center focus:ring-ring rounded-full transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border border-[rgba(115,115,115,0.2)] shrink-0"
style={{
boxShadow:
"inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
@ -489,9 +431,8 @@ export function SpaceSelector({
</DialogPrimitive.Close>
</div>
<div id="delete-dialog-content" className="space-y-3">
<div className="space-y-3">
<button
id="move-option"
type="button"
onClick={() =>
setDeleteDialog((prev) => ({ ...prev, action: "move" }))
@ -499,26 +440,20 @@ export function SpaceSelector({
className={cn(
"flex items-center gap-3 p-3 rounded-[12px] cursor-pointer transition-colors w-full text-left",
deleteDialog.action === "move"
? "bg-[#14161A] border border-[rgba(82,89,102,0.3)]"
: "bg-[#14161A]/50 border border-transparent hover:border-[rgba(82,89,102,0.2)]",
? "bg-[#14161A] shadow-inside-out"
: "bg-[#14161A]/50 hover:bg-[#14161A]/70",
)}
style={{
boxShadow:
deleteDialog.action === "move"
? "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08)"
: "none",
}}
>
<div
className={cn(
"size-4 rounded-full border-2 flex items-center justify-center shrink-0",
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0",
deleteDialog.action === "move"
? "border-blue-500"
? "border-[#4BA0FA]"
: "border-[#737373]",
)}
>
{deleteDialog.action === "move" && (
<div className="size-2 rounded-full bg-blue-500" />
<div className="w-2 h-2 rounded-full bg-[#4BA0FA]" />
)}
</div>
<span className="text-[#fafafa] text-sm font-medium">
@ -544,13 +479,9 @@ export function SpaceSelector({
>
<SelectTrigger
className={cn(
"bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] h-[45px]",
"bg-[#14161A] shadow-inside-out rounded-[12px] text-[#fafafa] text-[14px] h-[45px]",
dmSansClassName(),
)}
style={{
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
}}
>
<SelectValue placeholder="Select target space" />
</SelectTrigger>
@ -559,28 +490,58 @@ export function SpaceSelector({
"bg-[#14161A] border border-[rgba(82,89,102,0.2)] rounded-[12px]",
dmSansClassName(),
)}
style={{
boxShadow:
"0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08)",
}}
>
{availableTargetProjects.map(
(p: ContainerTagListType) => (
<SelectItem
key={p.id}
value={p.id}
className="text-[#fafafa] hover:bg-[#1B1F24] cursor-pointer rounded-md"
>
<span className="flex items-center gap-2 min-w-0">
<span>{p.emoji || "📁"}</span>
<span className="truncate">
{p.containerTag === DEFAULT_PROJECT_ID
? "My Space"
: spaceSelectorDisplayName(p, p.containerTag)}
(p: ContainerTagListType) => {
const plugin = detectPluginSpace(p.containerTag)
return (
<SelectItem
key={p.id}
value={p.id}
className="text-[#fafafa] hover:bg-[#1B1F24] cursor-pointer rounded-md"
>
<span className="flex items-center gap-2 min-w-0">
{plugin ? (
plugin.iconSrc ? (
<Image
src={plugin.iconSrc}
alt=""
width={16}
height={16}
className="shrink-0 rounded-[3px]"
aria-hidden
/>
) : (
<span
className="shrink-0 flex items-center justify-center w-4 h-4 rounded-[3px] bg-[#1E232B] text-[#FAFAFA] text-[10px] font-semibold uppercase"
aria-hidden
>
{pluginInitial(plugin.label)}
</span>
)
) : (
<span>{p.emoji || "📁"}</span>
)}
<span className="truncate">
{p.containerTag === DEFAULT_PROJECT_ID ? (
"My Space"
) : plugin ? (
<>
{plugin.label}
{plugin.projectId && (
<span className="ml-1.5 text-[11px] text-[#737373]">
· {plugin.projectId}
</span>
)}
</>
) : (
spaceSelectorDisplayName(p, p.containerTag)
)}
</span>
</span>
</span>
</SelectItem>
),
</SelectItem>
)
},
)}
</SelectContent>
</Select>
@ -588,7 +549,6 @@ export function SpaceSelector({
)}
<button
id="delete-option"
type="button"
onClick={() =>
setDeleteDialog((prev) => ({ ...prev, action: "delete" }))
@ -596,26 +556,20 @@ export function SpaceSelector({
className={cn(
"flex items-center gap-3 p-3 rounded-[12px] cursor-pointer transition-colors w-full text-left",
deleteDialog.action === "delete"
? "bg-[#14161A] border border-[rgba(220,38,38,0.3)]"
: "bg-[#14161A]/50 border border-transparent hover:border-[rgba(82,89,102,0.2)]",
? "bg-[#14161A] shadow-inside-out"
: "bg-[#14161A]/50 hover:bg-[#14161A]/70",
)}
style={{
boxShadow:
deleteDialog.action === "delete"
? "0px 1px 2px 0px rgba(87,0,0,0.1), inset 0px 0px 0px 1px rgba(67,43,43,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08)"
: "none",
}}
>
<div
className={cn(
"size-4 rounded-full border-2 flex items-center justify-center shrink-0",
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0",
deleteDialog.action === "delete"
? "border-red-500"
: "border-[#737373]",
)}
>
{deleteDialog.action === "delete" && (
<div className="size-2 rounded-full bg-red-500" />
<div className="w-2 h-2 rounded-full bg-red-500" />
)}
</div>
<span className="text-[#fafafa] text-sm font-medium">
@ -634,10 +588,7 @@ export function SpaceSelector({
)}
</div>
<div
id="delete-dialog-footer"
className="flex items-center justify-end gap-[22px]"
>
<div className="flex items-center justify-end gap-[22px]">
<button
type="button"
onClick={handleDeleteCancel}

View file

@ -0,0 +1,89 @@
"use client"
import { useMemo } from "react"
import { useQueries } from "@tanstack/react-query"
import { $fetch } from "@lib/api"
import { useAuth } from "@lib/auth-context"
export type PluginSpaceMeta = {
projectName?: string
source?: string
lastUpdatedAt?: string
}
type RawDoc = {
metadata?: Record<string, unknown> | null
updatedAt?: string | null
createdAt?: string | null
}
function extractMeta(doc: RawDoc): PluginSpaceMeta {
const md = doc?.metadata ?? {}
const project =
typeof md.project === "string" && md.project.trim()
? md.project.trim()
: undefined
const source =
typeof md.sm_source === "string" && md.sm_source.trim()
? md.sm_source.trim()
: undefined
return {
projectName: project,
source,
lastUpdatedAt: doc?.updatedAt ?? doc?.createdAt ?? undefined,
}
}
/**
* Fetches one recent doc per containerTag and pulls plugin metadata
* (`metadata.project`, `metadata.sm_source`) so plugin-provisioned spaces
* can show the real project name instead of the hash.
*/
export function usePluginSpaceMeta(
containerTags: string[],
): Map<string, PluginSpaceMeta> {
const { user } = useAuth()
const uniqueTags = useMemo(
() => Array.from(new Set(containerTags.filter(Boolean))).sort(),
[containerTags],
)
const results = useQueries({
queries: uniqueTags.map((tag) => ({
queryKey: ["plugin-space-meta", tag],
queryFn: async (): Promise<PluginSpaceMeta | null> => {
const response = await $fetch("@post/documents/documents", {
body: {
page: 1,
limit: 1,
sort: "createdAt",
order: "desc",
containerTags: [tag],
},
disableValidation: true,
})
if (response.error) return null
const data = response.data as unknown as
| { documents?: RawDoc[] }
| undefined
const doc = data?.documents?.[0]
if (!doc) return null
return extractMeta(doc)
},
enabled: !!user && !!tag,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
retry: 1,
})),
})
return useMemo(() => {
const map = new Map<string, PluginSpaceMeta>()
uniqueTags.forEach((tag, idx) => {
const meta = results[idx]?.data
if (meta) map.set(tag, meta)
})
return map
}, [uniqueTags, results])
}

View file

@ -0,0 +1,149 @@
export interface InstallStep {
title: string
description?: string
code?: string
copyLabel?: string
optional?: boolean
/** Blur the code block until hovered/focused (e.g. it contains the key). */
secret?: boolean
}
export interface PluginInfo {
id: string
name: string
tagline: string
icon: string
docsUrl?: string
/** Steps shown after a key is minted. The literal `sm_...` is replaced
* with the freshly generated key when rendered. */
installSteps?: InstallStep[]
}
/** Match `FREE_TIER_PLUGIN_IDS` in mono `packages/lib/plugins.ts`. */
export const FREE_TIER_PLUGIN_IDS = ["hermes", "codex"]
export function isFreeTierPlugin(pluginId: string): boolean {
return FREE_TIER_PLUGIN_IDS.includes(pluginId)
}
export const PLUGIN_CATALOG: Record<string, PluginInfo> = {
claude_code: {
id: "claude_code",
name: "Claude Code",
tagline: "Remembers your conventions, decisions, and project context",
icon: "/images/plugins/claude-code.svg",
docsUrl: "https://docs.supermemory.ai/integrations/claude-code",
installSteps: [
{
title: "Save your API key",
description:
"Add this to your shell profile so Claude Code can authenticate. This key is shown only once — save it now.",
code: 'export SUPERMEMORY_CC_API_KEY="sm_..."',
copyLabel: "API key",
secret: true,
},
{
title: "Install the plugin",
description: "Run these commands inside a Claude Code session:",
code: "/plugin marketplace add supermemoryai/claude-supermemory\n/plugin install claude-supermemory",
},
],
},
codex: {
id: "codex",
name: "Codex",
tagline: "Persistent memory for the Codex CLI — free on every plan",
icon: "/images/plugins/codex.png",
docsUrl: "https://docs.supermemory.ai/integrations/codex",
installSteps: [
{
title: "Save your API key",
description:
"Add this to your shell profile. This key is shown only once — save it now.",
code: 'export SUPERMEMORY_CODEX_API_KEY="sm_..."',
copyLabel: "API key",
secret: true,
},
{
title: "Install the hooks",
description: "Run this to wire Supermemory into Codex CLI:",
code: "npx codex-supermemory@latest install",
},
],
},
opencode: {
id: "opencode",
name: "OpenCode",
tagline: "Long-term memory for your OpenCode sessions",
icon: "/images/plugins/opencode.svg",
docsUrl: "https://docs.supermemory.ai/integrations/opencode",
installSteps: [
{
title: "Save your API key",
description:
"Add this to your shell profile. This key is shown only once — save it now.",
code: 'export SUPERMEMORY_API_KEY="sm_..."',
copyLabel: "API key",
secret: true,
},
{
title: "Install the plugin",
description: "Use --no-tui for non-interactive environments.",
code: "bunx opencode-supermemory@latest install",
},
{
title: "Verify your config",
description:
"Ensure ~/.config/opencode/opencode.jsonc includes the plugin:",
code: '{\n "plugin": ["opencode-supermemory"]\n}',
optional: true,
},
],
},
openclaw: {
id: "openclaw",
name: "OpenClaw",
tagline: "Cross-platform memory across Telegram, Discord, Slack",
icon: "/images/plugins/openclaw.svg",
docsUrl: "https://docs.supermemory.ai/integrations/openclaw",
installSteps: [
{
title: "Install the plugin",
description: "Run this in your OpenClaw project:",
code: "openclaw plugins install @supermemory/openclaw-supermemory",
},
{
title: "Configure Supermemory",
description:
"Run the setup command and paste your API key when prompted:",
code: "openclaw supermemory setup",
},
],
},
hermes: {
id: "hermes",
name: "Hermes",
tagline: "Persistent memory for the Hermes agent — free on every plan",
icon: "/images/plugins/hermes.svg",
docsUrl: "https://docs.supermemory.ai/integrations/hermes",
installSteps: [
{
title: "Run Hermes memory setup",
description:
"On the machine where Hermes is deployed, start the memory wizard, choose Supermemory as the provider, and paste your API key when prompted:",
code: "hermes memory setup",
},
],
},
}
const SPACE_TO_CATALOG_ID: Record<string, string> = {
"claude-code": "claude_code",
codex: "codex",
opencode: "opencode",
openclaw: "openclaw",
}
export function spacePluginIdToCatalogId(spacePluginId: string): string | null {
return SPACE_TO_CATALOG_ID[spacePluginId] ?? null
}

View file

@ -0,0 +1,543 @@
import type { DocumentsWithMemoriesResponseSchema } from "@repo/validation/api"
import type { z } from "zod"
import { detectPluginSource, pluginIconByLabel } from "@/lib/plugin-space"
type DocumentsResponse = z.infer<typeof DocumentsWithMemoriesResponseSchema>
type DocumentWithMemories = DocumentsResponse["documents"][0]
export type PluginDocumentKind =
| "codex-session"
| "codex-save"
| "amp-thread"
| "openclaw-session"
| "claude-code-doc"
export interface PluginArtifact {
label: string
value: string
}
export interface PluginDocumentMessage {
id: string
role: "user" | "assistant" | "tool" | "system" | "unknown"
text: string
}
export interface PluginDocumentSection {
label: string
value: string
tone?: "default" | "accent" | "muted"
}
export interface ParsedPluginDocument {
kind: PluginDocumentKind
pluginLabel: string
pluginIconSrc?: string
formatLabel: string
title: string
preview: string
summary: string
identifierLabel?: string
identifierValue?: string
clientLabel?: string
clientValue?: string
artifacts: PluginArtifact[]
messages: PluginDocumentMessage[]
sections: PluginDocumentSection[]
rawContent: string
}
const TRANSCRIPT_ROLE_PATTERN = "user|assistant|tool|system"
function normalizeContent(content: string | null | undefined): string {
if (!content) return ""
return (
content
.replace(/\r\n?/g, "\n")
.replace(/\\n/g, "\n")
// biome-ignore lint/suspicious/noControlCharactersInRegex: we need to remove null bytes
.replace(/\x00/g, "")
.trim()
)
}
function formatClientName(value: string | null | undefined): string | null {
if (!value) return null
const trimmed = value.trim()
if (!trimmed) return null
const normalized = trimmed.replace(/[_-]+/g, " ")
const lower = normalized.toLowerCase()
if (lower === "codex") return "Codex"
if (lower === "claude desktop") return "Claude Desktop"
if (lower === "claude code") return "Claude Code"
if (lower === "opencode") return "OpenCode"
if (lower === "openclaw") return "OpenClaw"
if (lower === "amp") return "Amp"
return normalized.replace(/\b\w/g, (match) => match.toUpperCase())
}
function extractArtifacts(text: string): {
cleanText: string
artifacts: PluginArtifact[]
} {
const artifacts: PluginArtifact[] = []
const cleanLines: string[] = []
for (const line of text.split("\n")) {
const trimmed = line.trim()
if (!trimmed) {
cleanLines.push(line)
continue
}
const memoryIdMatch = trimmed.match(/^memory id:\s*(.+)$/i)
if (memoryIdMatch?.[1]) {
artifacts.push({ label: "Memory ID", value: memoryIdMatch[1].trim() })
continue
}
cleanLines.push(line)
}
return {
cleanText: cleanLines.join("\n").trim(),
artifacts,
}
}
function parseTranscriptMessages(content: string): {
messages: PluginDocumentMessage[]
artifacts: PluginArtifact[]
} {
const messages: PluginDocumentMessage[] = []
const artifacts: PluginArtifact[] = []
const regex = new RegExp(
`^\\s*(\\d+)\\.\\s+\\[(${TRANSCRIPT_ROLE_PATTERN})\\]\\s*([\\s\\S]*?)(?=^\\s*\\d+\\.\\s+\\[(?:${TRANSCRIPT_ROLE_PATTERN})\\]\\s*|$)`,
"gm",
)
for (const match of content.matchAll(regex)) {
const ordinal = match[1] ?? `${messages.length + 1}`
const role = match[2] ?? "unknown"
const rawText = match[3] ?? ""
const { cleanText, artifacts: messageArtifacts } = extractArtifacts(
rawText.trim(),
)
artifacts.push(...messageArtifacts)
if (!cleanText) continue
messages.push({
id: `${ordinal}-${role}`,
role: role as PluginDocumentMessage["role"],
text: cleanText,
})
}
return { messages, artifacts }
}
function parseRoleBlockMessages(content: string): {
messages: PluginDocumentMessage[]
artifacts: PluginArtifact[]
} {
const messages: PluginDocumentMessage[] = []
const artifacts: PluginArtifact[] = []
const regex =
/\[role:\s*(user|assistant|tool|system)\]\s*([\s\S]*?)\s*\[\1:end\]/gi
let index = 0
for (const match of content.matchAll(regex)) {
const role = match[1] ?? "unknown"
const rawText = match[2] ?? ""
const { cleanText, artifacts: messageArtifacts } = extractArtifacts(
rawText.trim(),
)
artifacts.push(...messageArtifacts)
if (!cleanText) continue
messages.push({
id: `${role}-${index}`,
role: role.toLowerCase() as PluginDocumentMessage["role"],
text: cleanText,
})
index++
}
return { messages, artifacts }
}
function takePreview(text: string, maxLength = 180): string {
const normalized = text.replace(/\s+/g, " ").trim()
if (!normalized) return ""
if (normalized.length <= maxLength) return normalized
return `${normalized.slice(0, maxLength - 1).trimEnd()}...`
}
function parseSaveSections(content: string): ParsedPluginDocument | null {
const match = content.match(/\[SAVE:([^\]]+)\]([\s\S]*?)\[\/SAVE\]/i)
if (!match) return null
const savedAt = match[1] ?? ""
const rawBody = match[2] ?? ""
const sections: PluginDocumentSection[] = []
const artifacts: PluginArtifact[] = []
const overviewLines: string[] = []
for (const line of rawBody.split("\n")) {
const trimmed = line.trim()
if (!trimmed) continue
if (/^Decision:/i.test(trimmed)) {
sections.push({
label: "Decision",
value: trimmed.replace(/^Decision:\s*/i, "").trim(),
tone: "accent",
})
continue
}
if (/^Context:/i.test(trimmed)) {
sections.push({
label: "Context",
value: trimmed.replace(/^Context:\s*/i, "").trim(),
tone: "muted",
})
continue
}
if (/^Files:/i.test(trimmed)) {
sections.push({
label: "Files",
value: trimmed.replace(/^Files:\s*/i, "").trim(),
tone: "default",
})
continue
}
overviewLines.push(trimmed)
}
if (overviewLines.length > 0) {
sections.unshift({
label: "Summary",
value: overviewLines.join("\n\n"),
})
}
const summary =
sections.find((section) => section.label === "Decision")?.value ??
sections[0]?.value ??
"Saved project note"
return {
kind: "codex-save",
pluginLabel: "Codex",
formatLabel: "Saved note",
title: "Saved memory note",
preview: takePreview(
sections[0]?.value ?? "Saved project knowledge from Codex",
140,
),
summary: takePreview(summary, 220),
identifierLabel: "Saved",
identifierValue: savedAt.trim(),
artifacts,
messages: [],
sections,
rawContent: content,
}
}
function parseSessionTranscript(
content: string,
config: {
kind: "codex-session" | "amp-thread"
headerLabel: "Session" | "Amp thread"
pluginLabel: string
formatLabel: string
},
): ParsedPluginDocument | null {
const headerRegex = new RegExp(`\\[${config.headerLabel} ([^\\]]+)\\]`, "i")
const headerMatch = content.match(headerRegex)
if (!headerMatch?.[1]) return null
const identifierValue = headerMatch[1].trim()
const withoutHeader = content.replace(
new RegExp(`\\[${config.headerLabel} [^\\]]+\\]\\s*`, "gi"),
"",
)
const { messages, artifacts } = parseTranscriptMessages(withoutHeader)
if (messages.length === 0) return null
const userCount = messages.filter((message) => message.role === "user").length
const assistantCount = messages.filter(
(message) => message.role === "assistant",
).length
const previewSource =
messages.find((message) => message.role === "user")?.text ??
messages[0]?.text ??
"Conversation"
return {
kind: config.kind,
pluginLabel: config.pluginLabel,
formatLabel: config.formatLabel,
title: `${config.pluginLabel} conversation`,
preview: takePreview(previewSource, 140),
summary: `${userCount} user message${userCount === 1 ? "" : "s"} and ${assistantCount} assistant message${assistantCount === 1 ? "" : "s"} captured from ${config.pluginLabel}.`,
identifierLabel: config.headerLabel,
identifierValue,
artifacts,
messages,
sections: [],
rawContent: content,
}
}
function parseOpenClawTranscript(content: string): ParsedPluginDocument | null {
const { messages, artifacts } = parseRoleBlockMessages(content)
if (messages.length === 0) return null
const previewSource =
messages.find((message) => message.role === "user")?.text ??
messages[0]?.text ??
"Conversation"
return {
kind: "openclaw-session",
pluginLabel: "OpenClaw",
formatLabel: "Conversation",
title: "OpenClaw conversation",
preview: takePreview(previewSource, 140),
summary: `${messages.length} message${messages.length === 1 ? "" : "s"} captured from OpenClaw.`,
artifacts,
messages,
sections: [],
rawContent: content,
}
}
function withIcon(
parsed: ParsedPluginDocument | null,
): ParsedPluginDocument | null {
if (!parsed) return parsed
if (!parsed.pluginIconSrc) {
const icon = pluginIconByLabel(parsed.pluginLabel)
if (icon) parsed.pluginIconSrc = icon
}
return parsed
}
function getDocumentPluginSource(
document: DocumentWithMemories,
metadata: Record<string, unknown>,
): string | null {
if (typeof document.source === "string" && document.source) {
return document.source
}
if (typeof metadata.sm_source === "string" && metadata.sm_source) {
return metadata.sm_source
}
return null
}
const CLAUDE_CODE_CONTENT_RE =
/<\|turn_start\|>|<\|start\|>(?:user|assistant)<\|message\|>/
function firstMemoryEntryText(document: DocumentWithMemories): string | null {
const entries = document.memoryEntries
if (!Array.isArray(entries)) return null
for (const entry of entries) {
const memory = entry?.memory
if (typeof memory === "string" && memory.trim()) return memory.trim()
}
return null
}
function mapClaudeCodeRole(raw: string): PluginDocumentMessage["role"] {
const lower = raw.toLowerCase()
if (lower === "user") return "user"
if (lower === "assistant") return "assistant"
if (lower === "assistant:tool" || lower === "assistant:tool_result") {
return "tool"
}
if (lower === "system") return "system"
if (lower === "tool") return "tool"
return "unknown"
}
function parseClaudeCodeTurns(content: string): PluginDocumentMessage[] {
if (!content) return []
const messages: PluginDocumentMessage[] = []
const regex =
/<\|start\|>([a-z_:]+)<\|message\|>([\s\S]*?)(?=<\|end\|>|<\|start\|>|<\|turn_end\|>|<\|turn_start\|>|$)/gi
let index = 0
for (const match of content.matchAll(regex)) {
const rawRole = match[1] ?? ""
const role = mapClaudeCodeRole(rawRole)
let text = match[2] ?? ""
text = text
.replace(/<system_instruction>[\s\S]*?<\/system_instruction>/gi, "")
.trim()
if (!text) continue
messages.push({ id: `${role}-${index++}`, role, text })
}
return messages
}
function stripClaudeCodeTranscript(content: string): string {
if (!content) return ""
let text = content
.replace(/<system_instruction>[\s\S]*?<\/system_instruction>/gi, "")
.replace(/<\|turn_start\|>[^\n<]*/g, "")
.replace(/<\|turn_end\|>/g, "")
.replace(/<\|start\|>(?:user|assistant|tool|system)<\|message\|>/g, "")
.replace(/<\|end\|>/g, "")
.replace(/<\|[^|>]*\|>/g, "")
text = text.replace(/\s+/g, " ").trim()
return text
}
export function formatTokenCount(n: number): string {
if (n >= 1_000_000)
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`
return `${n}`
}
export function claudeCodeTokenBadge(
document: DocumentWithMemories,
): string | null {
const meta = (document.metadata ?? {}) as Record<string, unknown>
if (getDocumentPluginSource(document, meta) !== "claude-code-plugin") {
return null
}
const tokens = document.tokenCount
if (typeof tokens !== "number" || tokens <= 0) return null
return `${formatTokenCount(tokens)} tokens`
}
function parseClaudeCodeByMetadata(
document: DocumentWithMemories,
metadata: Record<string, unknown>,
): ParsedPluginDocument | null {
const docSource = getDocumentPluginSource(document, metadata)
let source = detectPluginSource(metadata, docSource)
const rawContent =
typeof document.content === "string" ? document.content : ""
if (!source) {
if (CLAUDE_CODE_CONTENT_RE.test(rawContent)) {
const md = metadata ?? {}
const project =
typeof md.project === "string" && md.project.trim()
? md.project.trim()
: undefined
source = {
pluginId: "claude-code",
label: "Claude Code",
iconSrc: "/images/plugins/claude-code.svg",
projectName: project,
formatLabel: "Session",
type: "session_turn",
}
}
}
if (!source) return null
const summary = typeof document.summary === "string" ? document.summary : ""
const title =
(typeof document.title === "string" && document.title.trim()) ||
(source.projectName
? `${source.formatLabel} · ${source.projectName}`
: source.formatLabel)
const memoryText = firstMemoryEntryText(document)
const cleanedTranscript = stripClaudeCodeTranscript(rawContent)
const preview = takePreview(memoryText || cleanedTranscript || summary, 220)
const parsed: ParsedPluginDocument = {
kind: "claude-code-doc",
pluginLabel: source.label,
pluginIconSrc: source.iconSrc,
formatLabel: source.formatLabel,
title,
preview,
summary,
artifacts: [],
messages: parseClaudeCodeTurns(rawContent),
sections: [],
rawContent,
}
return parsed
}
export function parsePluginDocument(
document: DocumentWithMemories | null,
): ParsedPluginDocument | null {
if (!document) return null
const metadata = (document.metadata ?? {}) as Record<string, unknown>
if (getDocumentPluginSource(document, metadata) === "claude-code-plugin") {
return withIcon(parseClaudeCodeByMetadata(document, metadata))
}
const content = normalizeContent(
typeof document.content === "string" ? document.content : "",
)
const clientName = formatClientName(
typeof metadata.sm_internal_mcp_client_name === "string"
? metadata.sm_internal_mcp_client_name
: null,
)
if (content) {
const codexSave = parseSaveSections(content)
if (codexSave) {
if (clientName) {
codexSave.clientLabel = "Client"
codexSave.clientValue = clientName
}
return withIcon(codexSave)
}
const codexSession = parseSessionTranscript(content, {
kind: "codex-session",
headerLabel: "Session",
pluginLabel: "Codex",
formatLabel: "Conversation",
})
if (codexSession) {
if (clientName) {
codexSession.clientLabel = "Client"
codexSession.clientValue = clientName
}
return withIcon(codexSession)
}
const ampThread = parseSessionTranscript(content, {
kind: "amp-thread",
headerLabel: "Amp thread",
pluginLabel: "Amp",
formatLabel: "Conversation",
})
if (ampThread) return withIcon(ampThread)
const openClawSession = parseOpenClawTranscript(content)
if (openClawSession) return withIcon(openClawSession)
}
return withIcon(parseClaudeCodeByMetadata(document, metadata))
}

View file

@ -0,0 +1,167 @@
export type PluginSpaceInfo = {
pluginId: "claude-code" | "openclaw" | "opencode" | "codex" | "amp"
label: string
iconSrc: string | null
projectId?: string
}
type PluginDef = {
id: PluginSpaceInfo["pluginId"]
label: string
iconSrc: string | null
prefixes: string[]
}
const PLUGINS: PluginDef[] = [
{
id: "claude-code",
label: "Claude Code",
iconSrc: "/images/plugins/claude-code.svg",
prefixes: ["claudecode"],
},
{
id: "openclaw",
label: "OpenClaw",
iconSrc: "/images/plugins/openclaw.svg",
prefixes: ["openclaw"],
},
{
id: "opencode",
label: "OpenCode",
iconSrc: "/images/plugins/opencode.svg",
prefixes: ["opencode"],
},
{
id: "codex",
label: "Codex",
iconSrc: "/images/plugins/codex.png",
prefixes: ["codex"],
},
{
id: "amp",
label: "Amp",
iconSrc: null,
prefixes: ["amp"],
},
]
function parsePluginRest(rest: string): { projectId?: string } {
if (!rest || rest === "default" || rest === "global") {
return { projectId: "Global" }
}
const userMatch = rest.match(/^user[_-]([0-9a-f]{6,64})$/i)
if (userMatch?.[1]) return { projectId: `User · ${userMatch[1].slice(0, 6)}` }
const projectMatch = rest.match(/^project[_-]([0-9a-f]{6,64})$/i)
if (projectMatch?.[1]) return { projectId: projectMatch[1].slice(0, 6) }
const hexOnly = rest.match(/^([0-9a-f]{6,64})$/i)
if (hexOnly?.[1]) return { projectId: hexOnly[1].slice(0, 6) }
return { projectId: rest.slice(0, 24) }
}
const PLUGIN_ICON_BY_LABEL: Record<string, string> = {
"Claude Code": "/images/plugins/claude-code.svg",
OpenClaw: "/images/plugins/openclaw.svg",
OpenCode: "/images/plugins/opencode.svg",
Codex: "/images/plugins/codex.png",
}
export function pluginIconByLabel(
label: string | null | undefined,
): string | null {
if (!label) return null
return PLUGIN_ICON_BY_LABEL[label] ?? null
}
export function pluginInitial(label: string | null | undefined): string {
if (!label) return "?"
return label.trim().charAt(0).toUpperCase() || "?"
}
export type PluginDocSource = {
pluginId: "claude-code"
label: string
iconSrc: string
projectName?: string
formatLabel: string
type: "session_turn" | "project-knowledge" | "manual" | "unknown"
}
function formatLabelForType(t: string | undefined): {
formatLabel: string
type: PluginDocSource["type"]
} {
switch (t) {
case "session_turn":
return { formatLabel: "Session", type: "session_turn" }
case "project-knowledge":
return { formatLabel: "Project knowledge", type: "project-knowledge" }
case "manual":
return { formatLabel: "Note", type: "manual" }
default:
return { formatLabel: "Note", type: "unknown" }
}
}
export function detectPluginSource(
metadata: Record<string, unknown> | null | undefined,
documentSource?: string | null,
): PluginDocSource | null {
const sourceFromMeta =
metadata && typeof metadata.sm_source === "string"
? metadata.sm_source
: null
const source = documentSource ?? sourceFromMeta
if (source !== "claude-code-plugin") return null
const md = metadata ?? {}
const project =
typeof md.project === "string" && md.project.trim()
? md.project.trim()
: undefined
const t = typeof md.type === "string" ? md.type : undefined
const { formatLabel, type } = formatLabelForType(t)
return {
pluginId: "claude-code",
label: "Claude Code",
iconSrc: "/images/plugins/claude-code.svg",
projectName: project,
formatLabel,
type,
}
}
export function detectPluginSpace(
containerTag: string,
): PluginSpaceInfo | null {
if (!containerTag) return null
for (const plugin of PLUGINS) {
for (const prefix of plugin.prefixes) {
if (containerTag === prefix) {
return {
pluginId: plugin.id,
label: plugin.label,
iconSrc: plugin.iconSrc,
projectId: "Global",
}
}
const separator = containerTag[prefix.length]
if (
containerTag.startsWith(prefix) &&
(separator === "_" || separator === "-")
) {
const rest = containerTag.slice(prefix.length + 1)
const parsed = parsePluginRest(rest)
return {
pluginId: plugin.id,
label: plugin.label,
iconSrc: plugin.iconSrc,
projectId: parsed.projectId,
}
}
}
}
return null
}