diff --git a/apps/web/components/memories-grid.tsx b/apps/web/components/memories-grid.tsx index be3632740..5402685e4 100644 --- a/apps/web/components/memories-grid.tsx +++ b/apps/web/components/memories-grid.tsx @@ -35,6 +35,7 @@ import { QuickNoteCard } from "./quick-note-card" import type { HighlightItem } from "./highlights-card" import { Button } from "@ui/components/button" import { + agentSourceParam, categoriesParam, type IntegrationParamValue, } from "@/lib/search-params" @@ -88,8 +89,17 @@ type DocumentFacet = { label: string } +type AgentSource = "claude-code" | "codex" + +type SourceFacet = { + source: AgentSource + count: number + label: string +} + type FacetsResponse = { facets: DocumentFacet[] + sourceFacets?: SourceFacet[] total: number } @@ -278,6 +288,10 @@ export function MemoriesGrid({ "categories", categoriesParam, ) + const [selectedAgentSource, setSelectedAgentSource] = useQueryState( + "source", + agentSourceParam, + ) const selectedCategoriesSet = useMemo( () => new Set(selectedCategories), [selectedCategories], @@ -315,6 +329,7 @@ export function MemoriesGrid({ "documents-with-memories", effectiveContainerTags, selectedCategories, + selectedAgentSource, ], initialPageParam: 1, queryFn: async ({ pageParam }) => { @@ -327,6 +342,7 @@ export function MemoriesGrid({ containerTags: effectiveContainerTags, categories: selectedCategories.length > 0 ? selectedCategories : undefined, + sources: selectedAgentSource ? [selectedAgentSource] : undefined, }, disableValidation: true, }) @@ -381,9 +397,17 @@ export function MemoriesGrid({ [setSelectedCategories], ) + const handleAgentSourceToggle = useCallback( + (source: AgentSource) => { + setSelectedAgentSource((prev) => (prev === source ? null : source)) + }, + [setSelectedAgentSource], + ) + const handleSelectAll = useCallback(() => { setSelectedCategories(null) - }, [setSelectedCategories]) + setSelectedAgentSource(null) + }, [setSelectedCategories, setSelectedAgentSource]) const documents = useMemo(() => { return ( @@ -559,6 +583,10 @@ export function MemoriesGrid({ const allVisibleSelected = documents.length > 0 && documents.every((d) => d.id && selectedDocumentIds.has(d.id)) + const agentSourceFacets = + facetsData?.sourceFacets?.filter( + (facet) => facet.source === "claude-code" || facet.source === "codex", + ) ?? [] return (
@@ -573,6 +601,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 && + !selectedAgentSource && "bg-[#00173C] border-[#2261CA33]", )} onClick={handleSelectAll} @@ -599,6 +628,21 @@ export function MemoriesGrid({ ({facet.count}) ))} + {agentSourceFacets.map((facet) => ( + + ))}
{/* View mode toggle โ€” segmented control */} diff --git a/apps/web/components/select-spaces-modal.tsx b/apps/web/components/select-spaces-modal.tsx index 7198d435d..c9436d956 100644 --- a/apps/web/components/select-spaces-modal.tsx +++ b/apps/web/components/select-spaces-modal.tsx @@ -34,7 +34,11 @@ import { spaceSelectorDisplayName, } from "@/lib/ingest-auto-space" import { + AGENT_SPACE_CATEGORY_LABEL, + detectAgentSpace, detectPluginSpace, + getAgentSpaceDisplayName, + getAgentSpaceSecondaryLabel, pluginInitial, type PluginSpaceInfo, } from "@/lib/plugin-space" @@ -79,6 +83,7 @@ interface SelectSpacesModalProps { type CategoryId = | "all" | "my" + | "agents" | `plugin:${PluginSpaceInfo["pluginId"]}` | `discover:${string}` @@ -90,6 +95,10 @@ type Category = { count: number } +function normalizeAgentProjectKey(value: string | null | undefined) { + return value?.trim().replace(/_/g, "-").toLowerCase() || null +} + export function SelectSpacesModal({ isOpen, onClose, @@ -148,6 +157,37 @@ export function SelectSpacesModal({ return [defaultSpace, ...rest] }, [projects]) + const visibleSpaces = useMemo(() => { + const sharedRepoProjects = new Set() + + for (const project of allSpaces) { + const agent = detectAgentSpace(project.containerTag) + if (!agent?.isSharedRepo) continue + + const projectKey = normalizeAgentProjectKey(agent.projectId) + if (projectKey) sharedRepoProjects.add(projectKey) + } + + if (sharedRepoProjects.size === 0) return allSpaces + + return allSpaces.filter((project) => { + const agent = detectAgentSpace(project.containerTag) + if (!agent || agent.isSharedRepo) return true + + // Claude Code already uses repo-based tags, so once the shared + // Agent Spaces row exists we should not show an extra source-specific row. + if (!agent.sourceIds.includes("claude-code")) return true + + const metaProject = pluginMetaMap.get(project.containerTag)?.projectName + const projectKeys = [ + normalizeAgentProjectKey(metaProject), + normalizeAgentProjectKey(agent.projectId), + ].filter((key): key is string => !!key) + + return !projectKeys.some((key) => sharedRepoProjects.has(key)) + }) + }, [allSpaces, pluginMetaMap]) + const { categories, connectedCatalogIds } = useMemo<{ categories: Category[] connectedCatalogIds: Set @@ -156,8 +196,14 @@ export function SelectSpacesModal({ PluginSpaceInfo["pluginId"], { label: string; iconSrc: string | null; count: number } >() + let agentCount = 0 let myCount = 0 - for (const p of allSpaces) { + for (const p of visibleSpaces) { + const agent = detectAgentSpace(p.containerTag) + if (agent) { + agentCount += 1 + continue + } const plugin = detectPluginSpace(p.containerTag) if (plugin) { const prev = pluginCounts.get(plugin.pluginId) @@ -184,6 +230,10 @@ export function SelectSpacesModal({ const catalogId = spacePluginIdToCatalogId(pluginId) if (catalogId) connectedIds.add(catalogId) } + if (agentCount > 0) { + connectedIds.add("claude_code") + connectedIds.add("codex") + } return { categories: [ { @@ -191,7 +241,7 @@ export function SelectSpacesModal({ label: "All Spaces", iconSrc: null, emoji: null, - count: allSpaces.length, + count: visibleSpaces.length, }, { id: "my", @@ -200,15 +250,27 @@ export function SelectSpacesModal({ emoji: "๐Ÿ“", count: myCount, }, + ...(agentCount > 0 + ? [ + { + id: "agents" as CategoryId, + label: AGENT_SPACE_CATEGORY_LABEL, + iconSrc: "/images/logo.png", + emoji: null, + count: agentCount, + }, + ] + : []), ...pluginCats, ], connectedCatalogIds: connectedIds, } - }, [allSpaces]) + }, [visibleSpaces]) const defaultCategory = useMemo(() => { if (!currentSelection) return "all" if (currentSelection === AUTO_CHAT_SPACE_ID) return "all" + if (detectAgentSpace(currentSelection)) return "agents" const plugin = detectPluginSpace(currentSelection) if (plugin) return `plugin:${plugin.pluginId}` return "my" @@ -231,7 +293,14 @@ export function SelectSpacesModal({ setLastBulkDeleteTag(null) }, [activeDiscoverId]) - const { org, user } = useAuth() + const modalSubtitle = useMemo(() => { + if (isBulkDeleteMode) return "Choose spaces to permanently delete" + if (activeCategory === "agents") { + return "Memories from Claude, Codex, and other agents" + } + return "Filter your memories by space" + }, [activeCategory, isBulkDeleteMode]) + const queryClient = useQueryClient() const [connectingPluginId, setConnectingPluginId] = useState( null, @@ -450,15 +519,18 @@ export function SelectSpacesModal({ ) const filteredProjects = useMemo(() => { - const byCategory = allSpaces.filter((p) => { + const byCategory = visibleSpaces.filter((p) => { if (activeCategory === "all") return true + const agent = detectAgentSpace(p.containerTag) + if (activeCategory === "agents") return !!agent const plugin = detectPluginSpace(p.containerTag) - if (activeCategory === "my") return !plugin + if (activeCategory === "my") return !plugin && !agent return plugin && `plugin:${plugin.pluginId}` === activeCategory }) if (!searchQuery.trim()) return byCategory const query = searchQuery.trim().toLowerCase() return byCategory.filter((p) => { + const agent = detectAgentSpace(p.containerTag) const plugin = detectPluginSpace(p.containerTag) const projectName = pluginMetaMap.get(p.containerTag)?.projectName const displayName = spaceSelectorDisplayName(p, p.containerTag, { @@ -468,18 +540,21 @@ export function SelectSpacesModal({ p.containerTag.toLowerCase().includes(query) || (p.name ?? "").toLowerCase().includes(query) || displayName.toLowerCase().includes(query) || + (agent?.label.toLowerCase().includes(query) ?? false) || + (agent?.isSharedRepo && "agent space".includes(query)) || + (agent?.projectId?.toLowerCase().includes(query) ?? false) || (plugin?.label.toLowerCase().includes(query) ?? false) || (plugin?.projectId?.toLowerCase().includes(query) ?? false) || (projectName?.toLowerCase().includes(query) ?? false) ) }) - }, [allSpaces, activeCategory, searchQuery, pluginMetaMap, user?.id]) + }, [visibleSpaces, activeCategory, searchQuery, pluginMetaMap, user?.id]) const recentProjects = useMemo(() => { if (!recents?.length) return [] if (searchQuery.trim()) return [] if (activeCategory !== "all") return [] - const byTag = new Map(allSpaces.map((p) => [p.containerTag, p])) + const byTag = new Map(visibleSpaces.map((p) => [p.containerTag, p])) const out: ContainerTagListType[] = [] for (const tag of recents) { const p = byTag.get(tag) @@ -487,7 +562,7 @@ export function SelectSpacesModal({ if (out.length >= 5) break } return out - }, [recents, searchQuery, activeCategory, allSpaces]) + }, [recents, searchQuery, activeCategory, visibleSpaces]) const recentSet = useMemo( () => new Set(recentProjects.map((p) => p.containerTag)), @@ -574,11 +649,23 @@ export function SelectSpacesModal({ const renderRow = useCallback( (project: ContainerTagListType) => { const isSelected = currentSelection === project.containerTag - const plugin = detectPluginSpace(project.containerTag) + const agent = detectAgentSpace(project.containerTag) + const plugin = agent ? null : detectPluginSpace(project.containerTag) const pluginProjectName = pluginMetaMap.get( project.containerTag, )?.projectName const pluginIdLabel = pluginProjectName || plugin?.projectId + const agentPrimaryLabel = agent + ? getAgentSpaceDisplayName(agent, { + projectName: pluginProjectName, + inAgentCategory: activeCategory === "agents", + }) + : null + const agentSecondaryLabel = agent + ? getAgentSpaceSecondaryLabel(agent, { + inAllSpaces: activeCategory === "all", + }) + : null const displayName = spaceSelectorDisplayName( project, project.containerTag, @@ -588,7 +675,7 @@ export function SelectSpacesModal({ ) const isDefault = project.containerTag === DEFAULT_PROJECT_ID const isOwnSpace = isOwnConversationSpace(project, user?.id) - const canEdit = !isDefault && !plugin && !isOwnSpace + const canEdit = !isDefault && !plugin && !agent && !isOwnSpace const canBulkDelete = enableDelete && !isDefault const isEditing = editingProject?.containerTag === project.containerTag const isBulkDeleteSelected = bulkDeleteTags.has(project.containerTag) @@ -696,7 +783,16 @@ export function SelectSpacesModal({ disabled={isBulkDeleteMode && !canBulkDelete} className="flex min-w-0 flex-1 items-center gap-3 text-left cursor-pointer focus:outline-none focus:ring-0 disabled:cursor-not-allowed" > - {plugin ? ( + {agent ? ( + + ) : plugin ? ( plugin.iconSrc ? ( - {plugin ? ( + {agent ? ( + <> + {agentPrimaryLabel} + {agentSecondaryLabel && ( + + ยท {agentSecondaryLabel} + + )} + + ) : plugin ? ( <> {plugin.label} {pluginIdLabel && ( @@ -780,6 +885,7 @@ export function SelectSpacesModal({ ) }, [ + activeCategory, cancelEditing, bulkDeleteTags, currentSelection, @@ -837,11 +943,11 @@ export function SelectSpacesModal({ const renderCategoryChip = ( category: { - id: string + id: CategoryId label: string count?: number - iconSrc?: string - emoji?: string + iconSrc?: string | null + emoji?: string | null }, isDiscover: boolean, ) => { @@ -1052,9 +1158,7 @@ export function SelectSpacesModal({ Select Space

- {isBulkDeleteMode - ? "Choose spaces to permanently delete" - : "Filter your memories by space"} + {modalSubtitle}

@@ -1137,9 +1241,7 @@ export function SelectSpacesModal({ Select Space

- {isBulkDeleteMode - ? "Choose spaces to permanently delete" - : "Filter your memories by space"} + {modalSubtitle}

diff --git a/apps/web/components/space-selector.tsx b/apps/web/components/space-selector.tsx index 7f89649e4..61b982c53 100644 --- a/apps/web/components/space-selector.tsx +++ b/apps/web/components/space-selector.tsx @@ -40,7 +40,12 @@ import { isOwnConversationSpace, spaceSelectorDisplayName, } from "@/lib/ingest-auto-space" -import { detectPluginSpace, pluginInitial } from "@/lib/plugin-space" +import { + detectAgentSpace, + detectPluginSpace, + getAgentSpaceDisplayName, + pluginInitial, +} from "@/lib/plugin-space" import { usePluginSpaceMeta } from "@/hooks/use-plugin-space-meta" import NovaOrb from "@/components/nova/nova-orb" import { AutoSpaceIcon } from "@/components/nova/auto-space-icon" @@ -190,6 +195,7 @@ export function SpaceSelector({ const displayInfo = useMemo<{ name: string emoji: string | null + agent: ReturnType plugin: ReturnType isAuto: boolean isOwnSpace: boolean @@ -199,6 +205,7 @@ export function SpaceSelector({ return { name: "Auto", emoji: null, + agent: null, plugin: null, isAuto: true, isOwnSpace: false, @@ -208,6 +215,7 @@ export function SpaceSelector({ return { name: "My Space", emoji: "๐Ÿ“", + agent: null, plugin: null, isAuto: false, isOwnSpace: false, @@ -216,19 +224,23 @@ export function SpaceSelector({ const found = allProjects.find( (p: ContainerTagListType) => p.containerTag === containerTag, ) - const plugin = detectPluginSpace(containerTag) + const agent = detectAgentSpace(containerTag) + const plugin = agent ? null : detectPluginSpace(containerTag) const isOwnSpace = isOwnConversationSpace({ containerTag }, user?.id) const projectName = pluginMetaMap.get(containerTag)?.projectName - const idForLabel = projectName || plugin?.projectId + const idForLabel = projectName || agent?.projectId || plugin?.projectId return { - name: plugin - ? idForLabel - ? `${plugin.label} ยท ${idForLabel}` - : plugin.label - : spaceSelectorDisplayName(found, containerTag, { - currentUserId: user?.id, - }), + name: agent + ? getAgentSpaceDisplayName(agent, { projectName }) + : plugin + ? idForLabel + ? `${plugin.label} ยท ${idForLabel}` + : plugin.label + : spaceSelectorDisplayName(found, containerTag, { + currentUserId: user?.id, + }), emoji: found?.emoji || "๐Ÿ“", + agent, plugin, isAuto: false, isOwnSpace, @@ -238,6 +250,7 @@ export function SpaceSelector({ const canEditCurrent = enableEdit && !displayInfo.isAuto && + !displayInfo.agent && !displayInfo.plugin && !displayInfo.isOwnSpace @@ -415,6 +428,18 @@ export function SpaceSelector({ size={compact ? 14 : 16} className="shrink-0 blur-[0.45px]!" /> + ) : displayInfo.agent ? ( + ) : displayInfo.plugin ? ( displayInfo.plugin.iconSrc ? ( + isSharedRepo: boolean +} + +export function getAgentSpacePlaceName( + agent: AgentSpaceInfo, + projectName?: string | null, +): string { + return projectName?.trim() || agent.projectId?.trim() || agent.label +} + +/** Primary row/trigger label for an agent-scoped space. */ +export function getAgentSpaceDisplayName( + agent: AgentSpaceInfo, + options?: { + projectName?: string | null + inAgentCategory?: boolean + }, +): string { + const placeName = getAgentSpacePlaceName(agent, options?.projectName) + + if (agent.isSharedRepo) { + return placeName + } + + const idLabel = options?.projectName || agent.projectId + if (idLabel) return `${agent.label} ยท ${idLabel}` + return agent.label +} + +/** Secondary hint when agent spaces appear outside the Agent Spaces filter. */ +export function getAgentSpaceSecondaryLabel( + agent: AgentSpaceInfo, + options: { inAllSpaces?: boolean }, +): string | null { + if (agent.isSharedRepo && options.inAllSpaces) { + return "Agent space" + } + return null +} + type PluginDef = { id: PluginSpaceInfo["pluginId"] label: string @@ -53,6 +100,19 @@ const PLUGINS: PluginDef[] = [ }, ] +const AGENTS_ICON_SRC = "/images/logo.png" +const AGENT_PLUGIN_IDS = new Set([ + "claude-code", + "codex", +]) + +function parseRepoProjectId(containerTag: string): string | undefined { + if (!containerTag.startsWith("repo_")) return undefined + const repoName = containerTag.slice("repo_".length) + if (!repoName) return undefined + return repoName.replace(/_/g, "-").slice(0, 32) +} + function parsePluginRest(rest: string): { projectId?: string } { if (!rest || rest === "default" || rest === "global") { return { projectId: "Global" } @@ -216,3 +276,28 @@ export function detectPluginSpace( return null } + +export function detectAgentSpace(containerTag: string): AgentSpaceInfo | null { + if (!containerTag) return null + + if (containerTag.startsWith("repo_")) { + return { + label: AGENT_SPACE_CATEGORY_LABEL, + iconSrc: AGENTS_ICON_SRC, + projectId: parseRepoProjectId(containerTag), + sourceIds: ["claude-code", "codex"], + isSharedRepo: true, + } + } + + const plugin = detectPluginSpace(containerTag) + if (!plugin || !AGENT_PLUGIN_IDS.has(plugin.pluginId)) return null + + return { + label: plugin.label as "Claude Code" | "Codex", + iconSrc: plugin.iconSrc ?? AGENTS_ICON_SRC, + projectId: plugin.projectId, + sourceIds: [plugin.pluginId as "claude-code" | "codex"], + isSharedRepo: false, + } +} diff --git a/apps/web/lib/search-params.ts b/apps/web/lib/search-params.ts index 9afb6eab5..13b5f229e 100644 --- a/apps/web/lib/search-params.ts +++ b/apps/web/lib/search-params.ts @@ -58,4 +58,7 @@ export type IntegrationParamValue = export const categoriesParam = parseAsArrayOf(parseAsString, ",").withDefault( [], ) +const agentSourceLiterals = ["claude-code", "codex"] as const +export type AgentSourceParamValue = (typeof agentSourceLiterals)[number] +export const agentSourceParam = parseAsStringLiteral(agentSourceLiterals) export const projectParam = parseAsArrayOf(parseAsString, ",").withDefault([])