Add shared Agents memory workspace (#1290)

This commit is contained in:
Vedant Mahajan 2026-07-24 00:48:44 +05:30 committed by GitHub
parent 5a3ff85ea5
commit cfc2b49192
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 894 additions and 95 deletions

View file

@ -458,7 +458,9 @@ function getPluginClientFromSpace(
for (const tag of [...containerTags, ...memorySpaceTags]) {
const plugin = detectPluginSpace(tag)
if (plugin) return normalizePluginClientId(plugin.pluginId)
if (plugin && plugin.pluginId !== "agents") {
return normalizePluginClientId(plugin.pluginId)
}
}
return null
@ -467,6 +469,11 @@ function getPluginClientFromSpace(
function getPluginClientFromDocument(
document: DocumentWithMemories,
): string | null {
if (typeof document.source === "string") {
const sourceClient = normalizePluginClientId(document.source)
if (PLUGIN_DISPLAY_CATALOG[sourceClient]) return sourceClient
}
for (const metadata of getDocumentMetadataRecords(document)) {
const metadataClient =
typeof metadata.sm_client === "string"
@ -478,7 +485,10 @@ function getPluginClientFromDocument(
: null
if (metadataClient) return normalizePluginClientId(metadataClient)
if (metadata.sm_source === "claude-code-plugin") return "claude_code"
if (typeof metadata.sm_source === "string") {
const sourceClient = normalizePluginClientId(metadata.sm_source)
if (PLUGIN_DISPLAY_CATALOG[sourceClient]) return sourceClient
}
}
if (hasClaudeCodeContainer(document)) return "claude_code"

View file

@ -34,10 +34,18 @@ import { getFaviconUrl, isSupermemoryFileUrl } from "@/lib/url-helpers"
import { QuickNoteCard } from "./quick-note-card"
import type { HighlightItem } from "./highlights-card"
import { Button } from "@ui/components/button"
import { ToggleGroup, ToggleGroupItem } from "@ui/components/toggle-group"
import {
agentSourceParam,
categoriesParam,
type IntegrationParamValue,
} from "@/lib/search-params"
import {
AGENT_SOURCE_FILTERS,
agentSourceValues,
isAgentsSelection,
type AgentSourceFilter,
} from "@/lib/agent-space"
import { NovaEmptyState } from "@/components/nova/nova-empty-state"
import {
AlertDialog,
@ -300,16 +308,30 @@ export function MemoriesGrid({
)
const { user, isSessionPending } = useAuth()
const { effectiveContainerTags, selectedProject } = useProject()
const profileContainerTag = selectedProject ?? effectiveContainerTags[0] ?? ""
const processingStatusMap = useProcessingDocuments()
const isMobile = useIsMobile()
const [selectedCategories, setSelectedCategories] = useQueryState(
"categories",
categoriesParam,
)
const [selectedAgentSource, setSelectedAgentSource] = useQueryState(
"agent",
agentSourceParam,
)
const selectedCategoriesSet = useMemo(
() => new Set(selectedCategories),
[selectedCategories],
)
const showAgentFilters = useMemo(
() => isAgentsSelection(effectiveContainerTags),
[effectiveContainerTags],
)
const selectedSources = useMemo(
() =>
showAgentFilters ? agentSourceValues(selectedAgentSource) : undefined,
[showAgentFilters, selectedAgentSource],
)
const { data: facetsData } = useQuery({
queryKey: ["document-facets", effectiveContainerTags],
@ -331,6 +353,41 @@ export function MemoriesGrid({
enabled: !!user,
})
const { data: agentSourceCounts } = useQuery({
queryKey: ["agent-source-counts", effectiveContainerTags],
queryFn: async (): Promise<Partial<Record<AgentSourceFilter, number>>> => {
const entries = await Promise.all(
AGENT_SOURCE_FILTERS.map(async (filter) => {
const response = await $fetch("@post/documents/documents", {
body: {
page: 1,
limit: 1,
sort: "createdAt",
order: "desc",
containerTags: effectiveContainerTags,
sources: [...filter.sources],
},
disableValidation: true,
})
if (response.error) {
throw new Error(
response.error?.message || "Failed to fetch agent source count",
)
}
const result = response.data as {
pagination?: { totalItems?: number }
} | null
return [filter.value, result?.pagination?.totalItems ?? 0] as const
}),
)
return Object.fromEntries(entries)
},
staleTime: 5 * 60 * 1000,
enabled: !!user && showAgentFilters,
})
const {
data,
error,
@ -343,6 +400,7 @@ export function MemoriesGrid({
"documents-with-memories",
effectiveContainerTags,
selectedCategories,
selectedSources,
],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
@ -355,6 +413,7 @@ export function MemoriesGrid({
containerTags: effectiveContainerTags,
categories:
selectedCategories.length > 0 ? selectedCategories : undefined,
sources: selectedSources,
},
disableValidation: true,
})
@ -411,7 +470,8 @@ export function MemoriesGrid({
const handleSelectAll = useCallback(() => {
setSelectedCategories(null)
}, [setSelectedCategories])
setSelectedAgentSource(null)
}, [setSelectedCategories, setSelectedAgentSource])
const documents = useMemo(() => {
return (
@ -590,7 +650,7 @@ export function MemoriesGrid({
return (
<div className="relative flex h-full min-h-0 flex-col">
{!isEmpty && !isSelectionMode && (
{(!isEmpty || (facetsData?.total ?? 0) > 0) && !isSelectionMode && (
<div
id="filter-pills"
className="mb-3 flex flex-col gap-2 pr-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
@ -601,6 +661,7 @@ export function MemoriesGrid({
dmSansClassName(),
"shrink-0 whitespace-nowrap rounded-full border border-[#161F2C] bg-[#0D121A] px-2.5 py-1 text-xs h-auto hover:bg-[#00173C] hover:border-[#2261CA33]",
selectedCategories.length === 0 &&
!selectedSources &&
"bg-[#00173C] border-[#2261CA33]",
)}
onClick={handleSelectAll}
@ -627,6 +688,36 @@ export function MemoriesGrid({
<span className="ml-1 text-[#737373]">({facet.count})</span>
</Button>
))}
{showAgentFilters && (
<ToggleGroup
type="single"
value={selectedAgentSource ?? ""}
onValueChange={(value) =>
setSelectedAgentSource(
value ? (value as AgentSourceFilter) : null,
)
}
aria-label="Filter memories by agent"
className="gap-1.5"
>
{AGENT_SOURCE_FILTERS.map((filter) => (
<ToggleGroupItem
key={filter.value}
value={filter.value}
aria-label={`Show ${filter.label} memories`}
className={cn(
dmSansClassName(),
"h-auto min-w-0 flex-none shrink-0 rounded-full! border border-[#161F2C]! bg-[#0D121A] px-2.5 py-1 text-xs hover:border-[#2261CA33]! hover:bg-[#00173C] data-[state=on]:border-[#2261CA33]! data-[state=on]:bg-[#00173C]",
)}
>
{filter.label}
<span className="ml-1 text-[#737373]">
({agentSourceCounts?.[filter.value] ?? 0})
</span>
</ToggleGroupItem>
))}
</ToggleGroup>
)}
</div>
<div className="order-1 flex w-full items-center justify-between gap-2 sm:order-2 sm:w-auto sm:justify-start sm:self-start">
{/* View mode toggle — segmented control */}
@ -894,7 +985,7 @@ export function MemoriesGrid({
<AnimatePresence initial={false}>
{profileOpen && !isMobile && (
<SpaceProfilePanel
containerTag={selectedProject}
containerTag={profileContainerTag}
isOpen
onClose={() => setProfileOpen(false)}
/>
@ -904,7 +995,7 @@ export function MemoriesGrid({
)}
</div>
<SpaceProfileModal
containerTag={selectedProject}
containerTag={profileContainerTag}
open={profileOpen && isMobile}
onOpenChange={setProfileOpen}
/>

View file

@ -41,6 +41,7 @@ import {
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,
@ -53,6 +54,7 @@ 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
@ -93,6 +95,8 @@ type Category = {
count: number
}
const AGENT_CATALOG_IDS = ["claude_code", "codex"] as const
export function SelectSpacesModal({
isOpen,
onClose,
@ -122,6 +126,10 @@ export function SelectSpacesModal({
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(
@ -134,10 +142,25 @@ export function SelectSpacesModal({
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.
@ -153,7 +176,7 @@ export function SelectSpacesModal({
updatedAt: "",
} as ContainerTagListType
return [defaultSpace, ...rest]
}, [projects, hasCompanyBrain])
}, [projects, hasCompanyBrain, agentGroupByTag])
const { categories, connectedCatalogIds } = useMemo<{
categories: Category[]
@ -188,6 +211,10 @@ export function SelectSpacesModal({
.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)
}
@ -295,12 +322,34 @@ export function SelectSpacesModal({
}
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 availableIds =
availablePluginsData?.plugins ?? Object.keys(PLUGIN_CATALOG)
return availableIds
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),
)
@ -314,7 +363,22 @@ export function SelectSpacesModal({
count: 0,
}
})
}, [availablePluginsData, apiKeyConnectedIds, connectedCatalogIds])
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) => {
@ -392,10 +456,12 @@ export function SelectSpacesModal({
setIsBulkDeleteMode(false)
setBulkDeleteTags(new Set())
setLastBulkDeleteTag(null)
onApply([containerTag])
onApply(
agentGroupByTag.get(containerTag)?.containerTags ?? [containerTag],
)
setSearchQuery("")
},
[onApply],
[agentGroupByTag, onApply],
)
const handleSelectAuto = useCallback(() => {
@ -467,7 +533,11 @@ export function SelectSpacesModal({
const query = searchQuery.trim().toLowerCase()
return byCategory.filter((p) => {
const plugin = detectPluginSpace(p.containerTag)
const projectName = pluginMetaMap.get(p.containerTag)?.projectName
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,
})
@ -480,7 +550,14 @@ export function SelectSpacesModal({
(projectName?.toLowerCase().includes(query) ?? false)
)
})
}, [allSpaces, activeCategory, searchQuery, pluginMetaMap, user?.id])
}, [
allSpaces,
activeCategory,
searchQuery,
pluginMetaMap,
agentGroupByTag,
user?.id,
])
const recentProjects = useMemo<ContainerTagListType[]>(() => {
if (!recents?.length) return []
@ -488,13 +565,19 @@ export function SelectSpacesModal({
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 p = byTag.get(tag)
if (p) out.push(p)
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])
}, [recents, searchQuery, activeCategory, allSpaces, agentGroupByTag])
const recentSet = useMemo(
() => new Set(recentProjects.map((p) => p.containerTag)),
@ -525,9 +608,13 @@ export function SelectSpacesModal({
const visibleBulkDeleteTags = useMemo(
() =>
[...recentProjects, ...mainList]
.filter((project) => project.containerTag !== DEFAULT_PROJECT_ID)
.filter(
(project) =>
project.containerTag !== DEFAULT_PROJECT_ID &&
!agentGroupByTag.has(project.containerTag),
)
.map((project) => project.containerTag),
[recentProjects, mainList],
[recentProjects, mainList, agentGroupByTag],
)
const toggleBulkDeleteTag = useCallback(
@ -564,6 +651,7 @@ export function SelectSpacesModal({
.filter(
(project) =>
project.containerTag !== DEFAULT_PROJECT_ID &&
!agentGroupByTag.has(project.containerTag) &&
bulkDeleteTags.has(project.containerTag),
)
.map((project) => ({
@ -573,18 +661,22 @@ export function SelectSpacesModal({
}),
containerTag: project.containerTag,
})),
[allSpaces, bulkDeleteTags, user?.id],
[allSpaces, bulkDeleteTags, agentGroupByTag, user?.id],
)
const bulkDeleteCount = bulkDeleteProjects.length
const renderRow = useCallback(
(project: ContainerTagListType) => {
const isSelected = currentSelection === project.containerTag
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 = pluginMetaMap.get(
project.containerTag,
)?.projectName
const pluginProjectName =
agentGroup?.projectName ??
agentGroup?.label ??
pluginMetaMap.get(project.containerTag)?.projectName
const pluginIdLabel = pluginProjectName || plugin?.projectId
const displayName = spaceSelectorDisplayName(
project,
@ -610,7 +702,7 @@ export function SelectSpacesModal({
: "Only you"
: null
const canEdit = !isDefault && !plugin && !isOwnSpace
const canBulkDelete = enableDelete && !isDefault
const canBulkDelete = enableDelete && !isDefault && !agentGroup
const isEditing = editingProject?.containerTag === project.containerTag
const isBulkDeleteSelected = bulkDeleteTags.has(project.containerTag)
const trimmedEditName = editingProject?.name.trim() ?? ""
@ -718,7 +810,7 @@ export function SelectSpacesModal({
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 ? (
plugin.pluginId === "agents" ? null : plugin.iconSrc ? (
<Image
src={plugin.iconSrc}
alt=""
@ -798,14 +890,18 @@ export function SelectSpacesModal({
title={plugin ? project.containerTag : displayName}
>
{plugin ? (
<>
{plugin.label}
{pluginIdLabel && (
<span className="ml-1.5 text-[12px] text-[#737373]">
· {pluginIdLabel}
</span>
)}
</>
plugin.pluginId === "agents" ? (
(pluginIdLabel ?? plugin.label)
) : (
<>
{plugin.label}
{pluginIdLabel && (
<span className="ml-1.5 text-[12px] text-[#737373]">
· {pluginIdLabel}
</span>
)}
</>
)
) : (
displayName
)}
@ -838,6 +934,7 @@ export function SelectSpacesModal({
)}
{enableDelete &&
!isDefault &&
!agentGroup &&
!isEditing &&
!isBulkDeleteMode &&
onDeleteRequest && (
@ -862,8 +959,10 @@ export function SelectSpacesModal({
},
[
cancelEditing,
agentGroupByTag,
bulkDeleteTags,
currentSelection,
selectedTagSet,
editingProject,
enableDelete,
handleEditKeyDown,
@ -922,16 +1021,7 @@ export function SelectSpacesModal({
)
}, [currentSelection, handleSelectAuto])
const renderCategoryChip = (
category: {
id: string
label: string
count?: number
iconSrc?: string
emoji?: string
},
isDiscover: boolean,
) => {
const renderCategoryChip = (category: Category, isDiscover: boolean) => {
const isActive = activeCategory === category.id
return (
<button
@ -954,6 +1044,9 @@ export function SelectSpacesModal({
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}
@ -993,16 +1086,29 @@ export function SelectSpacesModal({
)
}
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:") ? (
<DiscoverPanel
catalogId={activeDiscoverId ?? ""}
isConnecting={connectingPluginId === activeDiscoverId}
newKey={newKey?.pluginId === activeDiscoverId ? newKey.key : null}
onConnect={() => {
if (activeDiscoverId) connectMutation.mutate(activeDiscoverId)
}}
onDismissKey={() => setNewKey(null)}
/>
discoverPanelContent
) : (
<>
<div className="relative shrink-0">
@ -1314,6 +1420,8 @@ export function SelectSpacesModal({
isActive ? "text-[#fafafa]" : "text-[#737373]",
)}
/>
) : category.id === "plugin:agents" ? (
<Logo className="h-[18px] w-[22px]" />
) : category.iconSrc ? (
<Image
src={category.iconSrc}
@ -1372,7 +1480,9 @@ export function SelectSpacesModal({
)}
>
<span className="shrink-0 w-5 h-5 flex items-center justify-center">
{category.iconSrc ? (
{category.id === "discover:agents" ? (
<Logo className="h-[18px] w-[22px]" />
) : category.iconSrc ? (
<Image
src={category.iconSrc}
alt=""
@ -1404,17 +1514,7 @@ export function SelectSpacesModal({
<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)}
/>
discoverPanelContent
) : (
<>
<div className="relative">
@ -1538,6 +1638,85 @@ export function SelectSpacesModal({
)
}
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",
)
useEffect(() => {
if (newKey?.pluginId && catalogIds.includes(newKey.pluginId)) {
setActiveCatalogId(newKey.pluginId)
return
}
if (!catalogIds.includes(activeCatalogId)) {
setActiveCatalogId(catalogIds[0] ?? "codex")
}
}, [activeCatalogId, catalogIds, newKey?.pluginId])
if (catalogIds.length === 0) {
return (
<p className="py-8 text-center text-sm text-[#737373]">
Claude Code and Codex are connected.
</p>
)
}
return (
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden">
<div className="flex items-center gap-2">
<div className="mr-1 flex size-9 shrink-0 items-center justify-center rounded-[9px] border border-[#1E293B] bg-[#080B0F]">
<Logo className="h-[22px] w-[27px]" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-[#FAFAFA]">Agents</p>
<p className="text-[11px] text-[#737373]">
Claude Code and Codex share project memory
</p>
</div>
{catalogIds.map((catalogId) => {
const info = PLUGIN_CATALOG[catalogId]
if (!info) return null
return (
<button
key={catalogId}
type="button"
onClick={() => setActiveCatalogId(catalogId)}
className={cn(
"flex size-9 items-center justify-center rounded-[9px] border transition-colors",
activeCatalogId === catalogId
? "border-[#4BA0FA]/60 bg-[#00173C]"
: "border-[#1E293B] bg-[#080B0F] opacity-60 hover:opacity-100",
)}
aria-label={`Set up ${info.name}`}
>
<Image src={info.icon} alt="" width={20} height={20} />
</button>
)
})}
</div>
<DiscoverPanel
catalogId={activeCatalogId}
isConnecting={connectingPluginId === activeCatalogId}
newKey={newKey?.pluginId === activeCatalogId ? newKey.key : null}
onConnect={() => onConnect(activeCatalogId)}
onDismissKey={onDismissKey}
/>
</div>
)
}
function DiscoverPanel({
catalogId,
isConnecting,

View file

@ -43,8 +43,10 @@ import {
} from "@/lib/ingest-auto-space"
import { detectPluginSpace, pluginInitial } from "@/lib/plugin-space"
import { usePluginSpaceMeta } from "@/hooks/use-plugin-space-meta"
import { groupAgentSpaces, type AgentSpaceGroup } from "@/lib/agent-space"
import NovaOrb from "@/components/nova/nova-orb"
import { AutoSpaceIcon } from "@/components/nova/auto-space-icon"
import { Logo } from "@ui/assets/Logo"
export interface SpaceSelectorProps {
selectedProjects: string[]
@ -158,8 +160,10 @@ export function SpaceSelector({
}, [])
const activeTag = selectedProjects[0] ?? defaultTag
const activeTags =
selectedProjects.length > 0 ? selectedProjects : [defaultTag]
const { data: spaceCountData } = useQuery({
queryKey: ["space-selector-count", activeTag],
queryKey: ["space-selector-count", activeTags],
queryFn: async (): Promise<number> => {
const response = await $fetch("@post/documents/documents", {
body: {
@ -167,7 +171,7 @@ export function SpaceSelector({
limit: 1,
sort: "createdAt",
order: "desc",
containerTags: [activeTag],
containerTags: activeTags,
},
disableValidation: true,
})
@ -178,7 +182,7 @@ export function SpaceSelector({
return data?.pagination?.totalItems ?? 0
},
staleTime: 30 * 1000,
enabled: !!activeTag && activeTag !== AUTO_CHAT_SPACE_ID,
enabled: activeTags.length > 0 && !activeTags.includes(AUTO_CHAT_SPACE_ID),
})
const pluginTags = useMemo(
@ -191,6 +195,13 @@ export function SpaceSelector({
[allProjects],
)
const pluginMetaMap = usePluginSpaceMeta(pluginTags)
const agentGroupByTag = useMemo(() => {
const map = new Map<string, AgentSpaceGroup<ContainerTagListType>>()
for (const group of groupAgentSpaces(allProjects, pluginMetaMap)) {
for (const tag of group.containerTags) map.set(tag, group)
}
return map
}, [allProjects, pluginMetaMap])
const displayInfo = useMemo<{
name: string
@ -222,13 +233,19 @@ export function SpaceSelector({
(p: ContainerTagListType) => p.containerTag === containerTag,
)
const plugin = detectPluginSpace(containerTag)
const agentGroup = agentGroupByTag.get(containerTag)
const isOwnSpace = isOwnConversationSpace({ containerTag }, user?.id)
const projectName = pluginMetaMap.get(containerTag)?.projectName
const projectName =
agentGroup?.projectName ??
agentGroup?.label ??
pluginMetaMap.get(containerTag)?.projectName
const idForLabel = projectName || plugin?.projectId
return {
name: plugin
? idForLabel
? `${plugin.label} · ${idForLabel}`
? plugin.pluginId === "agents"
? idForLabel
: `${plugin.label} · ${idForLabel}`
: plugin.label
: spaceSelectorDisplayName(found, containerTag, {
currentUserId: user?.id,
@ -242,6 +259,7 @@ export function SpaceSelector({
allProjects,
selectedProjects,
pluginMetaMap,
agentGroupByTag,
includeAuto,
user?.id,
defaultTag,
@ -263,7 +281,7 @@ export function SpaceSelector({
const handleSelectSpacesApply = useCallback(
(selected: string[]) => {
const next = selected.slice(0, 1)
const next = selected
const selectedTag = next[0]
setShowSelectSpacesModal(false)
onValueChange(next)
@ -428,7 +446,14 @@ export function SpaceSelector({
className="shrink-0 blur-[0.45px]!"
/>
) : displayInfo.plugin ? (
displayInfo.plugin.iconSrc ? (
displayInfo.plugin.pluginId === "agents" ? (
<Logo
className={cn(
"shrink-0",
compact ? "h-3.5 w-[17px]" : "h-4 w-5",
)}
/>
) : displayInfo.plugin.iconSrc ? (
<Image
src={displayInfo.plugin.iconSrc}
alt=""
@ -664,7 +689,9 @@ export function SpaceSelector({
>
<span className="flex items-center gap-2 min-w-0">
{plugin ? (
plugin.iconSrc ? (
plugin.pluginId === "agents" ? (
<Logo className="h-4 w-5 shrink-0" />
) : plugin.iconSrc ? (
<Image
src={plugin.iconSrc}
alt=""
@ -693,14 +720,18 @@ export function SpaceSelector({
{p.containerTag === DEFAULT_PROJECT_ID ? (
"My Space"
) : plugin ? (
<>
{plugin.label}
{plugin.projectId && (
<span className="ml-1.5 text-[11px] text-[#737373]">
· {plugin.projectId}
</span>
)}
</>
plugin.pluginId === "agents" ? (
plugin.projectId || plugin.label
) : (
<>
{plugin.label}
{plugin.projectId && (
<span className="ml-1.5 text-[11px] text-[#737373]">
· {plugin.projectId}
</span>
)}
</>
)
) : (
spaceSelectorDisplayName(
p,

View file

@ -7,6 +7,7 @@ import { useAuth } from "@lib/auth-context"
export type PluginSpaceMeta = {
projectName?: string
projectId?: string
source?: string
lastUpdatedAt?: string
}
@ -27,8 +28,13 @@ function extractMeta(doc: RawDoc): PluginSpaceMeta {
typeof md.sm_source === "string" && md.sm_source.trim()
? md.sm_source.trim()
: undefined
const projectId =
typeof md.sm_project_id === "string" && md.sm_project_id.trim()
? md.sm_project_id.trim().toLowerCase()
: undefined
return {
projectName: project,
projectId,
source,
lastUpdatedAt: doc?.updatedAt ?? doc?.createdAt ?? undefined,
}
@ -36,7 +42,7 @@ function extractMeta(doc: RawDoc): PluginSpaceMeta {
/**
* Fetches one recent doc per containerTag and pulls plugin metadata
* (`metadata.project`, `metadata.sm_source`) so plugin-provisioned spaces
* (`metadata.project`, `metadata.sm_project_id`, `metadata.sm_source`) so plugin-provisioned spaces
* can show the real project name instead of the hash.
*/
export function usePluginSpaceMeta(

View file

@ -0,0 +1,126 @@
import { describe, expect, it } from "bun:test"
import {
agentSourceValues,
groupAgentSpaces,
isAgentContainerTag,
isAgentsSelection,
} from "./agent-space"
describe("Agents spaces", () => {
it("recognizes only Claude and Codex shared and legacy tags", () => {
expect(isAgentContainerTag("repo_supermemory__0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("user_project_0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("repo_supermemory")).toBe(true)
expect(isAgentContainerTag("claudecode_project_0123456789abcdef")).toBe(
true,
)
expect(isAgentContainerTag("codex_project_0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("codex_user_0123456789abcdef")).toBe(true)
expect(isAgentContainerTag("opencode_project_0123456789abcdef")).toBe(false)
})
it("shows agent filters only for an Agents selection", () => {
expect(
isAgentsSelection([
"repo_supermemory__0123456789abcdef",
"repo_supermemory",
]),
).toBe(true)
expect(isAgentsSelection(["repo_supermemory", "sm_project_default"])).toBe(
false,
)
expect(isAgentsSelection([])).toBe(false)
})
it("maps filter labels to canonical and legacy document sources", () => {
expect(agentSourceValues("claude-code")).toEqual([
"claude-code",
"claude-code-plugin",
])
expect(agentSourceValues("codex")).toEqual(["codex"])
expect(agentSourceValues(null)).toBeUndefined()
})
it("groups canonical and legacy project containers without synthetic tags", () => {
const projects = [
{ containerTag: "repo_supermemory__fedcba9876543210" },
{ containerTag: "repo_supermemory" },
{ containerTag: "codex_project_0123456789abcdef" },
{ containerTag: "claudecode_project_0123456789abcdef" },
{ containerTag: "user_project_0123456789abcdef" },
]
const metadata = new Map(
projects.map((project) => [
project.containerTag,
{ projectName: "supermemory" },
]),
)
const groups = groupAgentSpaces(projects, metadata)
expect(groups).toHaveLength(1)
expect(groups[0]?.label).toBe("supermemory")
expect(groups[0]?.representative.containerTag).toBe(
"repo_supermemory__fedcba9876543210",
)
expect(groups[0]?.containerTags).toEqual([
"repo_supermemory__fedcba9876543210",
"user_project_0123456789abcdef",
"claudecode_project_0123456789abcdef",
"repo_supermemory",
"codex_project_0123456789abcdef",
])
})
it("keeps repositories with the same basename in separate agent spaces", () => {
const projects = [
{ containerTag: "repo_api__0123456789abcdef" },
{ containerTag: "repo_api__fedcba9876543210" },
{ containerTag: "repo_api" },
]
const metadata = new Map(
projects.map((project) => [project.containerTag, { projectName: "api" }]),
)
const groups = groupAgentSpaces(projects, metadata)
expect(groups).toHaveLength(3)
expect(
groups.filter((group) => group.key.startsWith("project-id:")),
).toHaveLength(2)
})
it("keeps the old global Codex personal container separate", () => {
const projects = [
{ containerTag: "user_project_0123456789abcdef" },
{ containerTag: "codex_user_fedcba9876543210" },
]
const metadata = new Map(
projects.map((project) => [
project.containerTag,
{ projectName: "supermemory" },
]),
)
const groups = groupAgentSpaces(projects, metadata)
expect(groups).toHaveLength(2)
expect(groups[0]?.label).toBe("supermemory")
expect(groups[1]?.label).toBe("Legacy Codex personal")
expect(groups[1]?.kind).toBe("legacy-personal")
})
it("groups path-scoped legacy tags even before metadata loads", () => {
const projects = [
{ containerTag: "claudecode_project_0123456789abcdef" },
{ containerTag: "codex_project_0123456789abcdef" },
]
const groups = groupAgentSpaces(projects, new Map())
expect(groups).toHaveLength(1)
expect(groups[0]?.representative.containerTag).toBe(
"claudecode_project_0123456789abcdef",
)
})
})

291
apps/web/lib/agent-space.ts Normal file
View file

@ -0,0 +1,291 @@
export type AgentContainerKind =
| "canonical-project"
| "personal"
| "project"
| "legacy-personal"
| "legacy-project"
export type AgentSourceFilter = "claude-code" | "codex"
export const AGENT_SOURCE_FILTERS: ReadonlyArray<{
value: AgentSourceFilter
label: string
sources: readonly string[]
}> = [
{
value: "claude-code",
label: "Claude Code",
sources: ["claude-code", "claude-code-plugin"],
},
{ value: "codex", label: "Codex", sources: ["codex"] },
]
export type AgentSpaceMetadata = {
projectName?: string
projectId?: string
}
export type AgentSpaceGroup<T extends { containerTag: string }> = {
key: string
label: string
projectName?: string
kind: "project" | "legacy-personal"
representative: T
projects: T[]
containerTags: string[]
}
const TAG_PATTERNS: Array<{
kind: AgentContainerKind
pattern: RegExp
}> = [
{ kind: "canonical-project", pattern: /^repo_(.+)__([0-9a-f]{16})$/i },
{ kind: "personal", pattern: /^user_project_([0-9a-f]{6,64})$/i },
{ kind: "project", pattern: /^repo_(.+)$/i },
{
kind: "legacy-personal",
pattern: /^codex_user_([0-9a-f]{6,64})$/i,
},
{
kind: "legacy-personal",
pattern: /^claudecode_project_([0-9a-f]{6,64})$/i,
},
{
kind: "legacy-project",
pattern: /^codex_project_([0-9a-f]{6,64})$/i,
},
]
function matchAgentTag(containerTag: string): {
kind: AgentContainerKind
id: string
projectId?: string
projectSlug?: string
} | null {
for (const definition of TAG_PATTERNS) {
const match = containerTag.match(definition.pattern)
if (!match?.[1]) continue
if (definition.kind === "canonical-project" && match[2]) {
return {
kind: definition.kind,
id: match[2],
projectId: match[2].toLowerCase(),
projectSlug: match[1],
}
}
return { kind: definition.kind, id: match[1] }
}
return null
}
export function getAgentContainerKind(
containerTag: string,
): AgentContainerKind | null {
return matchAgentTag(containerTag)?.kind ?? null
}
export function isAgentContainerTag(containerTag: string): boolean {
return !!matchAgentTag(containerTag)
}
export function isAgentsSelection(containerTags: readonly string[]): boolean {
return containerTags.length > 0 && containerTags.every(isAgentContainerTag)
}
export function agentSourceValues(
filter: AgentSourceFilter | null | undefined,
): string[] | undefined {
if (!filter) return undefined
const definition = AGENT_SOURCE_FILTERS.find(
(candidate) => candidate.value === filter,
)
return definition ? [...definition.sources] : undefined
}
function normalizeProjectName(value: string | undefined): string | undefined {
const trimmed = value?.trim()
return trimmed || undefined
}
function humanizeProjectId(value: string): string {
return value.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim()
}
function tagPriority(containerTag: string): number {
switch (getAgentContainerKind(containerTag)) {
case "canonical-project":
return 0
case "personal":
return 1
case "legacy-personal":
return containerTag.startsWith("claudecode_project_") ? 2 : 5
case "project":
return 3
case "legacy-project":
return 4
default:
return 5
}
}
function legacyGroupIdentity(
containerTag: string,
projectName: string | undefined,
): { key: string; label: string; kind: AgentSpaceGroup<never>["kind"] } {
const match = matchAgentTag(containerTag)
if (!match) {
return { key: `tag:${containerTag}`, label: containerTag, kind: "project" }
}
// Old Codex personal memory was intentionally global. Even if its newest
// document contains a project name, assigning the whole container to that
// project would leak memories from its other historical projects.
if (containerTag.startsWith("codex_user_")) {
return {
key: `legacy-personal:${containerTag}`,
label: "Legacy Codex personal",
kind: "legacy-personal",
}
}
if (projectName) {
return {
key: `project:${projectName.toLocaleLowerCase()}`,
label: projectName,
kind: "project",
}
}
if (
containerTag.startsWith("user_project_") ||
containerTag.startsWith("claudecode_project_") ||
containerTag.startsWith("codex_project_")
) {
return {
key: `path:${match.id.toLocaleLowerCase()}`,
label: `Project · ${match.id.slice(0, 6)}`,
kind: "project",
}
}
return {
key: `repo:${match.id.toLocaleLowerCase()}`,
label: humanizeProjectId(match.id) || "Project",
kind: "project",
}
}
function normalizeProjectId(value: string | undefined): string | undefined {
const normalized = value?.trim().toLowerCase()
return normalized || undefined
}
function addProjectToGroup<T extends { containerTag: string }>(
grouped: Map<string, AgentSpaceGroup<T>>,
key: string,
label: string,
kind: AgentSpaceGroup<T>["kind"],
project: T,
projectName: string | undefined,
) {
const existing = grouped.get(key)
if (existing) {
existing.projects.push(project)
existing.containerTags.push(project.containerTag)
if (!existing.projectName && projectName) {
existing.projectName = projectName
existing.label = projectName
}
return
}
grouped.set(key, {
key,
label,
projectName,
kind,
representative: project,
projects: [project],
containerTags: [project.containerTag],
})
}
/**
* Collapse the physical Claude/Codex containers into one selectable Agents row
* per project. Every returned container tag remains real; the UI never writes
* to a synthetic "agents" tag.
*/
export function groupAgentSpaces<T extends { containerTag: string }>(
projects: T[],
metadata: ReadonlyMap<string, AgentSpaceMetadata>,
): AgentSpaceGroup<T>[] {
const grouped = new Map<string, AgentSpaceGroup<T>>()
const legacyProjects: Array<{
project: T
projectName: string | undefined
}> = []
const canonicalKeysByName = new Map<string, string[]>()
for (const project of projects) {
const match = matchAgentTag(project.containerTag)
if (!match) continue
const spaceMetadata = metadata.get(project.containerTag)
const projectName = normalizeProjectName(spaceMetadata?.projectName)
const projectId = normalizeProjectId(
spaceMetadata?.projectId ?? match.projectId,
)
if (!projectId) {
legacyProjects.push({ project, projectName })
continue
}
const key = `project-id:${projectId}`
const label =
projectName || humanizeProjectId(match.projectSlug ?? "") || "Project"
addProjectToGroup(grouped, key, label, "project", project, projectName)
if (projectName) {
const normalizedName = projectName.toLocaleLowerCase()
const keys = canonicalKeysByName.get(normalizedName) ?? []
if (!keys.includes(key)) keys.push(key)
canonicalKeysByName.set(normalizedName, keys)
}
}
for (const { project, projectName } of legacyProjects) {
const identity = legacyGroupIdentity(project.containerTag, projectName)
const canonicalMatches = projectName
? (canonicalKeysByName.get(projectName.toLocaleLowerCase()) ?? [])
: []
const key =
identity.kind === "project" && canonicalMatches.length === 1
? canonicalMatches[0]!
: identity.key
addProjectToGroup(
grouped,
key,
projectName ?? identity.label,
identity.kind,
project,
projectName,
)
}
return [...grouped.values()]
.map((group) => {
const orderedProjects = [...group.projects].sort(
(a, b) =>
tagPriority(a.containerTag) - tagPriority(b.containerTag) ||
a.containerTag.localeCompare(b.containerTag),
)
return {
...group,
representative: orderedProjects[0] ?? group.representative,
projects: orderedProjects,
containerTags: orderedProjects.map((project) => project.containerTag),
}
})
.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "project" ? -1 : 1
return a.label.localeCompare(b.label)
})
}

View file

@ -14,6 +14,38 @@ function makeCodexSessionDocument(content: string): PluginDocumentInput {
}
describe("parsePluginDocument — session transcripts", () => {
it("keeps the Codex source badge inside a shared Agents container", () => {
const parsed = parsePluginDocument({
...makeCodexSessionDocument(
["[Session abc-123]", "1. [user] Shared memory"].join("\n"),
),
source: "codex",
containerTags: ["user_project_0123456789abcdef"],
} as unknown as PluginDocumentInput)
expect(parsed?.pluginLabel).toBe("Codex")
expect(parsed?.pluginIconSrc).toBe("/images/plugins/codex.png")
})
it("keeps the Claude Code source badge inside a shared Agents container", () => {
const parsed = parsePluginDocument({
id: "doc_claude",
title: "Claude memory",
content: "Remember this project convention",
source: "claude-code",
metadata: {
sm_source: "claude-code",
type: "manual",
project: "supermemory",
},
containerTags: ["repo_supermemory"],
memoryEntries: [],
} as unknown as PluginDocumentInput)
expect(parsed?.pluginLabel).toBe("Claude Code")
expect(parsed?.pluginIconSrc).toBe("/images/plugins/claude-code.svg")
})
it("keeps multi-line message bodies intact", () => {
const parsed = parsePluginDocument(
makeCodexSessionDocument(

View file

@ -1,7 +1,15 @@
import { normalizePluginClientId } from "@/lib/plugin-catalog"
import { isAgentContainerTag } from "@/lib/agent-space"
export type PluginSpaceInfo = {
pluginId: "claude-code" | "openclaw" | "opencode" | "codex" | "amp" | "hermes"
pluginId:
| "agents"
| "claude-code"
| "openclaw"
| "opencode"
| "codex"
| "amp"
| "hermes"
label: string
iconSrc: string | null
projectId?: string
@ -16,10 +24,10 @@ type PluginDef = {
const PLUGINS: PluginDef[] = [
{
id: "claude-code",
label: "Claude Code",
iconSrc: "/images/plugins/claude-code.svg",
prefixes: ["claudecode"],
id: "agents",
label: "Agents",
iconSrc: null,
prefixes: ["user_project", "repo", "claudecode", "codex"],
},
{
id: "openclaw",
@ -33,12 +41,6 @@ const PLUGINS: PluginDef[] = [
iconSrc: "/images/plugins/opencode.svg",
prefixes: ["opencode"],
},
{
id: "codex",
label: "Codex",
iconSrc: "/images/plugins/codex.png",
prefixes: ["codex"],
},
{
id: "amp",
label: "Amp",
@ -186,8 +188,28 @@ export function detectPluginSpace(
containerTag: string,
): PluginSpaceInfo | null {
if (!containerTag) return null
if (isAgentContainerTag(containerTag)) {
const agents = PLUGINS[0]
if (agents) {
const matchingPrefix = agents.prefixes.find(
(prefix) =>
containerTag === prefix ||
(containerTag.startsWith(prefix) &&
["_", "-"].includes(containerTag[prefix.length] ?? "")),
)
if (matchingPrefix) {
const rest = containerTag.slice(matchingPrefix.length + 1)
return {
pluginId: agents.id,
label: agents.label,
iconSrc: agents.iconSrc,
projectId: parsePluginRest(rest).projectId,
}
}
}
}
for (const plugin of PLUGINS) {
for (const plugin of PLUGINS.slice(1)) {
for (const prefix of plugin.prefixes) {
if (containerTag === prefix) {
return {

View file

@ -59,4 +59,8 @@ export type IntegrationParamValue =
export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault(
[],
)
export const agentSourceParam = parseAsStringLiteral([
"claude-code",
"codex",
] as const)
export const projectParam = parseAsArrayOf(parseAsString, ",").withDefault([])

View file

@ -1115,6 +1115,13 @@ export const DocumentsWithMemoriesQuerySchema = z
description: "Optional container tags to filter documents by",
example: ["sm_project_default"],
}),
sources: z
.array(z.string().trim().min(1).max(255))
.optional()
.openapi({
description: "Optional document sources to filter by (OR logic)",
example: ["claude-code", "codex"],
}),
})
.openapi({
description: "Query parameters for listing documents with memory entries",