mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
Add shared Agents memory workspace (#1290)
This commit is contained in:
parent
5a3ff85ea5
commit
cfc2b49192
11 changed files with 894 additions and 95 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
126
apps/web/lib/agent-space.test.ts
Normal file
126
apps/web/lib/agent-space.test.ts
Normal 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
291
apps/web/lib/agent-space.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue