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 07:25:21 +01:00
parent 14fc9b02f8
commit 476bb46f42
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,
@@ -8,6 +8,7 @@ import {
buildPickerOptionData,
defaultBasePickerItem,
pickerItemToCheckoutRequest,
startsNewBranch,
type PickerItem,
} from "./new-workspace-picker-item";
@@ -386,3 +387,20 @@ describe("starting-ref search", () => {
},
);
});
describe("startsNewBranch", () => {
it("is true for a branch-off and for no request, which is the default base on a detached HEAD", () => {
expect(startsNewBranch(undefined)).toBe(true);
expect(startsNewBranch({ action: "branch-off", refName: "main" })).toBe(true);
});
it("is false for a checkout of a branch or a change request", () => {
expect(startsNewBranch({ action: "checkout", refName: "feature" })).toBe(false);
expect(
startsNewBranch({
checkoutSource: { kind: "change_request", forge: "github", number: 7 },
}),
).toBe(false);
expect(startsNewBranch({ githubPrNumber: 7 })).toBe(false);
});
});
@@ -210,6 +210,17 @@ export function pickerItemToCheckoutRequest(
}
}
/**
* Whether creating a worktree from this request cuts a new branch, which is what the daemon does
* for anything but a checkout. No request at all (a detached HEAD gives the screen no default
* base) cuts one from the repository's default branch.
*/
export function startsNewBranch(request: PickerCheckoutRequest | undefined): boolean {
if (!request) return true;
if (request.action === "checkout") return false;
return !request.checkoutSource && request.githubPrNumber === undefined;
}
export function prPickerOptionId(number: number): string {
return `${PR_OPTION_PREFIX}${number}`;
}
@@ -6,7 +6,7 @@ import type {
import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages";
import { encodeImages } from "@/utils/encode-images";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
@@ -90,9 +90,9 @@ import {
defaultBasePickerItem,
pickerItemLabel,
pickerItemToCheckoutRequest,
startsNewBranch,
type BranchPickerDetail,
type PickerCheckoutRequest,
type PickerItem,
type PickerOptionData,
} from "./new-workspace-picker-item";
import {
@@ -107,7 +107,6 @@ import {
createNewWorkspaceSetup,
useNewWorkspaceSetupState,
type NewWorkspaceSetup,
type SetupFacts,
} from "./new-workspace/setup-model";
import {
resolveNewWorkspaceAutomaticServerId,
@@ -200,31 +199,30 @@ interface WorkspaceIsolationState {
// Preserve the user's worktree choice while route metadata is provisional. Once
// the authoritative placement arrives, unsupported projects fall back to local.
function useWorkspaceIsolation(input: {
setup: NewWorkspaceSetup;
supportsMultiplicity: boolean;
worktreeSupport: "supported" | "unsupported" | "unknown";
manualIsolation: "local" | "worktree" | null;
setManualIsolation: (value: "local" | "worktree") => void;
canCreateWorktree: boolean;
}): WorkspaceIsolationState {
const { supportsMultiplicity, worktreeSupport, manualIsolation, setManualIsolation } = input;
const { setup, supportsMultiplicity, canCreateWorktree } = input;
// The last isolation choice is remembered alongside the other New Workspace
// form preferences (provider, model, mode). A manual in-screen pick overrides
// the remembered default until the screen remounts.
const { preferences, updatePreferences } = useFormPreferences();
const { manualIsolation } = useNewWorkspaceSetupState(setup);
const isolation = manualIsolation ?? preferences.isolation ?? "local";
const canCreateWorktree = supportsMultiplicity && worktreeSupport !== "unsupported";
const isWorktree = isolation === "worktree" && canCreateWorktree;
const setIsolation = useCallback(
(value: "local" | "worktree") => {
setManualIsolation(value);
void updatePreferences({ isolation: value });
},
[setManualIsolation, updatePreferences],
useEffect(
() =>
setup.onIsolationChosen((value) => {
void updatePreferences({ isolation: value });
}),
[setup, updatePreferences],
);
return {
isolation,
setIsolation,
setIsolation: setup.chooseIsolation,
effectiveIsolation: isWorktree ? "worktree" : "local",
canCreateWorktree,
showRefPicker: !supportsMultiplicity || isWorktree,
@@ -330,7 +328,7 @@ async function createMultiplicityWorkspace(input: {
projectId,
worktreeSlug: input.worktreeSlug,
...input.checkoutRequest,
...(input.branchName && input.checkoutRequest?.action === "branch-off"
...(input.branchName && startsNewBranch(input.checkoutRequest)
? { branchName: input.branchName }
: {}),
}
@@ -1018,10 +1016,15 @@ export function NewWorkspaceScreen({
}),
});
const composerState = chatDraft.composerState;
const [setup] = useState(createNewWorkspaceSetup);
const setupState = useNewWorkspaceSetupState(setup);
const { setup, pluginProject, canCreateWorktree, isPending } = usePluginSetup({
serverId: selectedServerId,
project: selectedProject,
supportsMultiplicity: supportsWorkspaceMultiplicity,
pendingAction,
isDraftHandoffActive,
});
const dispatchPickerSelection = setup.dispatchPicker;
const selectedItem = setupState.pickerSelection.selectedItem;
const selectedItem = useNewWorkspaceSetupState(setup).pickerSelection.selectedItem;
const handleForgeChangeRequestDetected = useCallback(() => {
dispatchPickerSelection({ type: "pr-detected" });
@@ -1054,17 +1057,11 @@ export function NewWorkspaceScreen({
cwd: selectedSourceDirectory ?? "",
});
const worktreeSupport = selectedProject
? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId })
: "unsupported";
const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive });
const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } =
useWorkspaceIsolation({
supportsMultiplicity: supportsWorkspaceMultiplicity,
worktreeSupport,
manualIsolation: setupState.manualIsolation,
setManualIsolation: setup.setManualIsolation,
});
const { effectiveIsolation, setIsolation, showRefPicker } = useWorkspaceIsolation({
setup,
supportsMultiplicity: supportsWorkspaceMultiplicity,
canCreateWorktree,
});
const branchSuggestionsQuery = useQuery({
queryKey: [
@@ -1126,31 +1123,21 @@ export function NewWorkspaceScreen({
const displayItem = itemById.get(selectedOptionId);
return displayItem ? pickerItemLabel(displayItem) : "main";
}, [itemById, selectedOptionId]);
const selectPickerItem = useCallback(
(item: PickerItem) => {
const nextAttachments = syncPickerPrAttachment({
attachments: chatDraft.attachments,
item,
});
dispatchPickerSelection({ type: "picker-selected", item });
chatDraft.setAttachments(nextAttachments);
setPickerOpen(false);
},
[chatDraft, dispatchPickerSelection],
const selectPickerItem = setup.chooseBase;
const setDraftAttachments = chatDraft.setAttachments;
useEffect(
() =>
setup.onBaseChosen((item) => {
setDraftAttachments((attachments) => syncPickerPrAttachment({ attachments, item }));
setPickerOpen(false);
}),
[setDraftAttachments, setup],
);
const pluginScreen = usePluginSetup({
setup,
serverId: selectedServerId,
project: selectedProject,
baseItem,
isolation: effectiveIsolation,
canCreateWorktree,
locked: isPending,
chooseIsolation: setIsolation,
chooseBase: selectPickerItem,
});
const pluginScreen = useMemo<NewWorkspaceItemsScreen>(
() => ({ project: pluginProject, isolation: effectiveIsolation, baseItem, locked: isPending }),
[baseItem, effectiveIsolation, isPending, pluginProject],
);
const handleSelectOption = useCallback(
(id: string) => {
@@ -1638,46 +1625,37 @@ export function NewWorkspaceScreen({
);
}
/** Terminal and chat launches edit in separate sessions; the screen follows the mounted one. */
/** Reports the screen's current facts to the setup model, which plugin commands read when they run. */
/**
* The setup model, built with what the screen has this render so a plugin item's mount effect can
* already run commands, and kept current on every commit.
*/
function usePluginSetup(input: {
setup: NewWorkspaceSetup;
serverId: string | null;
serverId: string;
project: Parameters<typeof getHostProjectId>[0] | null;
baseItem: PickerItem | null;
isolation: NewWorkspaceItemsScreen["isolation"];
canCreateWorktree: boolean;
locked: boolean;
chooseIsolation: SetupFacts["chooseIsolation"];
chooseBase: SetupFacts["chooseBase"];
}): NewWorkspaceItemsScreen {
const { setup, serverId, project, baseItem, isolation, locked } = input;
const { canCreateWorktree, chooseIsolation, chooseBase } = input;
supportsMultiplicity: boolean;
pendingAction: Parameters<typeof isNewWorkspacePending>[0]["pendingAction"];
isDraftHandoffActive: boolean;
}) {
const { serverId, project, supportsMultiplicity, pendingAction, isDraftHandoffActive } = input;
const worktreeSupport = project
? getWorktreeSupportForHostProject({ project, serverId })
: "unsupported";
const canCreateWorktree = supportsMultiplicity && worktreeSupport !== "unsupported";
const locked = isNewWorkspacePending({ pendingAction, isDraftHandoffActive });
const supportsMetadata = useHostFeature(serverId, "entityMetadata");
const projectId = project && serverId ? getHostProjectId(project, serverId) : null;
const projectId = project ? getHostProjectId(project, serverId) : null;
const projectName = project?.projectName ?? null;
const screen = useMemo<NewWorkspaceItemsScreen>(
() => ({
project: projectId && projectName ? { id: projectId, name: projectName } : null,
isolation,
baseItem,
locked,
}),
[baseItem, isolation, locked, projectId, projectName],
const pluginProject = useMemo(
() => (projectId && projectName ? { id: projectId, name: projectName } : null),
[projectId, projectName],
);
useEffect(() => {
setup.report({
locked: screen.locked,
project: screen.project,
canCreateWorktree,
supportsMetadata,
chooseIsolation,
chooseBase,
});
}, [canCreateWorktree, chooseBase, chooseIsolation, screen, setup, supportsMetadata]);
return screen;
const inputs = { locked, project: pluginProject, canCreateWorktree, supportsMetadata };
const [setup] = useState(() => createNewWorkspaceSetup(inputs));
useLayoutEffect(() => setup.syncInputs(inputs));
return { setup, pluginProject, canCreateWorktree, isPending: locked };
}
/** Terminal and chat launches edit in separate sessions; the screen follows the mounted one. */
function useNewWorkspaceComposerEditing() {
const terminal = useComposerEditingSession("terminal");
const chat = useComposerEditingSession("chat");
@@ -1,23 +1,25 @@
import { describe, expect, it } from "vitest";
import type { PickerItem } from "../new-workspace-picker-item";
import { createNewWorkspaceSetup, type SetupFacts } from "./setup-model";
import { createNewWorkspaceSetup, type SetupInputs } from "./setup-model";
function readyFacts(overrides: Partial<SetupFacts> = {}): SetupFacts {
return {
locked: false,
project: { id: "p1", name: "repo" },
canCreateWorktree: true,
supportsMetadata: true,
chooseIsolation: () => {},
chooseBase: () => {},
...overrides,
};
}
const READY: SetupInputs = {
locked: false,
project: { id: "p1", name: "repo" },
canCreateWorktree: true,
supportsMetadata: true,
};
describe("New workspace setup commands", () => {
it("accepts commands from the first moment, before the screen has committed", async () => {
const setup = createNewWorkspaceSetup(READY);
await setup.commands.setBranch("pas-101");
expect(setup.getSnapshot().branch).toBe("pas-101");
});
it("applies commands that run back to back without waiting for a render", async () => {
const setup = createNewWorkspaceSetup();
setup.report(readyFacts());
const setup = createNewWorkspaceSetup(READY);
void setup.commands.setBranch("pas-101");
void setup.commands.setMetadata({ issue: "PAS-101", keep: true });
@@ -27,27 +29,35 @@ describe("New workspace setup commands", () => {
expect(setup.getSnapshot().metadata).toEqual({ issue: "PAS-101" });
});
it("passes a base branch and an isolation choice to the picker's own handlers", async () => {
const setup = createNewWorkspaceSetup();
it("changes the model and tells the screen, for a base branch and an isolation choice", async () => {
const setup = createNewWorkspaceSetup(READY);
const chosen: PickerItem[] = [];
const isolations: string[] = [];
setup.report(
readyFacts({
chooseBase: (item) => chosen.push(item),
chooseIsolation: (isolation) => isolations.push(isolation),
}),
);
setup.onBaseChosen((item) => chosen.push(item));
setup.onIsolationChosen((isolation) => isolations.push(isolation));
await setup.commands.setBaseRef({ type: "branch", name: " release " });
await setup.commands.setIsolation("worktree");
expect(chosen).toMatchObject([{ kind: "branch", name: "release", refName: "release" }]);
expect(setup.getSnapshot().pickerSelection.selectedItem).toEqual(chosen[0]);
expect(isolations).toEqual(["worktree"]);
expect(setup.getSnapshot().manualIsolation).toBe("worktree");
});
it("shows a base ref's short name and keeps the exact ref for creation", async () => {
const setup = createNewWorkspaceSetup(READY);
await setup.commands.setBaseRef({ type: "branch", name: "refs/remotes/origin/release" });
expect(setup.getSnapshot().pickerSelection.selectedItem).toMatchObject({
name: "release",
refName: "refs/remotes/origin/release",
});
});
it("rejects every command while the screen is locked, as the pickers do", async () => {
const setup = createNewWorkspaceSetup();
setup.report(readyFacts({ locked: true }));
const setup = createNewWorkspaceSetup({ ...READY, locked: true });
const results = await Promise.allSettled([
setup.commands.setBranch("x"),
@@ -61,20 +71,21 @@ describe("New workspace setup commands", () => {
expect(setup.getSnapshot().metadata).toEqual({});
});
it("reads the facts when a command runs, so a project change after render is seen", async () => {
const setup = createNewWorkspaceSetup();
setup.report(readyFacts());
const pending = setup.commands.setBaseRef({ type: "branch", name: "main" });
setup.report(readyFacts({ project: null }));
await pending;
it("reads the inputs when a command runs, so a project change after render is seen", async () => {
const setup = createNewWorkspaceSetup(READY);
setup.syncInputs({ ...READY, project: null });
await expect(setup.commands.setBaseRef({ type: "branch", name: "main" })).rejects.toThrow(
"Choose a project first.",
);
});
it("rejects what the project or host cannot do", async () => {
const setup = createNewWorkspaceSetup();
setup.report(readyFacts({ canCreateWorktree: false, supportsMetadata: false }));
const setup = createNewWorkspaceSetup({
...READY,
canCreateWorktree: false,
supportsMetadata: false,
});
await expect(setup.commands.setIsolation("worktree")).rejects.toThrow(
"cannot create worktrees",
@@ -1,7 +1,7 @@
import { useSyncExternalStore } from "react";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { applyMetadataPatch } from "@/composer/draft/metadata";
import type { PickerItem } from "../new-workspace-picker-item";
import { branchNameFromRef, type PickerItem } from "../new-workspace-picker-item";
import {
initialPickerSelectionState,
reducePickerSelection,
@@ -22,17 +22,14 @@ interface SetupState {
}
/**
* What the screen derives from its hooks and reports on every render, so a command reads it at the
* moment it runs rather than from the render that built the command.
* What the screen derives from its hooks, given at construction and kept current on every
* commit, so a command reads it at the moment it runs rather than from the render that built it.
*/
export interface SetupFacts {
export interface SetupInputs {
locked: boolean;
project: { id: string; name: string } | null;
canCreateWorktree: boolean;
supportsMetadata: boolean;
/** The picker's own handlers, so a command has the effects a pick has. */
chooseIsolation(isolation: Isolation): void;
chooseBase(item: PickerItem): void;
}
export interface SetupCommands {
@@ -45,9 +42,16 @@ export interface SetupCommands {
export interface NewWorkspaceSetup {
subscribe(listener: () => void): () => void;
getSnapshot(): SetupState;
/** Events other than a pick: a detected change request, a target change. */
dispatchPicker(event: PickerSelectionEvent): void;
setManualIsolation(isolation: Isolation): void;
report(facts: SetupFacts): void;
/** The base picker's entry, and the one `setBaseRef` takes. */
chooseBase(item: PickerItem): void;
/** The isolation picker's entry, and the one `setIsolation` takes. */
chooseIsolation(isolation: Isolation): void;
/** What the screen does beyond changing the model when a pick lands; returns the remover. */
onBaseChosen(listener: (item: PickerItem) => void): () => void;
onIsolationChosen(listener: (isolation: Isolation) => void): () => void;
syncInputs(inputs: SetupInputs): void;
commands: SetupCommands;
}
@@ -55,26 +59,38 @@ const LOCKED_MESSAGE = "The New workspace screen is locked while the workspace i
/**
* The New workspace screen's choices as one plain model. The pickers and the plugin commands
* write to it, so a command sees what an earlier command or pick left, and applies the same rules
* as the picker it stands in for.
* write to it through the same entries, so a command sees what an earlier command or pick left,
* has the effects of a pick, and applies the same rules as the picker it stands in for. It is
* built with its inputs, so a command is valid from the first render.
*/
export function createNewWorkspaceSetup(): NewWorkspaceSetup {
export function createNewWorkspaceSetup(initial: SetupInputs): NewWorkspaceSetup {
let state: SetupState = {
pickerSelection: initialPickerSelectionState,
manualIsolation: null,
branch: null,
metadata: {},
};
let facts: SetupFacts | null = null;
let inputs = initial;
let baseChosen: (item: PickerItem) => void = () => {};
let isolationChosen: (isolation: Isolation) => void = () => {};
const listeners = new Set<() => void>();
const update = (patch: Partial<SetupState>) => {
state = { ...state, ...patch };
for (const listener of listeners) listener();
};
const unlocked = (): SetupFacts => {
if (!facts) throw new Error("The New workspace screen is not ready.");
if (facts.locked) throw new Error(LOCKED_MESSAGE);
return facts;
const unlocked = (): SetupInputs => {
if (inputs.locked) throw new Error(LOCKED_MESSAGE);
return inputs;
};
const dispatchPicker = (event: PickerSelectionEvent) =>
update({ pickerSelection: reducePickerSelection(state.pickerSelection, event) });
const chooseBase = (item: PickerItem) => {
dispatchPicker({ type: "picker-selected", item });
baseChosen(item);
};
const chooseIsolation = (isolation: Isolation) => {
update({ manualIsolation: isolation });
isolationChosen(isolation);
};
return {
subscribe(listener) {
@@ -82,11 +98,23 @@ export function createNewWorkspaceSetup(): NewWorkspaceSetup {
return () => listeners.delete(listener);
},
getSnapshot: () => state,
dispatchPicker: (event) =>
update({ pickerSelection: reducePickerSelection(state.pickerSelection, event) }),
setManualIsolation: (manualIsolation) => update({ manualIsolation }),
report: (next) => {
facts = next;
dispatchPicker,
chooseBase,
chooseIsolation,
onBaseChosen(listener) {
baseChosen = listener;
return () => {
baseChosen = () => {};
};
},
onIsolationChosen(listener) {
isolationChosen = listener;
return () => {
isolationChosen = () => {};
};
},
syncInputs: (next) => {
inputs = next;
},
commands: {
async setIsolation(isolation) {
@@ -94,18 +122,18 @@ export function createNewWorkspaceSetup(): NewWorkspaceSetup {
if (isolation === "worktree" && !current.canCreateWorktree) {
throw new Error("This project cannot create worktrees.");
}
current.chooseIsolation(isolation);
chooseIsolation(isolation);
},
async setBaseRef({ name }) {
const current = unlocked();
const refName = name.trim();
if (!current.project) throw new Error("Choose a project first.");
if (!refName) throw new Error("A base branch needs a name.");
current.chooseBase({
chooseBase({
kind: "branch",
name: refName,
name: branchNameFromRef(refName),
refName,
accessibilityLabel: `${refName}, branch`,
accessibilityLabel: `${branchNameFromRef(refName)}, branch`,
});
},
async setBranch(name) {