supermemory/apps/web/components/select-spaces-modal.tsx

1556 lines
46 KiB
TypeScript

"use client"
import { useState, useMemo, useEffect, useCallback, useRef } from "react"
import Image from "next/image"
import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
import { Dialog, DialogContent } from "@repo/ui/components/dialog"
import { Drawer, DrawerContent, DrawerTitle } from "@repo/ui/components/drawer"
import { cn } from "@lib/utils"
import { useIsMobile } from "@hooks/use-mobile"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import {
XIcon,
Search,
FolderIcon,
LayoutGrid,
Plus,
Trash2,
Clock,
ArrowRight,
BookOpen,
Loader,
Pencil,
Check,
} 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,
isOwnConversationSpace,
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"
import { useProjectMutations } from "@/hooks/use-project-mutations"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import NovaOrb from "@/components/nova/nova-orb"
import { AutoSpaceIcon } from "@/components/nova/auto-space-icon"
import { SpaceGlyph } from "./space-glyph"
interface SelectSpacesModalProps {
isOpen: boolean
onClose: () => void
selectedProjects: string[]
onApply: (selected: string[]) => void
projects: ContainerTagListType[]
recents?: string[]
showNewSpace?: boolean
includeAuto?: boolean
onNewSpace?: () => void
enableDelete?: boolean
onDeleteRequest?: (project: {
id: string
name: string
containerTag: string
}) => void
onBulkDeleteRequest?: (
projects: {
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({
isOpen,
onClose,
selectedProjects,
onApply,
projects,
recents,
showNewSpace = false,
includeAuto = false,
onNewSpace,
enableDelete = false,
onDeleteRequest,
onBulkDeleteRequest,
}: SelectSpacesModalProps) {
const [searchQuery, setSearchQuery] = useState("")
const [isBulkDeleteMode, setIsBulkDeleteMode] = useState(false)
const [bulkDeleteTags, setBulkDeleteTags] = useState<Set<string>>(new Set())
const [lastBulkDeleteTag, setLastBulkDeleteTag] = useState<string | null>(
null,
)
const [editingProject, setEditingProject] = useState<{
id: string
containerTag: string
originalName: string
name: string
} | null>(null)
const editInputRef = useRef<HTMLInputElement | null>(null)
const editingContainerTag = editingProject?.containerTag
const currentSelection = selectedProjects[0] ?? ""
const isMobile = useIsMobile()
const pluginTags = useMemo(
() =>
projects
.filter((p) => !!detectPluginSpace(p.containerTag))
.map((p) => p.containerTag),
[projects],
)
const pluginMetaMap = usePluginSpaceMeta(pluginTags)
const allSpaces = useMemo(() => {
const defaultSpace = {
id: "default",
name: "My Space",
emoji: "📁",
containerTag: DEFAULT_PROJECT_ID,
isExperimental: false,
isNova: false,
createdAt: "",
updatedAt: "",
} as ContainerTagListType
const rest = projects
.filter((p) => p.containerTag !== DEFAULT_PROJECT_ID)
.sort(compareSpacesUserFirst)
return [defaultSpace, ...rest]
}, [projects])
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"
if (currentSelection === AUTO_CHAT_SPACE_ID) return "all"
const plugin = detectPluginSpace(currentSelection)
if (plugin) return `plugin:${plugin.pluginId}`
return "my"
}, [currentSelection])
const [activeCategory, setActiveCategory] =
useState<CategoryId>(defaultCategory)
const activeDiscoverId = activeCategory.startsWith("discover:")
? activeCategory.slice("discover:".length)
: null
useEffect(() => {
if (isOpen) setActiveCategory(defaultCategory)
}, [isOpen, defaultCategory])
useEffect(() => {
if (!activeDiscoverId) return
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
}, [activeDiscoverId])
const { org, user } = useAuth()
const queryClient = useQueryClient()
const [connectingPluginId, setConnectingPluginId] = useState<string | null>(
null,
)
const [newKey, setNewKey] = useState<{
pluginId: string
key: string
} | null>(null)
const { updateProjectMutation } = useProjectMutations()
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 && !!activeDiscoverId,
})
const { data: apiKeys = [] } = useQuery({
queryKey: ["api-keys", org?.id],
enabled: isOpen && !!activeDiscoverId && !!org?.id,
queryFn: async () => {
if (!org?.id) return []
const data = (await authClient.apiKey.list({
fetchOptions: { query: { metadata: { organizationId: org.id } } },
})) as unknown as { metadata?: Record<string, unknown> | null }[]
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)
setEditingProject(null)
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
}
}, [isOpen])
useEffect(() => {
if (!editingContainerTag) return
const frame = requestAnimationFrame(() => {
editInputRef.current?.focus()
editInputRef.current?.select()
})
return () => cancelAnimationFrame(frame)
}, [editingContainerTag])
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
onClose()
setSearchQuery("")
setEditingProject(null)
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
}
},
[onClose],
)
const handleSelect = useCallback(
(containerTag: string) => {
setEditingProject(null)
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
onApply([containerTag])
setSearchQuery("")
},
[onApply],
)
const handleSelectAuto = useCallback(() => {
setEditingProject(null)
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
onApply([AUTO_CHAT_SPACE_ID])
setSearchQuery("")
}, [onApply])
const handleBulkModeToggle = useCallback(() => {
setEditingProject(null)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
setIsBulkDeleteMode((prev) => !prev)
}, [])
const startEditing = useCallback((project: ContainerTagListType) => {
const name = project.name ?? project.containerTag
setEditingProject({
id: project.id,
containerTag: project.containerTag,
originalName: name,
name,
})
}, [])
const cancelEditing = useCallback(() => {
setEditingProject(null)
}, [])
const saveEditing = useCallback(() => {
if (!editingProject) return
const nextName = editingProject.name.trim()
const currentName = editingProject.originalName.trim()
if (!nextName || nextName === currentName) return
updateProjectMutation.mutate(
{ containerTag: editingProject.containerTag, name: nextName },
{
onSuccess: () => setEditingProject(null),
},
)
}, [editingProject, updateProjectMutation])
const handleEditKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault()
saveEditing()
}
if (e.key === "Escape") {
e.preventDefault()
cancelEditing()
}
},
[cancelEditing, saveEditing],
)
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 byCategory.filter((p) => {
const plugin = detectPluginSpace(p.containerTag)
const projectName = pluginMetaMap.get(p.containerTag)?.projectName
const displayName = spaceSelectorDisplayName(p, p.containerTag, {
currentUserId: user?.id,
})
return (
p.containerTag.toLowerCase().includes(query) ||
(p.name ?? "").toLowerCase().includes(query) ||
displayName.toLowerCase().includes(query) ||
(plugin?.label.toLowerCase().includes(query) ?? false) ||
(plugin?.projectId?.toLowerCase().includes(query) ?? false) ||
(projectName?.toLowerCase().includes(query) ?? false)
)
})
}, [allSpaces, activeCategory, searchQuery, pluginMetaMap, user?.id])
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 showAutoRow = useMemo(() => {
if (!includeAuto) return false
if (isBulkDeleteMode) return false
if (activeCategory !== "all" && activeCategory !== "my") return false
const query = searchQuery.trim().toLowerCase()
if (!query) return true
return (
"auto".includes(query) ||
"let nova choose the right spaces".includes(query) ||
"discover spaces".includes(query)
)
}, [includeAuto, isBulkDeleteMode, activeCategory, searchQuery])
const visibleBulkDeleteTags = useMemo(
() =>
[...recentProjects, ...mainList]
.filter((project) => project.containerTag !== DEFAULT_PROJECT_ID)
.map((project) => project.containerTag),
[recentProjects, mainList],
)
const toggleBulkDeleteTag = useCallback(
(containerTag: string, shiftKey = false) => {
setBulkDeleteTags((prev) => {
const next = new Set(prev)
const currentIndex = visibleBulkDeleteTags.indexOf(containerTag)
const anchorIndex = lastBulkDeleteTag
? visibleBulkDeleteTags.indexOf(lastBulkDeleteTag)
: -1
if (shiftKey && currentIndex !== -1 && anchorIndex !== -1) {
const start = Math.min(anchorIndex, currentIndex)
const end = Math.max(anchorIndex, currentIndex)
for (const tag of visibleBulkDeleteTags.slice(start, end + 1)) {
next.add(tag)
}
} else if (next.has(containerTag)) {
next.delete(containerTag)
} else {
next.add(containerTag)
}
return next
})
setLastBulkDeleteTag(containerTag)
},
[lastBulkDeleteTag, visibleBulkDeleteTags],
)
const bulkDeleteProjects = useMemo(
() =>
allSpaces
.filter(
(project) =>
project.containerTag !== DEFAULT_PROJECT_ID &&
bulkDeleteTags.has(project.containerTag),
)
.map((project) => ({
id: project.id,
name: spaceSelectorDisplayName(project, project.containerTag, {
currentUserId: user?.id,
}),
containerTag: project.containerTag,
})),
[allSpaces, bulkDeleteTags, user?.id],
)
const bulkDeleteCount = bulkDeleteProjects.length
const renderRow = useCallback(
(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 displayName = spaceSelectorDisplayName(
project,
project.containerTag,
{
currentUserId: user?.id,
},
)
const isDefault = project.containerTag === DEFAULT_PROJECT_ID
const isOwnSpace = isOwnConversationSpace(project, user?.id)
const canEdit = !isDefault && !plugin && !isOwnSpace
const canBulkDelete = enableDelete && !isDefault
const isEditing = editingProject?.containerTag === project.containerTag
const isBulkDeleteSelected = bulkDeleteTags.has(project.containerTag)
const trimmedEditName = editingProject?.name.trim() ?? ""
const isSaveDisabled =
!trimmedEditName ||
trimmedEditName === editingProject?.originalName.trim() ||
updateProjectMutation.isPending
const handleRowAction = (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
) => {
if (isEditing) return
if (isBulkDeleteMode) {
if (canBulkDelete) {
toggleBulkDeleteTag(project.containerTag, e.shiftKey)
}
return
}
handleSelect(project.containerTag)
}
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",
(isBulkDeleteMode ? isBulkDeleteSelected : isSelected)
? "bg-[#14161A] shadow-inside-out"
: "hover:bg-[#14161A]/50",
isBulkDeleteMode &&
!canBulkDelete &&
"cursor-not-allowed opacity-45",
)}
>
<button
type="button"
onClick={handleRowAction}
disabled={isBulkDeleteMode && !canBulkDelete}
aria-label={
isBulkDeleteMode ? "Select space for deletion" : "Select space"
}
aria-pressed={isBulkDeleteMode ? isBulkDeleteSelected : isSelected}
className={cn(
"w-4 h-4 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors cursor-pointer disabled:cursor-not-allowed",
isBulkDeleteMode
? isBulkDeleteSelected
? "border-red-400 bg-red-400/10"
: "border-[#737373]"
: isSelected
? "border-[#4BA0FA]"
: "border-[#737373]",
)}
>
{isBulkDeleteMode ? (
isBulkDeleteSelected && <Check className="size-3 text-red-300" />
) : isSelected ? (
<div className="w-2 h-2 rounded-full bg-[#4BA0FA]" />
) : null}
</button>
{isEditing ? (
<div className="flex min-w-0 flex-1 items-center gap-2">
<SpaceGlyph
emoji={project.emoji}
size={18}
className="shrink-0"
/>
<input
type="text"
value={editingProject.name}
ref={editInputRef}
onChange={(e) =>
setEditingProject((prev) =>
prev ? { ...prev, name: e.target.value } : prev,
)
}
onKeyDown={handleEditKeyDown}
className={cn(
"min-w-0 flex-1 rounded-[9px] border border-[rgba(82,89,102,0.35)] bg-[#0D121A] px-2.5 py-1.5 text-sm font-medium text-[#fafafa] shadow-inside-out placeholder:text-[#737373] focus:outline-none focus:ring-1 focus:ring-[rgba(75,160,250,0.45)]",
dmSansClassName(),
)}
aria-label="Space name"
/>
<button
type="button"
onClick={saveEditing}
disabled={isSaveDisabled}
aria-label="Save space name"
className="shrink-0 rounded-full p-1.5 text-[#4BA0FA] transition-colors hover:bg-[#4BA0FA]/15 disabled:cursor-not-allowed disabled:opacity-35"
>
<Check className="size-3.5" />
</button>
<button
type="button"
onClick={cancelEditing}
disabled={updateProjectMutation.isPending}
aria-label="Cancel editing space name"
className="shrink-0 rounded-full p-1.5 text-[#737373] transition-colors hover:bg-[#737373]/15 hover:text-[#fafafa] disabled:cursor-not-allowed disabled:opacity-35"
>
<XIcon className="size-3.5" />
</button>
</div>
) : (
<button
type="button"
onClick={handleRowAction}
disabled={isBulkDeleteMode && !canBulkDelete}
className="flex min-w-0 flex-1 items-center gap-3 text-left cursor-pointer focus:outline-none focus:ring-0 disabled:cursor-not-allowed"
>
{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>
)
) : isOwnSpace ? (
<NovaOrb size={20} className="shrink-0 blur-[0.55px]!" />
) : (
<SpaceGlyph
emoji={project.emoji}
size={20}
className="shrink-0"
/>
)}
<span
className="min-w-0 flex-1 truncate text-[#fafafa] text-sm font-medium"
title={plugin ? project.containerTag : displayName}
>
{plugin ? (
<>
{plugin.label}
{pluginIdLabel && (
<span className="ml-1.5 text-[12px] text-[#737373]">
· {pluginIdLabel}
</span>
)}
</>
) : (
displayName
)}
</span>
</button>
)}
{canEdit && !isEditing && !isBulkDeleteMode && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
startEditing(project)
}}
aria-label="Rename space"
className="shrink-0 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity p-1.5 rounded-full text-[#737373] hover:bg-[#737373]/15 hover:text-[#fafafa] cursor-pointer focus:outline-none"
>
<Pencil className="size-3.5" />
</button>
)}
{enableDelete &&
!isDefault &&
!isEditing &&
!isBulkDeleteMode &&
onDeleteRequest && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDeleteRequest({
id: project.id,
name: displayName,
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>
)
},
[
cancelEditing,
bulkDeleteTags,
currentSelection,
editingProject,
enableDelete,
handleEditKeyDown,
handleSelect,
isBulkDeleteMode,
onDeleteRequest,
pluginMetaMap,
saveEditing,
startEditing,
toggleBulkDeleteTag,
updateProjectMutation.isPending,
user?.id,
],
)
const renderAutoRow = useCallback(() => {
const isSelected = currentSelection === AUTO_CHAT_SPACE_ID
return (
<div
key={AUTO_CHAT_SPACE_ID}
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",
)}
>
<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>
<button
type="button"
onClick={handleSelectAuto}
className="flex min-w-0 flex-1 items-center gap-3 text-left cursor-pointer focus:outline-none focus:ring-0"
>
<AutoSpaceIcon size={20} />
<span className="min-w-0 flex-1 truncate text-[#fafafa] text-sm font-medium">
Auto
<span className="ml-1.5 text-[12px] text-[#737373]">
· Nova chooses spaces
</span>
</span>
</button>
</div>
)
}, [currentSelection, handleSelectAuto])
const renderCategoryChip = (
category: {
id: string
label: string
count?: number
iconSrc?: string
emoji?: string
},
isDiscover: boolean,
) => {
const isActive = activeCategory === category.id
return (
<button
key={category.id}
type="button"
onClick={() => setActiveCategory(category.id)}
className={cn(
"flex shrink-0 items-center gap-2 whitespace-nowrap rounded-full border px-3 py-2 transition-colors",
isActive
? "border-[#2261CA33] bg-[#00173C] text-[#fafafa]"
: "border-[#161F2C] bg-[#0D121A] text-[#A1A1AA]",
isDiscover && !isActive && "opacity-60",
)}
>
<span className="flex size-[18px] shrink-0 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 ? (
<SpaceGlyph emoji={category.emoji} size={16} />
) : category.id.startsWith("plugin:") ? (
<span
className="flex h-[18px] w-[18px] items-center justify-center rounded-[3px] bg-[#1E232B] text-[10px] font-semibold uppercase text-[#FAFAFA]"
aria-hidden
>
{pluginInitial(category.label)}
</span>
) : (
<FolderIcon
className={cn(
"size-4",
isActive ? "text-[#fafafa]" : "text-[#737373]",
)}
/>
)}
</span>
<span className="text-[13px] font-medium">{category.label}</span>
{isDiscover ? (
<ArrowRight className="size-3.5 text-[#737373]" />
) : (
<span className="text-[11px] tabular-nums text-[#737373]">
{category.count}
</span>
)}
</button>
)
}
const rightPanelContent = activeCategory.startsWith("discover:") ? (
<DiscoverPanel
catalogId={activeDiscoverId ?? ""}
isConnecting={connectingPluginId === activeDiscoverId}
newKey={newKey?.pluginId === activeDiscoverId ? newKey.key : null}
onConnect={() => {
if (activeDiscoverId) connectMutation.mutate(activeDiscoverId)
}}
onDismissKey={() => setNewKey(null)}
/>
) : (
<>
<div className="relative shrink-0">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#737373]" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search spaces..."
className={cn(
"w-full rounded-[12px] bg-[#14161A] py-2.5 pl-10 pr-4 text-[14px] text-[#fafafa] shadow-inside-out placeholder:text-[#737373] focus:outline-none",
dmSansClassName(),
)}
/>
</div>
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto scrollbar-thin pr-1">
{filteredProjects.length === 0 ? (
<p className="py-8 text-center text-sm text-[#737373]">
No spaces found
</p>
) : (
<div className="flex flex-col gap-1">
{showAutoRow && (
<>
<div className="px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
Mode
</div>
{renderAutoRow()}
<div className="my-1.5 h-px bg-[rgba(82,89,102,0.18)]" />
</>
)}
{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>
</>
)
const footerContent = !activeCategory.startsWith("discover:") &&
(isBulkDeleteMode || (showNewSpace && onNewSpace)) && (
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-[rgba(82,89,102,0.18)] px-4 py-3">
{isBulkDeleteMode ? (
<>
<p className="min-w-0 text-[13px] font-medium text-[#737373]">
{bulkDeleteCount === 0
? "No spaces selected"
: `${bulkDeleteCount} ${
bulkDeleteCount === 1 ? "space" : "spaces"
} selected`}
</p>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={handleBulkModeToggle}
className={cn(
"px-3 py-2 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]",
dmSansClassName(),
)}
>
Cancel
</button>
<button
type="button"
disabled={bulkDeleteCount === 0}
onClick={() => {
if (bulkDeleteCount === 0) return
onBulkDeleteRequest?.(bulkDeleteProjects)
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
}}
className={cn(
"flex items-center gap-2 rounded-full bg-red-600 px-4 py-2 text-[13px] font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-40",
dmSansClassName(),
)}
>
<Trash2 className="size-4" />
Delete selected
</button>
</div>
</>
) : (
<>
<span />
{showNewSpace && onNewSpace && (
<button
type="button"
onClick={onNewSpace}
className={cn(
"flex items-center gap-2 rounded-full bg-[#14161A] px-4 py-2 text-[13px] font-medium text-[#fafafa] shadow-inside-out transition-colors hover:bg-[#121820] focus:outline-none focus:ring-0",
dmSansClassName(),
)}
>
<Plus className="size-4" />
New space
</button>
)}
</>
)}
</div>
)
if (isMobile) {
return (
<Drawer open={isOpen} onOpenChange={handleOpenChange}>
<DrawerContent
className={cn(
"flex h-[85dvh] flex-col gap-0 overflow-hidden border-none bg-[#1B1F24] p-0",
dmSansClassName(),
)}
>
<DrawerTitle className="sr-only">Select Space</DrawerTitle>
<div className="flex shrink-0 items-start justify-between gap-3 px-4 pt-1">
<div className="space-y-1">
<p
className={cn(
"font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Select Space
</p>
<p className="text-[13px] font-medium leading-[1.35] text-[#737373]">
{isBulkDeleteMode
? "Choose spaces to permanently delete"
: "Filter your memories by space"}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{enableDelete && onBulkDeleteRequest && !activeDiscoverId && (
<button
type="button"
onClick={handleBulkModeToggle}
className={cn(
"flex h-8 items-center gap-1.5 rounded-full bg-[#0D121A] px-2.5 text-[12px] font-medium transition-colors",
isBulkDeleteMode ? "text-[#fafafa]" : "text-[#737373]",
)}
style={{
boxShadow:
"inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<Trash2 className="size-3.5" />
{isBulkDeleteMode ? "Cancel" : "Bulk delete"}
</button>
)}
<button
type="button"
onClick={() => handleOpenChange(false)}
aria-label="Close"
className="flex size-8 shrink-0 items-center justify-center rounded-full border border-[rgba(115,115,115,0.2)] bg-[#0D121A]"
style={{
boxShadow:
"inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<XIcon stroke="#737373" className="size-4" />
</button>
</div>
</div>
<div className="mt-3 flex shrink-0 gap-1.5 overflow-x-auto px-4 pb-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{categories.map((category) => renderCategoryChip(category, false))}
{discoverCategories.length > 0 && (
<>
<div className="mx-0.5 my-1 w-px shrink-0 bg-[rgba(82,89,102,0.25)]" />
{discoverCategories.map((category) =>
renderCategoryChip(category, true),
)}
</>
)}
</div>
<div className="mt-3 flex min-h-0 flex-1 flex-col gap-3 overflow-hidden px-4 pb-2">
{rightPanelContent}
</div>
{footerContent}
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent
className={cn(
"w-[calc(100vw-1rem)]! max-w-[720px]! max-h-[calc(100dvh-1rem)] min-w-0 border-none bg-[#1B1F24] flex flex-col p-0 gap-0 rounded-[22px] overflow-hidden sm:w-[92vw]!",
dmSansClassName(),
)}
style={{
display: "flex",
boxShadow:
"0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
}}
showCloseButton={false}
>
<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(
"font-semibold text-[#fafafa]",
dmSans125ClassName(),
)}
>
Select Space
</p>
<p className="text-[#737373] font-medium text-[14px] leading-[1.35]">
{isBulkDeleteMode
? "Choose spaces to permanently delete"
: "Filter your memories by space"}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{enableDelete && onBulkDeleteRequest && !activeDiscoverId && (
<button
type="button"
onClick={handleBulkModeToggle}
className={cn(
"flex h-7 items-center gap-1.5 rounded-full bg-[#0D121A] px-2.5 text-[12px] font-medium transition-colors hover:bg-[#121820] focus:outline-none",
isBulkDeleteMode ? "text-[#fafafa]" : "text-[#737373]",
)}
style={{
boxShadow:
"inset 1.313px 1.313px 3.938px 0px rgba(0,0,0,0.7)",
}}
>
<Trash2 className="size-3.5" />
{isBulkDeleteMode ? "Cancel" : "Bulk delete"}
</button>
)}
<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>
<div className="mt-4 flex min-h-0 flex-1 flex-col gap-5 overflow-hidden px-4 pb-4 sm:min-h-[420px] sm:flex-row sm:gap-3">
<div className="w-full shrink-0 overflow-x-hidden overflow-y-auto scrollbar-thin sm:w-[200px] sm:pr-1">
<div className="grid grid-cols-2 gap-1 sm:flex sm:flex-col">
{categories.map((category) => {
const isActive = activeCategory === category.id
return (
<button
key={category.id}
type="button"
onClick={() => setActiveCategory(category.id)}
className={cn(
"flex min-w-0 items-center gap-2.5 px-3 py-2 rounded-[12px] text-left transition-colors cursor-pointer focus:outline-none focus:ring-0 sm:w-full",
isActive
? "bg-[#14161A] shadow-inside-out text-[#fafafa]"
: "text-[#A1A1AA] hover:bg-[#14161A]/50 hover:text-[#fafafa]",
dmSansClassName(),
)}
>
<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 ? (
<SpaceGlyph emoji={category.emoji} size={16} />
) : 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="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="col-span-2 mt-3 px-3 pt-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-[#737373] sm:mt-2 sm:px-3 sm:pt-2 sm:pb-1">
Discover
</div>
{discoverCategories.map((category) => {
const isActive = activeCategory === category.id
return (
<button
key={category.id}
type="button"
onClick={() => setActiveCategory(category.id)}
className={cn(
"flex min-w-0 items-center gap-2.5 px-3 py-2 rounded-[12px] text-left transition-colors cursor-pointer focus:outline-none focus:ring-0 sm:w-full",
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 min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-hidden">
{activeCategory.startsWith("discover:") ? (
<DiscoverPanel
catalogId={activeDiscoverId ?? ""}
isConnecting={connectingPluginId === activeDiscoverId}
newKey={
newKey?.pluginId === activeDiscoverId ? newKey.key : null
}
onConnect={() => {
if (activeDiscoverId) connectMutation.mutate(activeDiscoverId)
}}
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="min-h-0 flex-1 overflow-x-hidden overflow-y-auto scrollbar-thin pr-1 sm:max-h-[360px]">
{filteredProjects.length === 0 ? (
<p className="text-center text-[#737373] text-sm py-8">
No spaces found
</p>
) : (
<div className="flex flex-col gap-1">
{showAutoRow && (
<>
<div className="px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
Mode
</div>
{renderAutoRow()}
<div className="my-1.5 h-px bg-[rgba(82,89,102,0.18)]" />
</>
)}
{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>
{!activeCategory.startsWith("discover:") &&
(isBulkDeleteMode || (showNewSpace && onNewSpace)) && (
<div className="flex items-center justify-between gap-3 border-t border-[rgba(82,89,102,0.18)] px-4 py-3">
{isBulkDeleteMode ? (
<>
<p className="min-w-0 text-[13px] font-medium text-[#737373]">
{bulkDeleteCount === 0
? "No spaces selected"
: `${bulkDeleteCount} ${
bulkDeleteCount === 1 ? "space" : "spaces"
} selected`}
</p>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={handleBulkModeToggle}
className={cn(
"px-3 py-2 text-[13px] font-medium text-[#737373] transition-colors hover:text-[#fafafa]",
dmSansClassName(),
)}
>
Cancel
</button>
<button
type="button"
disabled={bulkDeleteCount === 0}
onClick={() => {
if (bulkDeleteCount === 0) return
onBulkDeleteRequest?.(bulkDeleteProjects)
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
}}
className={cn(
"flex items-center gap-2 rounded-full bg-red-600 px-4 py-2 text-[13px] font-medium text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-40",
dmSansClassName(),
)}
>
<Trash2 className="size-4" />
Delete selected
</button>
</div>
</>
) : (
<>
<span />
{showNewSpace && onNewSpace && (
<button
type="button"
onClick={onNewSpace}
className={cn(
"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(),
)}
>
<Plus className="size-4" />
New space
</button>
)}
</>
)}
</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 min-h-0 flex-1 flex-col gap-4 overflow-y-auto scrollbar-thin pr-1">
<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>
)
}