From cfc2b49192c04b9528eed41178c5ddba0e8dd39f Mon Sep 17 00:00:00 2001 From: Vedant Mahajan Date: Fri, 24 Jul 2026 00:48:44 +0530 Subject: [PATCH] Add shared Agents memory workspace (#1290) --- apps/web/components/dashboard-view.tsx | 14 +- apps/web/components/memories-grid.tsx | 99 ++++++- apps/web/components/select-spaces-modal.tsx | 299 ++++++++++++++++---- apps/web/components/space-selector.tsx | 63 +++-- apps/web/hooks/use-plugin-space-meta.ts | 8 +- apps/web/lib/agent-space.test.ts | 126 +++++++++ apps/web/lib/agent-space.ts | 291 +++++++++++++++++++ apps/web/lib/plugin-document.test.ts | 32 +++ apps/web/lib/plugin-space.ts | 46 ++- apps/web/lib/search-params.ts | 4 + packages/validation/api.ts | 7 + 11 files changed, 894 insertions(+), 95 deletions(-) create mode 100644 apps/web/lib/agent-space.test.ts create mode 100644 apps/web/lib/agent-space.ts diff --git a/apps/web/components/dashboard-view.tsx b/apps/web/components/dashboard-view.tsx index 37355282..93559ace 100644 --- a/apps/web/components/dashboard-view.tsx +++ b/apps/web/components/dashboard-view.tsx @@ -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" diff --git a/apps/web/components/memories-grid.tsx b/apps/web/components/memories-grid.tsx index 132dd3fa..5794cd19 100644 --- a/apps/web/components/memories-grid.tsx +++ b/apps/web/components/memories-grid.tsx @@ -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>> => { + 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 (
- {!isEmpty && !isSelectionMode && ( + {(!isEmpty || (facetsData?.total ?? 0) > 0) && !isSelectionMode && (
({facet.count}) ))} + {showAgentFilters && ( + + setSelectedAgentSource( + value ? (value as AgentSourceFilter) : null, + ) + } + aria-label="Filter memories by agent" + className="gap-1.5" + > + {AGENT_SOURCE_FILTERS.map((filter) => ( + + {filter.label} + + ({agentSourceCounts?.[filter.value] ?? 0}) + + + ))} + + )}
{/* View mode toggle — segmented control */} @@ -894,7 +985,7 @@ export function MemoriesGrid({ {profileOpen && !isMobile && ( setProfileOpen(false)} /> @@ -904,7 +995,7 @@ export function MemoriesGrid({ )}
diff --git a/apps/web/components/select-spaces-modal.tsx b/apps/web/components/select-spaces-modal.tsx index a13c2c1d..27db8ea0 100644 --- a/apps/web/components/select-spaces-modal.tsx +++ b/apps/web/components/select-spaces-modal.tsx @@ -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(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>() + 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() 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(() => { - 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(() => { 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() 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 ? ( {plugin ? ( - <> - {plugin.label} - {pluginIdLabel && ( - - · {pluginIdLabel} - - )} - + plugin.pluginId === "agents" ? ( + (pluginIdLabel ?? plugin.label) + ) : ( + <> + {plugin.label} + {pluginIdLabel && ( + + · {pluginIdLabel} + + )} + + ) ) : ( 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 ( + ) + })} +
+ onConnect(activeCatalogId)} + onDismissKey={onDismissKey} + /> + + ) +} + function DiscoverPanel({ catalogId, isConnecting, diff --git a/apps/web/components/space-selector.tsx b/apps/web/components/space-selector.tsx index 8bba3f38..ee2b0071 100644 --- a/apps/web/components/space-selector.tsx +++ b/apps/web/components/space-selector.tsx @@ -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 => { 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>() + 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" ? ( + + ) : displayInfo.plugin.iconSrc ? ( {plugin ? ( - plugin.iconSrc ? ( + plugin.pluginId === "agents" ? ( + + ) : plugin.iconSrc ? ( - {plugin.label} - {plugin.projectId && ( - - · {plugin.projectId} - - )} - + plugin.pluginId === "agents" ? ( + plugin.projectId || plugin.label + ) : ( + <> + {plugin.label} + {plugin.projectId && ( + + · {plugin.projectId} + + )} + + ) ) : ( spaceSelectorDisplayName( p, diff --git a/apps/web/hooks/use-plugin-space-meta.ts b/apps/web/hooks/use-plugin-space-meta.ts index 4921409c..f56591eb 100644 --- a/apps/web/hooks/use-plugin-space-meta.ts +++ b/apps/web/hooks/use-plugin-space-meta.ts @@ -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( diff --git a/apps/web/lib/agent-space.test.ts b/apps/web/lib/agent-space.test.ts new file mode 100644 index 00000000..f413fa81 --- /dev/null +++ b/apps/web/lib/agent-space.test.ts @@ -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", + ) + }) +}) diff --git a/apps/web/lib/agent-space.ts b/apps/web/lib/agent-space.ts new file mode 100644 index 00000000..74693af9 --- /dev/null +++ b/apps/web/lib/agent-space.ts @@ -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 = { + 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["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( + grouped: Map>, + key: string, + label: string, + kind: AgentSpaceGroup["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( + projects: T[], + metadata: ReadonlyMap, +): AgentSpaceGroup[] { + const grouped = new Map>() + const legacyProjects: Array<{ + project: T + projectName: string | undefined + }> = [] + const canonicalKeysByName = new Map() + + 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) + }) +} diff --git a/apps/web/lib/plugin-document.test.ts b/apps/web/lib/plugin-document.test.ts index 79dc630d..2fa58a07 100644 --- a/apps/web/lib/plugin-document.test.ts +++ b/apps/web/lib/plugin-document.test.ts @@ -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( diff --git a/apps/web/lib/plugin-space.ts b/apps/web/lib/plugin-space.ts index a4e4d7df..af04eea6 100644 --- a/apps/web/lib/plugin-space.ts +++ b/apps/web/lib/plugin-space.ts @@ -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 { diff --git a/apps/web/lib/search-params.ts b/apps/web/lib/search-params.ts index 2d9f1688..661a5ed4 100644 --- a/apps/web/lib/search-params.ts +++ b/apps/web/lib/search-params.ts @@ -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([]) diff --git a/packages/validation/api.ts b/packages/validation/api.ts index 8579ca40..06bfc158 100644 --- a/packages/validation/api.ts +++ b/packages/validation/api.ts @@ -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",