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.
This commit is contained in:
Mohamed Boudra
2026-11-02 09:47:49 +01:00
parent 4706165690
commit 4b0d5f5e4c
4 changed files with 66 additions and 3 deletions
@@ -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(
@@ -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<void>;
chooseIssue(label: string): Promise<void>;
createWorkspace(options?: { prompt?: string }): Promise<{ branch: string; metadata: unknown }>;
switchToOtherProject(): Promise<void>;
expectIsolation(isolation: "local" | "worktree"): Promise<void>;
/** Removes the plugin, leaves the screen, and opens New workspace for the project again. */
reopenWithoutPlugin(): Promise<void>;
@@ -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();
}
}
@@ -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);
@@ -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<SetupState>) => {
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) {