From 4b0d5f5e4cffd0cf4057ed3aad34b153dbef598a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 09:47:49 +0100 Subject: [PATCH] fix(plugins): a branch and metadata chosen for one project are not sent for another The setup model outlives the project and host it was chosen for, so an issue picked for project A was sent on workspace.create after the user switched to project B. The model now drops the branch and metadata when the project changes (project ids are host-scoped, so a host switch counts). A project that is still resolving is not a switch. The base selection is left alone: the built-in picker keeps a branch pick across a project switch, and that behavior is unchanged. --- .../plugin-new-workspace-items.spec.ts | 16 +++++++++ .../helpers/plugin-new-workspace-items.ts | 33 +++++++++++++++++-- .../screens/new-workspace/setup-model.test.ts | 13 ++++++++ .../src/screens/new-workspace/setup-model.ts | 7 ++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/browser/plugin-new-workspace-items.spec.ts b/packages/app/e2e/browser/plugin-new-workspace-items.spec.ts index e0fa0ce97d..c1b6f4287b 100644 --- a/packages/app/e2e/browser/plugin-new-workspace-items.spec.ts +++ b/packages/app/e2e/browser/plugin-new-workspace-items.spec.ts @@ -48,6 +48,22 @@ test("a plugin's branch applies when the project has no default base to branch o ); }); +test("a branch and metadata chosen for one project are not sent for another", async ({ page }) => { + test.setTimeout(180_000); + await withNewWorkspaceIssuesExample( + page, + { compact: false, otherProject: true }, + async (screen) => { + await screen.chooseWorktree(); + await screen.chooseIssue("PAS-101 Slow sidebar"); + await screen.switchToOtherProject(); + const created = await screen.createWorkspace(); + expect(created.branch).not.toBe("pas-101-slow-sidebar"); + expect(JSON.stringify(created.metadata ?? {})).not.toContain("PAS-101"); + }, + ); +}); + test("an isolation set from a mount effect is remembered like a pick", async ({ page }) => { test.setTimeout(180_000); await withNewWorkspaceIssuesExample( diff --git a/packages/app/e2e/support/helpers/plugin-new-workspace-items.ts b/packages/app/e2e/support/helpers/plugin-new-workspace-items.ts index 8d7962d936..12629fb89f 100644 --- a/packages/app/e2e/support/helpers/plugin-new-workspace-items.ts +++ b/packages/app/e2e/support/helpers/plugin-new-workspace-items.ts @@ -68,11 +68,18 @@ const COMPACT = { width: 500, height: 800 }; /** Runs `run` on the New workspace screen with the new-workspace-issues example installed. */ export async function withNewWorkspaceIssuesExample( page: Page, - options: { compact: boolean; detachedHead?: boolean; setsOnMount?: boolean }, + options: { + compact: boolean; + detachedHead?: boolean; + setsOnMount?: boolean; + /** A second project the screen can switch to. */ + otherProject?: boolean; + }, run: (screen: { chooseWorktree(): Promise; chooseIssue(label: string): Promise; createWorkspace(options?: { prompt?: string }): Promise<{ branch: string; metadata: unknown }>; + switchToOtherProject(): Promise; expectIsolation(isolation: "local" | "worktree"): Promise; /** Removes the plugin, leaves the screen, and opens New workspace for the project again. */ reopenWithoutPlugin(): Promise; @@ -83,6 +90,9 @@ export async function withNewWorkspaceIssuesExample( const example = await copyPluginExample("new-workspace-issues"); if (options.setsOnMount) await replaceWithMountEffectItem(example.directory); const repo = await createTempGitRepo("new-workspace-issues-", { branches: ["main"] }); + const otherRepo = options.otherProject + ? await createTempGitRepo("new-workspace-other-", { branches: ["main"] }) + : null; let createdDirectory: string | null = null; try { // A detached HEAD leaves the screen with no default base to branch off. @@ -90,6 +100,8 @@ export async function withNewWorkspaceIssuesExample( await client.patchDaemonConfig({ pluginsEnabled: true }); await client.installDirectoryPlugin(example.directory); const project = await openProjectViaDaemon(client, repo.path); + const other = otherRepo ? await openProjectViaDaemon(client, otherRepo.path) : null; + let target = project; await gotoAppShell(page); await waitForSidebarHydration(page); await openNewWorkspaceComposer(page, project); @@ -106,6 +118,20 @@ export async function withNewWorkspaceIssuesExample( await page.getByRole("button", { name: "Issue", exact: true }).click(); await page.getByText(label, { exact: true }).click(); }), + switchToOtherProject: () => + test.step("switch to the other project", async () => { + if (!other) throw new Error("Open the example with otherProject to switch projects"); + await page.getByRole("button", { name: "Workspace project" }).click(); + await page + .getByTestId("combobox-desktop-container") + .last() + .getByText(other.projectDisplayName, { exact: true }) + .click(); + await expect(page.getByRole("button", { name: "Workspace project" })).toContainText( + other.projectDisplayName, + ); + target = other; + }), expectIsolation: (isolation) => test.step(`the screen is on ${isolation}`, () => expectWorkspaceIsolationSelected(page, isolation)), @@ -124,8 +150,8 @@ export async function withNewWorkspaceIssuesExample( const created = await assertNewWorkspaceSidebarAndHeader(page, { serverId: getServerId(), client, - previousWorkspaceId: project.workspaceId, - projectDisplayName: project.projectDisplayName, + previousWorkspaceId: target.workspaceId, + projectDisplayName: target.projectDisplayName, // The sidebar is closed at compact width. assertSidebarRow: !options.compact, }); @@ -142,5 +168,6 @@ export async function withNewWorkspaceIssuesExample( await client.close(); await example.cleanup(); await repo.cleanup(); + await otherRepo?.cleanup(); } } diff --git a/packages/app/src/screens/new-workspace/setup-model.test.ts b/packages/app/src/screens/new-workspace/setup-model.test.ts index d14e9949c5..bdbc4e5f46 100644 --- a/packages/app/src/screens/new-workspace/setup-model.test.ts +++ b/packages/app/src/screens/new-workspace/setup-model.test.ts @@ -63,6 +63,19 @@ describe("New workspace setup commands", () => { }); }); + it("drops the branch and metadata chosen for another project, but not while the project resolves", async () => { + const setup = createNewWorkspaceSetup({ ...READY, project: null }, recordingEffects().effects); + await setup.commands.setBranch("pas-101"); + setup.syncInputs(READY); + expect(setup.getSnapshot().branch).toBe("pas-101"); + + await setup.commands.setMetadata({ issue: "PAS-101" }); + setup.syncInputs({ ...READY, project: { id: "p2", name: "other" } }); + + expect(setup.getSnapshot().branch).toBeNull(); + expect(setup.getSnapshot().metadata).toEqual({}); + }); + it("rejects every command while the screen is locked, as the pickers do", async () => { const setup = createNewWorkspaceSetup({ ...READY, locked: true }, recordingEffects().effects); diff --git a/packages/app/src/screens/new-workspace/setup-model.ts b/packages/app/src/screens/new-workspace/setup-model.ts index 9902fd814f..ce0098becb 100644 --- a/packages/app/src/screens/new-workspace/setup-model.ts +++ b/packages/app/src/screens/new-workspace/setup-model.ts @@ -80,6 +80,7 @@ export function createNewWorkspaceSetup( metadata: {}, }; let inputs = initial; + let target = initial.project?.id ?? null; const listeners = new Set<() => void>(); const update = (patch: Partial) => { state = { ...state, ...patch }; @@ -110,6 +111,12 @@ export function createNewWorkspaceSetup( chooseIsolation, syncInputs: (next) => { inputs = next; + // A branch and metadata are chosen for one project on one host. A project id is + // host-scoped, so a change of host changes it too. An absent project is a screen that is + // still resolving, not a switch. + if (!next.project) return; + if (target && target !== next.project.id) update({ branch: null, metadata: {} }); + target = next.project.id; }, commands: { async setIsolation(isolation) {