mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-23 07:24:29 +00:00
Agent switcher snapped back to the just-connected plugin while the API-key banner was open; fixed the effect so manual tab clicks stick. Replaced the double header + blurred install-steps overlay with named chip tabs (matching existing chip style), inline Connect + Docs, and a plain step-title preview. Fixes ENG-1141
1872 lines
56 KiB
TypeScript
1872 lines
56 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, DialogTitle } from "@repo/ui/components/dialog"
|
|
import { Drawer, DrawerContent, DrawerTitle } from "@repo/ui/components/drawer"
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar"
|
|
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,
|
|
Lock,
|
|
} from "lucide-react"
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
|
import { toast } from "sonner"
|
|
import { DEFAULT_PROJECT_ID, SHARED_TEAM_BRAIN_TAG } 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 { groupAgentSpaces, type AgentSpaceGroup } from "@/lib/agent-space"
|
|
import {
|
|
PLUGIN_CATALOG,
|
|
spacePluginIdToCatalogId,
|
|
type PluginInfo,
|
|
} from "@/lib/plugin-catalog"
|
|
import { INSET, 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"
|
|
import { useHasCompanyBrain } from "@/hooks/use-company-brain"
|
|
import { Logo } from "@ui/assets/Logo"
|
|
|
|
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
|
|
}
|
|
|
|
const AGENT_CATALOG_IDS = [
|
|
"claude_code",
|
|
"codex",
|
|
"opencode",
|
|
"cursor",
|
|
] as const
|
|
|
|
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 selectedTagSet = useMemo(
|
|
() => new Set(selectedProjects),
|
|
[selectedProjects],
|
|
)
|
|
const isMobile = useIsMobile()
|
|
|
|
const pluginTags = useMemo(
|
|
() =>
|
|
projects
|
|
.filter((p) => !!detectPluginSpace(p.containerTag))
|
|
.map((p) => p.containerTag),
|
|
[projects],
|
|
)
|
|
|
|
const pluginMetaMap = usePluginSpaceMeta(pluginTags)
|
|
const hasCompanyBrain = useHasCompanyBrain()
|
|
const agentGroups = useMemo(
|
|
() => groupAgentSpaces(projects, pluginMetaMap),
|
|
[projects, pluginMetaMap],
|
|
)
|
|
const agentGroupByTag = useMemo(() => {
|
|
const map = new Map<string, AgentSpaceGroup<ContainerTagListType>>()
|
|
for (const group of agentGroups) {
|
|
for (const tag of group.containerTags) map.set(tag, group)
|
|
}
|
|
return map
|
|
}, [agentGroups])
|
|
|
|
const allSpaces = useMemo(() => {
|
|
const rest = projects
|
|
.filter((p) => p.containerTag !== DEFAULT_PROJECT_ID)
|
|
.filter((p) => {
|
|
const group = agentGroupByTag.get(p.containerTag)
|
|
return !group || group.representative.containerTag === p.containerTag
|
|
})
|
|
.sort(compareSpacesUserFirst)
|
|
// Company brain orgs use real Private + Team Brain spaces; skip the
|
|
// synthetic "My Space" default that would otherwise duplicate Private.
|
|
if (hasCompanyBrain) return rest
|
|
const defaultSpace = {
|
|
id: "default",
|
|
name: "My Space",
|
|
emoji: "📁",
|
|
containerTag: DEFAULT_PROJECT_ID,
|
|
isExperimental: false,
|
|
isNova: false,
|
|
createdAt: "",
|
|
updatedAt: "",
|
|
} as ContainerTagListType
|
|
return [defaultSpace, ...rest]
|
|
}, [projects, hasCompanyBrain, agentGroupByTag])
|
|
|
|
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()) {
|
|
if (pluginId === "agents") {
|
|
for (const catalogId of AGENT_CATALOG_IDS) connectedIds.add(catalogId)
|
|
continue
|
|
}
|
|
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 availablePluginIds = useMemo(
|
|
() => availablePluginsData?.plugins ?? Object.keys(PLUGIN_CATALOG),
|
|
[availablePluginsData],
|
|
)
|
|
const agentDiscoverCatalogIds = useMemo(
|
|
() =>
|
|
AGENT_CATALOG_IDS.filter(
|
|
(id) =>
|
|
availablePluginIds.includes(id) &&
|
|
(!apiKeyConnectedIds.has(id) && !connectedCatalogIds.has(id)
|
|
? true
|
|
: newKey?.pluginId === id),
|
|
),
|
|
[
|
|
availablePluginIds,
|
|
apiKeyConnectedIds,
|
|
connectedCatalogIds,
|
|
newKey?.pluginId,
|
|
],
|
|
)
|
|
|
|
const discoverCategories = useMemo<Category[]>(() => {
|
|
const categories: Category[] = availablePluginIds
|
|
.filter((id) => !!PLUGIN_CATALOG[id])
|
|
.filter(
|
|
(id) =>
|
|
!AGENT_CATALOG_IDS.some((agentCatalogId) => agentCatalogId === 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,
|
|
}
|
|
})
|
|
if (agentDiscoverCatalogIds.length > 0) {
|
|
categories.unshift({
|
|
id: "discover:agents",
|
|
label: "Agents",
|
|
iconSrc: null,
|
|
emoji: null,
|
|
count: 0,
|
|
})
|
|
}
|
|
return categories
|
|
}, [
|
|
availablePluginIds,
|
|
agentDiscoverCatalogIds.length,
|
|
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(
|
|
"Plugin access was denied. Check your plan or try again.",
|
|
)
|
|
}
|
|
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(
|
|
agentGroupByTag.get(containerTag)?.containerTags ?? [containerTag],
|
|
)
|
|
setSearchQuery("")
|
|
},
|
|
[agentGroupByTag, 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 agentGroup = agentGroupByTag.get(p.containerTag)
|
|
const projectName =
|
|
agentGroup?.projectName ??
|
|
agentGroup?.label ??
|
|
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,
|
|
agentGroupByTag,
|
|
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[] = []
|
|
const seen = new Set<string>()
|
|
for (const tag of recents) {
|
|
const representativeTag =
|
|
agentGroupByTag.get(tag)?.representative.containerTag ?? tag
|
|
const p = byTag.get(representativeTag)
|
|
if (p && !seen.has(p.containerTag)) {
|
|
seen.add(p.containerTag)
|
|
out.push(p)
|
|
}
|
|
if (out.length >= 5) break
|
|
}
|
|
return out
|
|
}, [recents, searchQuery, activeCategory, allSpaces, agentGroupByTag])
|
|
|
|
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 &&
|
|
!agentGroupByTag.has(project.containerTag),
|
|
)
|
|
.map((project) => project.containerTag),
|
|
[recentProjects, mainList, agentGroupByTag],
|
|
)
|
|
|
|
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 &&
|
|
!agentGroupByTag.has(project.containerTag) &&
|
|
bulkDeleteTags.has(project.containerTag),
|
|
)
|
|
.map((project) => ({
|
|
id: project.id,
|
|
name: spaceSelectorDisplayName(project, project.containerTag, {
|
|
currentUserId: user?.id,
|
|
}),
|
|
containerTag: project.containerTag,
|
|
})),
|
|
[allSpaces, bulkDeleteTags, agentGroupByTag, user?.id],
|
|
)
|
|
|
|
const bulkDeleteCount = bulkDeleteProjects.length
|
|
|
|
const renderRow = useCallback(
|
|
(project: ContainerTagListType) => {
|
|
const agentGroup = agentGroupByTag.get(project.containerTag)
|
|
const isSelected = agentGroup
|
|
? agentGroup.containerTags.some((tag) => selectedTagSet.has(tag))
|
|
: currentSelection === project.containerTag
|
|
const plugin = detectPluginSpace(project.containerTag)
|
|
const pluginProjectName =
|
|
agentGroup?.projectName ??
|
|
agentGroup?.label ??
|
|
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 isCbSpace =
|
|
hasCompanyBrain && !plugin && !isOwnSpace && !!project.visibility
|
|
const isShared = project.visibility === "public"
|
|
const orgName = org?.name ?? "your team"
|
|
const orgMembers = org?.members ?? []
|
|
const memberCount = orgMembers.length
|
|
const isDefaultBrain = project.containerTag === SHARED_TEAM_BRAIN_TAG
|
|
const descriptor = isCbSpace
|
|
? isShared
|
|
? `${orgName} · ${memberCount} ${
|
|
memberCount === 1 ? "member" : "members"
|
|
}`
|
|
: "Only you"
|
|
: null
|
|
const canEdit = !isDefault && !plugin && !isOwnSpace
|
|
const canBulkDelete = enableDelete && !isDefault && !agentGroup
|
|
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.pluginId === "agents" ? null : 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]!" />
|
|
) : isCbSpace ? (
|
|
isShared ? (
|
|
<span className="shrink-0 flex items-center" aria-hidden>
|
|
{orgMembers.slice(0, 3).map((m, i) => (
|
|
<Avatar
|
|
key={m.id}
|
|
className={cn(
|
|
"size-6 ring-2 ring-[#14161A]",
|
|
i > 0 && "-ml-2",
|
|
)}
|
|
>
|
|
<AvatarImage
|
|
src={m.user?.image ?? ""}
|
|
alt=""
|
|
className="object-cover"
|
|
/>
|
|
<AvatarFallback className="bg-[#1E232B] text-white text-[10px] font-medium">
|
|
{(m.user?.name ?? m.user?.email ?? "U")
|
|
.charAt(0)
|
|
.toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
))}
|
|
{memberCount > 3 && (
|
|
<span className="-ml-2 flex size-6 items-center justify-center rounded-full bg-[#1E232B] text-[#A3A3A3] text-[9px] font-medium ring-2 ring-[#14161A]">
|
|
+{memberCount - 3}
|
|
</span>
|
|
)}
|
|
</span>
|
|
) : (
|
|
<span className="shrink-0 relative" aria-hidden>
|
|
<Avatar className="size-6">
|
|
<AvatarImage
|
|
src={user?.image ?? ""}
|
|
alt=""
|
|
className="object-cover"
|
|
/>
|
|
<AvatarFallback className="bg-[#1E232B] text-white text-[10px] font-medium">
|
|
{(user?.name ?? user?.email ?? "U")
|
|
.charAt(0)
|
|
.toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span className="absolute -right-1 -bottom-1 flex size-3.5 items-center justify-center rounded-full bg-[#14161A] text-[#A3A3A3]">
|
|
<Lock className="size-2" />
|
|
</span>
|
|
</span>
|
|
)
|
|
) : (
|
|
<SpaceGlyph
|
|
emoji={project.emoji}
|
|
size={20}
|
|
className="shrink-0"
|
|
/>
|
|
)}
|
|
<span className="flex min-w-0 flex-1 flex-col">
|
|
<span
|
|
className="truncate text-[#fafafa] text-sm font-medium"
|
|
title={plugin ? project.containerTag : displayName}
|
|
>
|
|
{plugin ? (
|
|
plugin.pluginId === "agents" ? (
|
|
(pluginIdLabel ?? plugin.label)
|
|
) : (
|
|
<>
|
|
{plugin.label}
|
|
{pluginIdLabel && (
|
|
<span className="ml-1.5 text-[12px] text-[#737373]">
|
|
· {pluginIdLabel}
|
|
</span>
|
|
)}
|
|
</>
|
|
)
|
|
) : (
|
|
displayName
|
|
)}
|
|
</span>
|
|
{descriptor && (
|
|
<span className="truncate text-[11px] text-[#737373]">
|
|
{descriptor}
|
|
</span>
|
|
)}
|
|
</span>
|
|
{isCbSpace && isDefaultBrain && (
|
|
<span className="ml-2 shrink-0 rounded-[4px] bg-[#4BA0FA]/15 px-1.5 py-0.5 text-[10px] font-medium text-[#4BA0FA]">
|
|
Default
|
|
</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 &&
|
|
!agentGroup &&
|
|
!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,
|
|
agentGroupByTag,
|
|
bulkDeleteTags,
|
|
currentSelection,
|
|
selectedTagSet,
|
|
editingProject,
|
|
enableDelete,
|
|
handleEditKeyDown,
|
|
handleSelect,
|
|
hasCompanyBrain,
|
|
isBulkDeleteMode,
|
|
onDeleteRequest,
|
|
pluginMetaMap,
|
|
saveEditing,
|
|
startEditing,
|
|
toggleBulkDeleteTag,
|
|
updateProjectMutation.isPending,
|
|
user?.id,
|
|
org?.name,
|
|
org?.members,
|
|
user?.email,
|
|
user?.image,
|
|
user?.name,
|
|
],
|
|
)
|
|
|
|
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: Category, 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.id === "plugin:agents" ||
|
|
category.id === "discover:agents" ? (
|
|
<Logo className="h-[18px] w-[22px]" />
|
|
) : 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 discoverPanelContent =
|
|
activeDiscoverId === "agents" ? (
|
|
<AgentsDiscoverPanel
|
|
catalogIds={agentDiscoverCatalogIds}
|
|
connectingPluginId={connectingPluginId}
|
|
newKey={newKey}
|
|
onConnect={(catalogId) => connectMutation.mutate(catalogId)}
|
|
onDismissKey={() => setNewKey(null)}
|
|
/>
|
|
) : (
|
|
<DiscoverPanel
|
|
catalogId={activeDiscoverId ?? ""}
|
|
isConnecting={connectingPluginId === activeDiscoverId}
|
|
newKey={newKey?.pluginId === activeDiscoverId ? newKey.key : null}
|
|
onConnect={() => {
|
|
if (activeDiscoverId) connectMutation.mutate(activeDiscoverId)
|
|
}}
|
|
onDismissKey={() => setNewKey(null)}
|
|
/>
|
|
)
|
|
|
|
const rightPanelContent = activeCategory.startsWith("discover:") ? (
|
|
discoverPanelContent
|
|
) : (
|
|
<>
|
|
<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>
|
|
</>
|
|
)}
|
|
{hasCompanyBrain && recentProjects.length === 0
|
|
? (() => {
|
|
const shared = mainList.filter(
|
|
(p) => p.visibility === "public",
|
|
)
|
|
const personal = mainList.filter(
|
|
(p) => p.visibility !== "public",
|
|
)
|
|
return (
|
|
<>
|
|
{shared.length > 0 && (
|
|
<>
|
|
<div className="px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
|
|
Shared
|
|
</div>
|
|
{shared.map(renderRow)}
|
|
</>
|
|
)}
|
|
{personal.length > 0 && (
|
|
<>
|
|
<div className="px-3 pt-2 pb-0.5 text-[10px] uppercase tracking-[0.08em] text-[#737373]">
|
|
Personal
|
|
</div>
|
|
{personal.map(renderRow)}
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
})()
|
|
: 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">
|
|
<DialogTitle
|
|
className={cn(
|
|
"font-semibold text-[#fafafa]",
|
|
dmSans125ClassName(),
|
|
)}
|
|
>
|
|
Select Space
|
|
</DialogTitle>
|
|
<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.id === "plugin:agents" ? (
|
|
<Logo className="h-[18px] w-[22px]" />
|
|
) : 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.id === "discover:agents" ? (
|
|
<Logo className="h-[18px] w-[22px]" />
|
|
) : 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:") ? (
|
|
discoverPanelContent
|
|
) : (
|
|
<>
|
|
<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 AgentsDiscoverPanel({
|
|
catalogIds,
|
|
connectingPluginId,
|
|
newKey,
|
|
onConnect,
|
|
onDismissKey,
|
|
}: {
|
|
catalogIds: readonly string[]
|
|
connectingPluginId: string | null
|
|
newKey: { pluginId: string; key: string } | null
|
|
onConnect: (catalogId: string) => void
|
|
onDismissKey: () => void
|
|
}) {
|
|
const [activeCatalogId, setActiveCatalogId] = useState(
|
|
newKey?.pluginId ?? catalogIds[0] ?? "codex",
|
|
)
|
|
|
|
// snap to the just-connected plugin only when the key changes, so manual tab clicks aren't overridden
|
|
useEffect(() => {
|
|
if (newKey?.pluginId) setActiveCatalogId(newKey.pluginId)
|
|
}, [newKey?.pluginId])
|
|
|
|
useEffect(() => {
|
|
if (!catalogIds.includes(activeCatalogId)) {
|
|
setActiveCatalogId(catalogIds[0] ?? "codex")
|
|
}
|
|
}, [activeCatalogId, catalogIds])
|
|
|
|
if (catalogIds.length === 0) {
|
|
return (
|
|
<p className="py-8 text-center text-sm text-[#737373]">
|
|
Claude Code, Codex, and OpenCode are connected.
|
|
</p>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
|
|
<div className="scrollbar-none flex shrink-0 items-center gap-1.5 overflow-x-auto border-b border-white/[0.06] pb-3">
|
|
{catalogIds.map((catalogId) => {
|
|
const info = PLUGIN_CATALOG[catalogId]
|
|
if (!info) return null
|
|
const isActive = activeCatalogId === catalogId
|
|
return (
|
|
<button
|
|
key={catalogId}
|
|
type="button"
|
|
onClick={() => setActiveCatalogId(catalogId)}
|
|
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] hover:text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
<Image src={info.icon} alt="" width={16} height={16} />
|
|
<span className="text-[12px] font-medium">{info.name}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
<DiscoverPanel
|
|
catalogId={activeCatalogId}
|
|
hideHeader
|
|
isConnecting={connectingPluginId === activeCatalogId}
|
|
newKey={newKey?.pluginId === activeCatalogId ? newKey.key : null}
|
|
onConnect={() => onConnect(activeCatalogId)}
|
|
onDismissKey={onDismissKey}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DiscoverPanel({
|
|
catalogId,
|
|
isConnecting,
|
|
newKey,
|
|
onConnect,
|
|
onDismissKey,
|
|
hideHeader,
|
|
}: {
|
|
catalogId: string
|
|
isConnecting: boolean
|
|
newKey: string | null
|
|
onConnect: () => void
|
|
onDismissKey: () => void
|
|
hideHeader?: boolean
|
|
}) {
|
|
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">
|
|
{hideHeader ? (
|
|
<p className="text-[13px] leading-[1.4] text-[#737373]">
|
|
{info.tagline}
|
|
</p>
|
|
) : (
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] border border-[#1E293B] bg-[#080B0F]">
|
|
<Image
|
|
alt={info.name}
|
|
className="size-[22px]"
|
|
height={22}
|
|
src={info.icon}
|
|
width={22}
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p
|
|
className={cn(
|
|
dmSans125ClassName(),
|
|
"font-semibold text-[15px] text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
{info.name}
|
|
</p>
|
|
<p className="text-[12px] text-[#737373] leading-[1.4]">
|
|
{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>
|
|
)}
|
|
|
|
{isConnected ? (
|
|
<InstallSteps steps={setupSteps} apiKey={newKey ?? undefined} />
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-2">
|
|
<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 items-center justify-center gap-1.5 rounded-full px-3 sm:h-9 sm:px-4",
|
|
"text-[12px] font-medium text-[#A1A1AA] sm:text-[14px]",
|
|
"transition-colors hover:text-[#FAFAFA]",
|
|
)}
|
|
>
|
|
<BookOpen className="size-3.5" /> Docs
|
|
</a>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2.5">
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-[#737373]">
|
|
What you'll do next
|
|
</p>
|
|
<ol className="flex flex-col gap-2">
|
|
{setupSteps.map((step, i) => (
|
|
<li key={step.title} className="flex items-center gap-2.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>
|
|
<span className="text-[13px] text-[#A1A1AA]">
|
|
{step.title}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|