diff --git a/packages/app/e2e/composer-attachments.spec.ts b/packages/app/e2e/composer-attachments.spec.ts index 98d0c7435..c270cf71b 100644 --- a/packages/app/e2e/composer-attachments.spec.ts +++ b/packages/app/e2e/composer-attachments.spec.ts @@ -207,7 +207,7 @@ test.describe("Composer attachments", () => { }); await openNewWorkspaceComposer(page, { - projectKey: workspace.projectId, + projectKey: workspace.projectKey, projectDisplayName: workspace.projectDisplayName, }); @@ -293,7 +293,7 @@ test.describe("Composer attachments", () => { }); await openNewWorkspaceComposer(page, { - projectKey: workspace.projectId, + projectKey: workspace.projectKey, projectDisplayName: workspace.projectDisplayName, }); await fillComposerDraft(page, "lock test prompt"); diff --git a/packages/app/e2e/empty-project-persists.spec.ts b/packages/app/e2e/empty-project-persists.spec.ts index c915d809f..8e715af87 100644 --- a/packages/app/e2e/empty-project-persists.spec.ts +++ b/packages/app/e2e/empty-project-persists.spec.ts @@ -32,12 +32,12 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro await archiveItem.click(); } -async function removeProjectFromSidebar(page: Page, projectId: string): Promise { - const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`); +async function removeProjectFromSidebar(page: Page, projectKey: string): Promise { + const projectRow = page.getByTestId(`sidebar-project-row-${projectKey}`); await expect(projectRow).toBeVisible({ timeout: 30_000 }); await projectRow.hover(); - const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`); + const kebab = page.getByTestId(`sidebar-project-kebab-${projectKey}`); await expect(kebab).toBeVisible({ timeout: 10_000 }); await kebab.click(); @@ -45,7 +45,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise< // user-confirmed removal proceeds deterministically. page.once("dialog", (dialog) => void dialog.accept()); - const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`); + const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectKey}`); await expect(removeItem).toBeVisible({ timeout: 10_000 }); await removeItem.click(); } @@ -152,9 +152,9 @@ test.describe("Project with no workspaces persists", () => { const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" }); try { - const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`); + const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`); const newWorkspaceRow = page.getByTestId( - `sidebar-project-new-workspace-row-${workspace.projectId}`, + `sidebar-project-new-workspace-row-${workspace.projectKey}`, ); const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace"); @@ -197,7 +197,7 @@ test.describe("Project remove", () => { const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" }); try { - const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`); + const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`); await gotoAppShell(page); await waitForSidebarHydration(page); @@ -206,7 +206,7 @@ test.describe("Project remove", () => { timeout: 30_000, }); - await removeProjectFromSidebar(page, workspace.projectId); + await removeProjectFromSidebar(page, workspace.projectKey); await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, { timeout: 30_000, diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 20bbd2c81..d69a4e8b2 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -46,9 +46,13 @@ function requireWorkspace(payload: WorkspacePayload) { } function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject { + const projectKey = workspace.projectGroupKey ?? workspace.project?.projectGroupKey; + if (!projectKey) { + throw new Error(`Workspace ${workspace.id} has no project group key`); + } return { workspaceId: workspace.id, - projectKey: workspace.projectId, + projectKey, projectDisplayName: workspace.projectDisplayName, workspaceName: workspace.name, workspaceDirectory: workspace.workspaceDirectory, diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index d3fd79738..e55b7d570 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -8,6 +8,8 @@ export interface SeedWorkspaceDescriptor { id: string; name: string; projectId: string; + projectGroupKey?: string; + project?: { projectGroupKey?: string }; projectDisplayName: string; projectRootPath: string; workspaceDirectory: string; @@ -179,8 +181,10 @@ export interface SeededWorkspace { workspaceId: string; workspaceName: string; workspaceDirectory: string; - /** Stable project identity the daemon groups workspaces under. */ + /** Host-local identity used by daemon project operations. */ projectId: string; + /** Opaque cross-host key used by grouped project UI and routes. */ + projectKey: string; /** Project label the UI shows (owner/repo for known remotes, else basename). */ projectDisplayName: string; cleanup(): Promise; @@ -208,6 +212,10 @@ export async function seedWorkspace(options: { throw new Error(created.error ?? `Failed to create workspace ${project.path}`); } const workspace = created.workspace; + const projectKey = workspace.projectGroupKey ?? workspace.project?.projectGroupKey; + if (!projectKey) { + throw new Error(`Created workspace ${workspace.id} has no project group key`); + } return { client, repoPath: project.path, @@ -215,6 +223,7 @@ export async function seedWorkspace(options: { workspaceName: workspace.name, workspaceDirectory: workspace.workspaceDirectory, projectId: workspace.projectId, + projectKey, projectDisplayName: workspace.projectDisplayName, cleanup: async () => { await client.removeProject(workspace.projectId).catch(() => undefined); diff --git a/packages/app/e2e/import-session.opencode.real.spec.ts b/packages/app/e2e/import-session.opencode.real.spec.ts index 9e1e909f5..2f7d09ac5 100644 --- a/packages/app/e2e/import-session.opencode.real.spec.ts +++ b/packages/app/e2e/import-session.opencode.real.spec.ts @@ -64,6 +64,14 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise { await client.close().catch(() => undefined); diff --git a/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts b/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts index b106810b8..9b4fbf6e1 100644 --- a/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts +++ b/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts @@ -165,7 +165,7 @@ test.describe("New workspace Codex mode preferences", () => { await waitForSidebarHydration(page); await openGlobalNewWorkspaceComposer(page); await selectNewWorkspaceProject(page, { - projectKey: seeded.projectId, + projectKey: seeded.projectKey, projectDisplayName: seeded.projectDisplayName, }); @@ -221,7 +221,7 @@ test.describe("New workspace Codex mode preferences", () => { await openGlobalNewWorkspaceComposer(page); await selectNewWorkspaceProject(page, { - projectKey: seeded.projectId, + projectKey: seeded.projectKey, projectDisplayName: seeded.projectDisplayName, }); diff --git a/packages/app/e2e/new-workspace-composer-draft.spec.ts b/packages/app/e2e/new-workspace-composer-draft.spec.ts index 3606fe7e0..d561f846b 100644 --- a/packages/app/e2e/new-workspace-composer-draft.spec.ts +++ b/packages/app/e2e/new-workspace-composer-draft.spec.ts @@ -34,7 +34,7 @@ test.describe("New workspace composer draft", () => { await gotoAppShell(page); await waitForSidebarHydration(page); await openNewWorkspaceComposer(page, { - projectKey: firstProject.projectId, + projectKey: firstProject.projectKey, projectDisplayName: firstProject.projectDisplayName, }); await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName); @@ -42,7 +42,7 @@ test.describe("New workspace composer draft", () => { await fillNewWorkspaceDraft(page, DRAFT); await selectNewWorkspaceProject(page, { - projectKey: secondProject.projectId, + projectKey: secondProject.projectKey, projectDisplayName: secondProject.projectDisplayName, }); diff --git a/packages/app/e2e/new-workspace-entry.spec.ts b/packages/app/e2e/new-workspace-entry.spec.ts index b2ad1c884..78aad7ece 100644 --- a/packages/app/e2e/new-workspace-entry.spec.ts +++ b/packages/app/e2e/new-workspace-entry.spec.ts @@ -179,13 +179,13 @@ test.describe("New workspace entry points", () => { try { await gotoAppShell(page); await waitForSidebarHydration(page); - await expect(projectRow(page, projectA.projectId)).toBeVisible({ timeout: 30_000 }); - await expect(projectRow(page, projectB.projectId)).toBeVisible({ timeout: 30_000 }); - await expect(projectRow(page, projectC.projectId)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, projectA.projectKey)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, projectB.projectKey)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, projectC.projectKey)).toBeVisible({ timeout: 30_000 }); // Project A's row icon opens New Workspace with A preselected. await openNewWorkspaceComposer(page, { - projectKey: projectA.projectId, + projectKey: projectA.projectKey, projectDisplayName: projectA.projectDisplayName, }); await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName); @@ -194,7 +194,9 @@ test.describe("New workspace entry points", () => { // manualProjectKey is what the reused 'new' screen must reset when the next // route-driven navigation targets a different project. await page.getByTestId("new-workspace-project-picker-trigger").click(); - const optionC = page.getByTestId(`new-workspace-project-picker-option-${projectC.projectId}`); + const optionC = page.getByTestId( + `new-workspace-project-picker-option-${projectC.projectKey}`, + ); await expect(optionC).toBeVisible({ timeout: 30_000 }); await optionC.click(); await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName); @@ -203,7 +205,7 @@ test.describe("New workspace entry points", () => { // because the stale manual choice (C) was reset on the route change. If the // reset were missing, the trigger would still read C. await openNewWorkspaceComposer(page, { - projectKey: projectB.projectId, + projectKey: projectB.projectKey, projectDisplayName: projectB.projectDisplayName, }); await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName); @@ -226,8 +228,8 @@ test.describe("New workspace entry points", () => { try { await gotoAppShell(page); await waitForSidebarHydration(page); - await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 }); - await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 }); // Open New Workspace for the non-git project via the global button, then // select it in the picker (the per-row icon would preselect it too). @@ -236,7 +238,7 @@ test.describe("New workspace entry points", () => { await expect(trigger).toBeVisible({ timeout: 30_000 }); await trigger.click(); const nonGitOption = page.getByTestId( - `new-workspace-project-picker-option-${nonGitProject.projectId}`, + `new-workspace-project-picker-option-${nonGitProject.projectKey}`, ); await expect(nonGitOption).toBeVisible({ timeout: 30_000 }); await nonGitOption.click(); @@ -249,7 +251,7 @@ test.describe("New workspace entry points", () => { // Switching to the git project on the same screen reveals the Isolation row. await trigger.click(); const gitOption = page.getByTestId( - `new-workspace-project-picker-option-${gitProject.projectId}`, + `new-workspace-project-picker-option-${gitProject.projectKey}`, ); await expect(gitOption).toBeVisible({ timeout: 30_000 }); await gitOption.click(); diff --git a/packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts b/packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts index 9a8948669..9f125f19e 100644 --- a/packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts +++ b/packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts @@ -126,7 +126,7 @@ test.describe("New Workspace mode cycle safety", () => { // so its handler is still registered when we cycle here. await openGlobalNewWorkspaceComposer(page); await selectNewWorkspaceProject(page, { - projectKey: seeded.projectId, + projectKey: seeded.projectKey, projectDisplayName: seeded.projectDisplayName, }); diff --git a/packages/app/e2e/sidebar-model-b.spec.ts b/packages/app/e2e/sidebar-model-b.spec.ts index 30ebb767b..fe57e5248 100644 --- a/packages/app/e2e/sidebar-model-b.spec.ts +++ b/packages/app/e2e/sidebar-model-b.spec.ts @@ -53,8 +53,8 @@ test.describe("Model B sidebar shape", () => { // Both projects are expandable parents — the non-git one is NOT flattened // into a bare workspace link. - await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 }); - await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 }); // Each parent shows both of its workspace rows underneath. await expect(workspaceRow(page, gitProject.workspaceId)).toBeVisible({ timeout: 30_000 }); @@ -65,12 +65,12 @@ test.describe("Model B sidebar shape", () => { // Both projects show a per-row New workspace icon (revealed on hover): the // git project can branch off a worktree, and the non-git project can add // another workspace because the host supports workspaceMultiplicity. - await projectRow(page, gitProject.projectId).hover(); - await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({ + await projectRow(page, gitProject.projectKey).hover(); + await expect(projectNewWorktreeIcon(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000, }); - await projectRow(page, nonGitProject.projectId).hover(); - await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({ + await projectRow(page, nonGitProject.projectKey).hover(); + await expect(projectNewWorktreeIcon(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000, }); diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index 223a0cce3..7494fa239 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -219,7 +219,7 @@ test.describe("Half-screen desktop layout", () => { } await gotoAppShell(page); - await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click(); + await page.getByTestId(`sidebar-project-show-more-${workspace.projectKey}`).click(); await waitForSidebarWorkspace(page, lastWorkspaceId); const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll"); diff --git a/packages/app/e2e/workspace-model-restart.spec.ts b/packages/app/e2e/workspace-model-restart.spec.ts index 3f459bf46..8c298e483 100644 --- a/packages/app/e2e/workspace-model-restart.spec.ts +++ b/packages/app/e2e/workspace-model-restart.spec.ts @@ -32,6 +32,8 @@ interface RestartDaemonClient { name: string; status: string; workspaceDirectory: string; + projectGroupKey?: string; + project?: { projectGroupKey?: string }; }>; }>; fetchAgents(options?: { scope?: "active" }): Promise<{ @@ -461,9 +463,18 @@ test.describe("Workspace model restart regressions", () => { .poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 }) .toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`); + const reconciledWorkspace = (await client.fetchWorkspaces()).entries.find( + (workspace) => workspace.id === seeded.workspaceA, + ); + const reconciledProjectKey = + reconciledWorkspace?.projectGroupKey ?? reconciledWorkspace?.project?.projectGroupKey; + if (!reconciledProjectKey) { + throw new Error(`Workspace ${seeded.workspaceA} was not reconciled with a project key`); + } + await openGlobalNewWorkspaceComposer(page); await selectNewWorkspaceProject(page, { - projectKey: seeded.projectId, + projectKey: reconciledProjectKey, projectDisplayName: seeded.projectDisplayName, }); await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName); diff --git a/packages/app/e2e/workspace-multiplicity.spec.ts b/packages/app/e2e/workspace-multiplicity.spec.ts index 1e5066cbe..bafc4407a 100644 --- a/packages/app/e2e/workspace-multiplicity.spec.ts +++ b/packages/app/e2e/workspace-multiplicity.spec.ts @@ -84,7 +84,7 @@ test.describe("Workspace multiplicity creation flow", () => { try { const project = { - projectKey: seeded.projectId, + projectKey: seeded.projectKey, projectDisplayName: seeded.projectDisplayName, }; @@ -136,7 +136,7 @@ test.describe("Workspace multiplicity creation flow", () => { try { const project = { - projectKey: seeded.projectId, + projectKey: seeded.projectKey, projectDisplayName: seeded.projectDisplayName, }; @@ -182,7 +182,7 @@ test.describe("Workspace multiplicity creation flow", () => { try { const project = { - projectKey: seeded.projectId, + projectKey: seeded.projectKey, projectDisplayName: seeded.projectDisplayName, }; @@ -190,7 +190,7 @@ test.describe("Workspace multiplicity creation flow", () => { await waitForSidebarHydration(page); // Model B: a non-git project is an expandable parent like any other, with // its single workspace already rendered as its own row underneath. - await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({ + await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({ timeout: 30_000, }); await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({ @@ -210,7 +210,7 @@ test.describe("Workspace multiplicity creation flow", () => { // Both the original and the new workspace render as distinct rows under // the same expandable parent. - await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({ + await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({ timeout: 30_000, }); await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({