fix(plugins): New workspace commands work from mount effects, and branch and base ref match what creation uses

A command called from a New workspace item's mount effect rejected because the
setup model only received its inputs and the pickers' handlers from a passive
effect on the screen, which runs after a child's. The model now takes its
inputs at construction, picks the base and isolation itself, and is refreshed
in a layout effect; the screen supplies the side effects of a pick (the PR
attachment, the remembered isolation) as listeners.

The branch a plugin sets was dropped when no base was resolved, such as on a
detached HEAD, because it was sent only for an explicit branch-off. It now
applies whenever the worktree cuts a new branch, decided in one place that
mirrors the daemon.

baseRef.name was the display-shortened branch name, so setting back what was
read based the worktree on the local branch instead of the origin one. It is
now the exact ref creation uses, and host and baseRef are frozen like project
and metadata.
This commit is contained in:
Mohamed Boudra
2026-11-04 14:05:15 +01:00
parent a66e502655
commit e5bc1fa823
13 changed files with 399 additions and 190 deletions
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
buildBranchPickerItems,
pickerItemToCheckoutRequest,
type PickerItem,
} from "@/screens/new-workspace-picker-item";
import { createNewWorkspaceSetup } from "@/screens/new-workspace/setup-model";
import { toPluginNewWorkspace } from "./model";
const HOST = { id: "host", label: "Host" } as Parameters<typeof toPluginNewWorkspace>[0]["host"];
function viewOf(baseItem: PickerItem) {
return {
project: { id: "p", name: "repo" },
isolation: "worktree" as const,
baseItem,
branch: null,
metadata: {},
locked: false,
};
}
describe("the base ref a plugin reads", () => {
it("names the exact ref creation uses, so setting it back picks the same base", async () => {
// `release` exists locally and on origin, and the two have diverged.
const rows = buildBranchPickerItems([
{
name: "release",
committerDate: 0,
hasLocal: true,
hasRemote: true,
localAhead: 2,
localBehind: 1,
},
]);
const origin = rows.find((row) => row.kind === "branch" && row.refName.includes("remotes"));
if (!origin) throw new Error("expected an origin row");
const setup = createNewWorkspaceSetup({
locked: false,
project: { id: "p", name: "repo" },
canCreateWorktree: true,
supportsMetadata: true,
});
const chosen: PickerItem[] = [];
setup.onBaseChosen((item) => chosen.push(item));
const read = toPluginNewWorkspace({
host: HOST,
view: viewOf(origin),
commands: setup.commands,
});
expect(read.baseRef).toEqual({ type: "branch", name: "refs/remotes/origin/release" });
if (read.baseRef?.type !== "branch") throw new Error("expected a branch");
await read.setBaseRef(read.baseRef);
expect(pickerItemToCheckoutRequest(chosen[0] ?? null)).toEqual(
pickerItemToCheckoutRequest(origin),
);
});
});
@@ -1,6 +1,6 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginHostProps, PluginNewWorkspace } from "@getpaseo/plugin/client";
import { branchNameFromRef, type PickerItem } from "@/screens/new-workspace-picker-item";
import type { PickerItem } from "@/screens/new-workspace-picker-item";
import type { SetupCommands } from "@/screens/new-workspace/setup-model";
import { cloneAndFreeze } from "../freeze";
@@ -15,10 +15,11 @@ export interface NewWorkspaceView {
locked: boolean;
}
/** `name` is the exact ref creation uses, so a plugin can set back what it read. */
function toBaseRef(item: PickerItem | null): PluginNewWorkspace["baseRef"] {
if (!item) return null;
return item.kind === "branch"
? { type: "branch", name: branchNameFromRef(item.refName) }
? { type: "branch", name: item.refName }
: { type: "change-request", number: item.item.number };
}
@@ -29,10 +30,10 @@ export function toPluginNewWorkspace(input: {
}): PluginNewWorkspace {
const { host, view, commands } = input;
return {
host: { id: host.id, label: host.label },
host: cloneAndFreeze({ id: host.id, label: host.label }),
project: cloneAndFreeze(view.project),
isolation: view.isolation,
baseRef: toBaseRef(view.baseItem),
baseRef: cloneAndFreeze(toBaseRef(view.baseItem)),
branch: view.branch,
metadata: cloneAndFreeze(view.metadata),
...commands,