From 8493d46a6403cd63bb37d7912d85d353f8dd8f85 Mon Sep 17 00:00:00 2001 From: ved015 <122012786+ved015@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:44:21 +0530 Subject: [PATCH] Group stable agent repository containers --- apps/web/hooks/use-plugin-space-meta.ts | 8 +- apps/web/lib/agent-space.test.ts | 28 +++++- apps/web/lib/agent-space.ts | 124 +++++++++++++++++++----- 3 files changed, 131 insertions(+), 29 deletions(-) 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 index 4a68d161..f413fa81 100644 --- a/apps/web/lib/agent-space.test.ts +++ b/apps/web/lib/agent-space.test.ts @@ -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" }, diff --git a/apps/web/lib/agent-space.ts b/apps/web/lib/agent-space.ts index a42d3790..74693af9 100644 --- a/apps/web/lib/agent-space.ts +++ b/apps/web/lib/agent-space.ts @@ -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 = { @@ -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["kind"] } { @@ -159,6 +175,41 @@ function groupIdentity( } } +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 @@ -169,33 +220,54 @@ export function groupAgentSpaces( metadata: ReadonlyMap, ): AgentSpaceGroup[] { const grouped = new Map>() + const legacyProjects: Array<{ + project: T + projectName: string | undefined + }> = [] + const canonicalKeysByName = new Map() 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()]