Group stable agent repository containers

This commit is contained in:
ved015 2026-07-14 09:44:21 +05:30
parent 03d1b63883
commit 8493d46a64
3 changed files with 131 additions and 29 deletions

View file

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

View file

@ -8,6 +8,7 @@ import {
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(
@ -20,7 +21,10 @@ describe("Agents spaces", () => {
it("shows agent filters only for an Agents selection", () => {
expect(
isAgentsSelection(["user_project_0123456789abcdef", "repo_supermemory"]),
isAgentsSelection([
"repo_supermemory__0123456789abcdef",
"repo_supermemory",
]),
).toBe(true)
expect(isAgentsSelection(["repo_supermemory", "sm_project_default"])).toBe(
false,
@ -39,6 +43,7 @@ describe("Agents spaces", () => {
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" },
@ -56,9 +61,10 @@ describe("Agents spaces", () => {
expect(groups).toHaveLength(1)
expect(groups[0]?.label).toBe("supermemory")
expect(groups[0]?.representative.containerTag).toBe(
"user_project_0123456789abcdef",
"repo_supermemory__fedcba9876543210",
)
expect(groups[0]?.containerTags).toEqual([
"repo_supermemory__fedcba9876543210",
"user_project_0123456789abcdef",
"claudecode_project_0123456789abcdef",
"repo_supermemory",
@ -66,6 +72,24 @@ describe("Agents spaces", () => {
])
})
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" },

View file

@ -1,4 +1,5 @@
export type AgentContainerKind =
| "canonical-project"
| "personal"
| "project"
| "legacy-personal"
@ -21,6 +22,7 @@ export const AGENT_SOURCE_FILTERS: ReadonlyArray<{
export type AgentSpaceMetadata = {
projectName?: string
projectId?: string
}
export type AgentSpaceGroup<T extends { containerTag: string }> = {
@ -37,6 +39,7 @@ 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 },
{
@ -56,10 +59,21 @@ const TAG_PATTERNS: Array<{
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]) return { kind: definition.kind, id: match[1] }
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
}
@ -99,20 +113,22 @@ function humanizeProjectId(value: string): string {
function tagPriority(containerTag: string): number {
switch (getAgentContainerKind(containerTag)) {
case "personal":
case "canonical-project":
return 0
case "personal":
return 1
case "legacy-personal":
return containerTag.startsWith("claudecode_project_") ? 1 : 4
return containerTag.startsWith("claudecode_project_") ? 2 : 5
case "project":
return 2
case "legacy-project":
return 3
case "legacy-project":
return 4
default:
return 5
}
}
function groupIdentity(
function legacyGroupIdentity(
containerTag: string,
projectName: string | undefined,
): { key: string; label: string; kind: AgentSpaceGroup<never>["kind"] } {
@ -159,6 +175,41 @@ function groupIdentity(
}
}
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
@ -169,33 +220,54 @@ export function groupAgentSpaces<T extends { containerTag: string }>(
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) {
if (!isAgentContainerTag(project.containerTag)) continue
const projectName = normalizeProjectName(
metadata.get(project.containerTag)?.projectName,
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,
)
const identity = groupIdentity(project.containerTag, projectName)
const existing = grouped.get(identity.key)
if (existing) {
existing.projects.push(project)
existing.containerTags.push(project.containerTag)
if (!existing.projectName && projectName) {
existing.projectName = projectName
existing.label = projectName
}
if (!projectId) {
legacyProjects.push({ project, projectName })
continue
}
grouped.set(identity.key, {
key: identity.key,
label: identity.label,
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,
kind: identity.kind,
representative: project,
projects: [project],
containerTags: [project.containerTag],
})
)
}
return [...grouped.values()]