diff --git a/packages/app/e2e/helpers/project-picker-ui.ts b/packages/app/e2e/helpers/project-picker-ui.ts index 019d482e86..9fef26f719 100644 --- a/packages/app/e2e/helpers/project-picker-ui.ts +++ b/packages/app/e2e/helpers/project-picker-ui.ts @@ -7,7 +7,8 @@ export async function expectOpenedProject(page: Page, projectName: string): Prom .first(); await expect(projectRow).toBeVisible({ timeout: 30_000 }); - const testId = await projectRow.getAttribute("data-testid"); - expect(testId).not.toBeNull(); - return testId!.replace("sidebar-project-row-", ""); + await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 }); + const projectId = new URL(page.url()).searchParams.get("projectId"); + expect(projectId).not.toBeNull(); + return projectId!; } diff --git a/packages/app/e2e/project-picker-desktop.spec.ts b/packages/app/e2e/project-picker-desktop.spec.ts index a4a71f0567..878c42ee3b 100644 --- a/packages/app/e2e/project-picker-desktop.spec.ts +++ b/packages/app/e2e/project-picker-desktop.spec.ts @@ -1,8 +1,10 @@ import { test, expect } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates"; +import { getE2EDaemonPort } from "./helpers/daemon-port"; +import { waitForConnectedHost } from "./helpers/hosts"; import { expectOpenedProject } from "./helpers/project-picker-ui"; -import { expectNewWorkspaceForAddedProject } from "./helpers/add-project-flow"; +import { expectNewWorkspaceForAddedProject, openAddProjectFlow } from "./helpers/add-project-flow"; import { getServerId } from "./helpers/server-id"; import { connectSeedClient } from "./helpers/seed-client"; @@ -18,8 +20,12 @@ test("Browse opens the folder selected by the desktop dialog", async ({ dialogOpenResult: projectPickerFixture.projectPath, }); await gotoAppShell(page); + await waitForConnectedHost(page, { + serverId: getServerId(), + endpoint: `localhost:${getE2EDaemonPort()}`, + }); - await page.getByTestId("sidebar-add-project").click(); + await openAddProjectFlow(page); const browse = page.getByRole("button", { name: /^Browse/ }); await expect(browse).toBeVisible({ timeout: 30_000 }); await browse.click(); @@ -50,8 +56,12 @@ test("canceling Browse returns to the Add Project methods", async ({ dialogOpenResult: null, }); await gotoAppShell(page); + await waitForConnectedHost(page, { + serverId: getServerId(), + endpoint: `localhost:${getE2EDaemonPort()}`, + }); - await page.getByTestId("sidebar-add-project").click(); + await openAddProjectFlow(page); const browse = page.getByRole("button", { name: /^Browse/ }); await expect(browse).toBeVisible({ timeout: 30_000 }); await browse.click(); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index f827c06207..5f137f87b3 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -115,12 +115,14 @@ function workspace(input: { name: string; projectId: string; projectDisplayName: string; + projectGroupKey?: string; status?: WorkspaceDescriptor["status"]; statusEnteredAt?: Date | null; }): WorkspaceDescriptor { return { id: input.id, projectId: input.projectId, + projectGroupKey: input.projectGroupKey, projectDisplayName: input.projectDisplayName, projectRootPath: `/repo/${input.projectId}`, workspaceDirectory: `/repo/${input.projectId}/${input.id}`, @@ -401,6 +403,36 @@ describe("shared sidebar workspace model", () => { expect(nextEntries.get("srv:one")).toBe(previousEntries.get("srv:one")); expect(nextEntries.get("srv:two")).not.toBe(previousEntries.get("srv:two")); }); + + it("keeps a structurally disambiguated project key in status entries", () => { + const projectKey = "host:srv:project:prj_a"; + const model = buildSidebarWorkspacePlacementModel({ + projects: [project({ projectKey, projectName: "Clone A", workspaceKeys: ["srv:clone-a"] })], + }); + const entries = buildSidebarWorkspaceEntries({ + placements: model.workspaces, + sessions: [ + { + serverId: "srv", + workspaceAgentActivity: new Map(), + workspaces: new Map([ + [ + "clone-a", + workspace({ + id: "clone-a", + name: "main", + projectId: "prj_a", + projectGroupKey: "remote:github.com/acme/app", + projectDisplayName: "acme/app", + }), + ], + ]), + }, + ], + }); + + expect(entries.get("srv:clone-a")?.projectKey).toBe(projectKey); + }); }); describe("shouldShowSidebarHostLabels", () => { diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 0224cf751e..8e1e3c34e2 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -142,14 +142,17 @@ function normalizeCurrentBranch(currentBranch: string | null | undefined): strin export function createSidebarWorkspaceEntry(input: { serverId: string; workspace: WorkspaceDescriptor; + projectKey?: string; pendingCreateAttempts?: Record; workspaceAgentActivity?: ReadonlyMap; }): SidebarWorkspaceEntry { - const projectKey = resolveProjectGroupKey({ - serverId: input.serverId, - projectId: input.workspace.projectId, - projectGroupKey: input.workspace.projectGroupKey, - }); + const projectKey = + input.projectKey ?? + resolveProjectGroupKey({ + serverId: input.serverId, + projectId: input.workspace.projectId, + projectGroupKey: input.workspace.projectGroupKey, + }); const effectiveStatus = deriveEffectiveWorkspaceStatus(input); return { workspaceKey: `${input.serverId}:${input.workspace.id}`, @@ -328,6 +331,7 @@ export function buildSidebarWorkspaceEntries(input: { const entry = createSidebarWorkspaceEntry({ serverId: placement.serverId, workspace, + projectKey: placement.projectKey, pendingCreateAttempts: input.pendingCreateAttempts, workspaceAgentActivity: session.workspaceAgentActivity, }); diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts index 55bf4a3544..f07a3a9612 100644 --- a/packages/app/src/hooks/use-open-project.test.ts +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -93,6 +93,7 @@ describe("openProjectDirectly", () => { serverId: SERVER_ID, project: { projectId: "project-1", + projectGroupKey: null, projectDisplayName: "project", projectCustomName: null, projectKind: "git", @@ -191,6 +192,7 @@ describe("cloneGithubProjectDirectly", () => { project: { ...projectPayload, projectCustomName: null, + projectGroupKey: null, }, }, ]); diff --git a/packages/app/src/hooks/use-projects.test.ts b/packages/app/src/hooks/use-projects.test.ts index 13c480315c..6dbfcb5407 100644 --- a/packages/app/src/hooks/use-projects.test.ts +++ b/packages/app/src/hooks/use-projects.test.ts @@ -225,6 +225,7 @@ describe("deriveProjectsFromReplica", () => { "gitRuntime", "githubRuntime", "isOnline", + "projectId", "repoRoot", "serverId", "serverName", diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts index f72b781027..348c1fddf4 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts @@ -103,6 +103,7 @@ it("commits the authoritative snapshot before buffered project updates", () => { kind: "upsert", project: { projectId: "attached", + projectGroupKey: "remote:github.com/acme/attached", projectDisplayName: "Renamed attached project", projectCustomName: "Personal name", projectRootPath: "/moved/attached", @@ -124,12 +125,14 @@ it("commits the authoritative snapshot before buffered project updates", () => { const session = useSessionStore.getState().sessions[serverId]; expect(session?.workspaces.get(attachedMain.id)).toMatchObject({ + projectGroupKey: "remote:github.com/acme/attached", projectDisplayName: "Renamed attached project", projectCustomName: "Personal name", projectRootPath: "/moved/attached", projectKind: "directory", }); expect(session?.workspaces.get(attachedFeature.id)).toMatchObject({ + projectGroupKey: "remote:github.com/acme/attached", projectDisplayName: "Renamed attached project", projectRootPath: "/moved/attached", }); diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index e78a2b557e..2c63a93bc6 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -42,6 +42,7 @@ function applyProjectDelta( hasAttachedWorkspace = true; snapshot.workspaces.set(workspaceId, { ...workspace, + projectGroupKey: project.projectGroupKey, projectDisplayName: project.projectDisplayName, projectCustomName: project.projectCustomName, projectRootPath: project.projectRootPath, diff --git a/packages/app/src/utils/projects.test.ts b/packages/app/src/utils/projects.test.ts index 71cf3d1924..12fc165e22 100644 --- a/packages/app/src/utils/projects.test.ts +++ b/packages/app/src/utils/projects.test.ts @@ -31,6 +31,7 @@ function workspace(input: { project?: ProjectPlacementPayload; projectId?: string; projectGroupKey?: string; + projectCustomName?: string | null; projectName?: string; remoteUrl?: string | null; currentBranch?: string; @@ -41,6 +42,7 @@ function workspace(input: { projectId: input.projectId ?? input.project?.projectKey ?? input.repoRoot, projectGroupKey: input.projectGroupKey, projectDisplayName: input.projectName ?? input.project?.projectName ?? "Project", + projectCustomName: input.projectCustomName, projectRootPath: input.repoRoot, workspaceDirectory: input.repoRoot, projectKind: "git", @@ -119,6 +121,63 @@ describe("buildProjects", () => { ]); }); + it("keeps host-local metadata when the grouping key differs from the project ID", () => { + const projectGroupKey = "remote:github.com/acme/app"; + const result = buildProjects({ + hosts: [ + { + serverId: "desktop", + serverName: "Desktop", + isOnline: true, + workspaces: [ + workspace({ + id: "main", + repoRoot: "/repo/app", + projectId: "prj_app", + projectGroupKey, + projectName: "acme/app", + projectCustomName: "My App", + }), + ], + }, + ], + }); + + expect(result.projects[0]).toMatchObject({ + projectKey: projectGroupKey, + projectName: "acme/app", + projectCustomName: "My App", + }); + }); + + it("preserves the root of a grouped project without workspaces", () => { + const result = buildProjects({ + hosts: [ + { + serverId: "desktop", + serverName: "Desktop", + isOnline: true, + workspaces: [], + emptyProjects: [ + { + projectId: "prj_app", + projectGroupKey: "remote:github.com/acme/app", + projectDisplayName: "acme/app", + projectCustomName: null, + projectRootPath: "/repo/app", + projectKind: "git", + }, + ], + }, + ], + }); + + expect(result.projects[0]?.hosts[0]).toMatchObject({ + projectId: "prj_app", + repoRoot: "/repo/app", + }); + }); + it("normalizes detached and blank branches out of workspace summaries", () => { const result = buildProjects({ hosts: [ diff --git a/packages/app/src/utils/projects.ts b/packages/app/src/utils/projects.ts index ca543f1bd5..9353b7887c 100644 --- a/packages/app/src/utils/projects.ts +++ b/packages/app/src/utils/projects.ts @@ -193,7 +193,10 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult { ), ); for (const hostProject of hostProjects) { - const customName = findProjectCustomName(host.workspaces, hostProject.projectKey); + const placement = hostProject.hosts.find((entry) => entry.serverId === host.serverId); + if (!placement) continue; + const projectId = placement.projectId ?? hostProject.projectKey; + const customName = findProjectCustomName(host.workspaces, projectId); let group = groups.get(hostProject.projectKey); if (!group) { group = { @@ -209,15 +212,13 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult { } if (!group.hostsByServerId.has(host.serverId)) { - const placement = hostProject.hosts.find((entry) => entry.serverId === host.serverId); - if (!placement) continue; group.hostsByServerId.set(host.serverId, { serverId: host.serverId, - projectId: placement.projectId ?? hostProject.projectKey, + projectId, serverName: host.serverName, isOnline: host.isOnline, workspaces: [], - fallbackRepoRoot: emptyRepoRootByProjectKey.get(hostProject.projectKey) ?? "", + fallbackRepoRoot: emptyRepoRootByProjectKey.get(projectId) ?? "", }); } } diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 13bb5ec796..5396e05df4 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -73,7 +73,11 @@ test("creates a worktree and registers it in the source workspace project withou expect(result.workspace.baseBranch).toBe("main"); expect(result.workspace.title).toBe("Feature One"); expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled(); - expect(deps.projects.get(sourceProject.projectId)).toEqual(sourceProject); + expect(deps.projects.get(sourceProject.projectId)).toEqual({ + ...sourceProject, + projectGroupKey: path.resolve(sourceProject.rootPath), + updatedAt: expect.any(String), + }); expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]); }); @@ -436,7 +440,11 @@ test("an explicit project FK remains unchanged when its worktree comes from anot ); expect(result.workspace.projectId).toBe(project.projectId); - expect(deps.projects.get(project.projectId)).toEqual(project); + expect(deps.projects.get(project.projectId)).toEqual({ + ...project, + projectGroupKey: path.resolve(project.rootPath), + updatedAt: expect.any(String), + }); }); // POSIX-only: Windows git worktree paths need separate canonicalization coverage. diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 53ec923a8f..bfc0a2c076 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -649,6 +649,7 @@ describe("project command-center RPCs", () => { rootPath: directoryPath, kind: "non_git", displayName: "new-project", + projectGroupKey: directoryPath, timestamp: expect.any(String), }); expect(messages).toEqual([ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 5b798e9de2..80e0820e74 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4289,6 +4289,7 @@ export class Session { return { id: result.workspace.workspaceId, projectId: result.workspace.projectId, + ...(projectRecord?.projectGroupKey ? { projectGroupKey: projectRecord.projectGroupKey } : {}), projectDisplayName: projectRecord ? resolveProjectDisplayName(projectRecord) : result.workspace.projectId, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 8a87e5064d..de02b8b7e9 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -3699,6 +3699,7 @@ test("create paseo worktree response preserves an explicit non-Git project", asy expect(response?.payload.error).toBeNull(); expect(response?.payload.workspace).toMatchObject({ projectId: explicitProject.projectId, + projectGroupKey: explicitProject.rootPath, projectDisplayName: explicitProject.displayName, projectRootPath: explicitProject.rootPath, projectKind: "non_git", @@ -3710,7 +3711,10 @@ test("create paseo worktree response preserves an explicit non-Git project", asy expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/); expect(response?.payload.workspace?.workspaceDirectory).toContain(path.join("worktree-123")); expect(workspaces.has(response?.payload.workspace?.id ?? "")).toBe(true); - expect(projects.get(explicitProject.projectId)).toEqual(explicitProject); + expect(projects.get(explicitProject.projectId)).toEqual({ + ...explicitProject, + projectGroupKey: explicitProject.rootPath, + }); }); test("workspace updates stay scoped to the matching cwd", async () => { diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 325a6caf30..da393ebc29 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -253,7 +253,14 @@ describe("WorkspaceReconciliationService", () => { const metadataResult = await service.reconcileGitMetadata(); - expect(metadataResult.changesApplied).toEqual([]); + expect(metadataResult.changesApplied).toEqual([ + { + kind: "project_updated", + projectId: "p1", + directory: projectRoot, + fields: { projectGroupKey: projectRoot }, + }, + ]); expect(workspaces.get("w1")?.archivedAt).toBeNull(); const fullResult = await service.runOnce(); @@ -304,7 +311,14 @@ describe("WorkspaceReconciliationService", () => { ); const afterGitInit = await service.reconcileGitMetadata(); - expect(beforeGitInit.changesApplied).toEqual([]); + expect(beforeGitInit.changesApplied).toEqual([ + { + kind: "project_updated", + projectId: "p1", + directory: projectRoot, + fields: { projectGroupKey: projectRoot }, + }, + ]); expect(afterGitInit.changesApplied).toEqual([ { kind: "project_updated", @@ -386,7 +400,20 @@ describe("WorkspaceReconciliationService", () => { const result = await service.reconcileGitMetadata(); - expect(result.changesApplied).toEqual([]); + expect(result.changesApplied).toEqual([ + { + kind: "project_updated", + projectId: "p1", + directory: projectRoot, + fields: { projectGroupKey: projectRoot }, + }, + { + kind: "project_updated", + projectId: "p2", + directory: equivalentProjectRoot, + fields: { projectGroupKey: projectRoot }, + }, + ]); expect(git.reads).toEqual([projectRoot, workspaceRoot]); }); @@ -445,7 +472,7 @@ describe("WorkspaceReconciliationService", () => { kind: "project_updated", projectId: "p1", directory: projectRoot, - fields: { kind: "git" }, + fields: { kind: "git", projectGroupKey: projectRoot }, }, { kind: "workspace_updated", @@ -462,6 +489,7 @@ describe("WorkspaceReconciliationService", () => { expect(projects.get("p1")).toEqual({ ...originalProject, kind: "git", + projectGroupKey: projectRoot, updatedAt: expect.any(String), }); expect(workspaces.get("w1")).toEqual({ @@ -867,6 +895,55 @@ describe("WorkspaceReconciliationService", () => { expect(projects.get("p1")!.projectGroupKey).toBe("remote:github.com/new-owner/new-repo"); }); + test("falls back to the persisted root when a Git remote disappears", async () => { + const dir = createTempGitRepo("reconcile-removed-remote-"); + tempDirs.push(dir); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + projectGroupKey: "remote:github.com/acme/old-repo", + rootPath: dir, + kind: "git", + displayName: "acme/old-repo", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "w1", + createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: "p1", + cwd: dir, + kind: "local_checkout", + displayName: "main", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + workspaceGitService: createWorkspaceGitServiceStub({ + [dir]: { + projectKind: "git", + projectDisplayName: "old-repo", + workspaceDisplayName: "main", + gitRemote: null, + }, + }), + }); + + await service.runOnce(); + + expect(projects.get("p1")?.projectGroupKey).toBe(path.resolve(dir)); + }); + test("keeps custom and default names stable when the remote changes", async () => { const dir = createTempGitRepo("reconcile-customname-"); tempDirs.push(dir); @@ -1044,7 +1121,7 @@ describe("WorkspaceReconciliationService", () => { kind: "project_updated", projectId: "p1", directory: projectRoot, - fields: { kind: "git" }, + fields: { kind: "git", projectGroupKey: projectRoot }, }, { kind: "workspace_updated", @@ -1328,6 +1405,12 @@ describe("WorkspaceReconciliationService", () => { const result = await service.reconcileGitMetadata(); expect(result.changesApplied).toEqual([ + { + kind: "project_updated", + projectId: "p1", + directory: rootPath, + fields: { projectGroupKey: "/tmp/main-repo" }, + }, { kind: "workspace_updated", workspaceId: "w1", diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 52915d2fca..3b87d552d6 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -342,18 +342,16 @@ export class WorkspaceReconciliationService { ); const projectUpdates: Partial> = {}; const mappedKind = deriveProjectKind(currentGit); - const projectGroupKey = currentGit.remoteUrl - ? deriveProjectGroupKey({ - rootPath: project.rootPath, - remoteUrl: currentGit.remoteUrl, - mainRepoRoot: currentGit.mainRepoRoot, - }) - : null; + const projectGroupKey = deriveProjectGroupKey({ + rootPath: project.rootPath, + remoteUrl: currentGit.remoteUrl, + mainRepoRoot: currentGit.mainRepoRoot, + }); if (project.kind !== mappedKind) { projectUpdates.kind = mappedKind; } - if (projectGroupKey && project.projectGroupKey !== projectGroupKey) { + if (project.projectGroupKey !== projectGroupKey) { projectUpdates.projectGroupKey = projectGroupKey; }