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
@@ -18,3 +18,46 @@ for (const layout of [
});
});
}
test("a branch chosen while Local applies once the user switches to a new worktree", async ({
page,
}) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(page, { compact: false }, async (screen) => {
await screen.chooseIssue("PAS-101 Slow sidebar");
await screen.chooseWorktree();
const created = await screen.createWorkspace({ prompt: "" });
expect(created.branch).toBe("pas-101-slow-sidebar");
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "PAS-101" });
});
});
test("a plugin's branch applies when the project has no default base to branch off", async ({
page,
}) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(
page,
{ compact: false, detachedHead: true },
async (screen) => {
await screen.chooseWorktree();
await screen.chooseIssue("PAS-101 Slow sidebar");
const created = await screen.createWorkspace();
expect(created.branch).toBe("pas-101-slow-sidebar");
},
);
});
test("an item can set the branch and metadata from its mount effect", async ({ page }) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(
page,
{ compact: false, setsOnMount: true },
async (screen) => {
await screen.chooseWorktree();
const created = await screen.createWorkspace();
expect(created.branch).toBe("set-on-mount");
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "ON-MOUNT" });
},
);
});
@@ -1,4 +1,7 @@
import { expect, test, type Page } from "@playwright/test";
import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import {
archiveWorkspaceFromDaemon,
@@ -13,25 +16,72 @@ import { getServerId } from "./server-id";
import { createTempGitRepo } from "./workspace";
import { waitForSidebarHydration } from "./workspace-ui";
/** Presses Create, typing the prompt first unless the test wants an empty one. */
async function submitCreate(page: Page, prompt: string | undefined) {
if (prompt !== "") return submitNewWorkspacePrompt(page, prompt);
await page.getByTestId("message-input-root").getByRole("button", { name: "Create" }).click();
}
/** An item that sets the branch and metadata from its mount effect, before the user touches anything. */
async function replaceWithMountEffectItem(directory: string) {
await writeFile(
path.join(directory, "index.client.tsx"),
`import { useEffect } from "react";
import { useNewWorkspace } from "@getpaseo/plugin/client";
import { Action } from "@getpaseo/plugin/client/ui";
function OnMount() {
const newWorkspace = useNewWorkspace();
useEffect(() => {
void newWorkspace.setBranch("set-on-mount");
void newWorkspace.setMetadata({ "new-workspace-issues.issue": "ON-MOUNT" });
}, []);
return <Action title="Mount" icon="Flag" onPress={() => {}} />;
}
export default function contribute(client) {
client.addNewWorkspaceItem({ id: "on-mount", Component: OnMount });
return () => {};
}
`,
);
}
type Client = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
/** The daemon fills in the git runtime after it creates the workspace, so wait for the branch. */
async function readCreatedWorkspace(client: Client, workspaceId: string) {
const deadline = Date.now() + 30_000;
for (;;) {
const { entries } = await client.fetchWorkspaces();
const workspace = entries.find((entry) => entry.id === workspaceId);
const branch = workspace?.gitRuntime?.currentBranch ?? null;
if (workspace && branch) return { branch, metadata: workspace.metadata };
if (Date.now() > deadline) throw new Error(`Workspace ${workspaceId} never reported a branch`);
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
const PLUGIN_ID = "new-workspace-issues-example";
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 },
options: { compact: boolean; detachedHead?: boolean; setsOnMount?: boolean },
run: (screen: {
chooseWorktree(): Promise<void>;
chooseIssue(label: string): Promise<void>;
createWorkspace(): Promise<{ branch: string | null; metadata: unknown }>;
createWorkspace(options?: { prompt?: string }): Promise<{ branch: string; metadata: unknown }>;
}) => Promise<void>,
) {
const client = await connectNewWorkspaceDaemonClient();
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("new-workspace-issues");
if (options.setsOnMount) await replaceWithMountEffectItem(example.directory);
const repo = await createTempGitRepo("new-workspace-issues-", { branches: ["main"] });
let createdDirectory: string | null = null;
try {
// A detached HEAD leaves the screen with no default base to branch off.
if (options.detachedHead) execFileSync("git", ["checkout", "--detach"], { cwd: repo.path });
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
const project = await openProjectViaDaemon(client, repo.path);
@@ -51,9 +101,9 @@ export async function withNewWorkspaceIssuesExample(
await page.getByRole("button", { name: "Issue", exact: true }).click();
await page.getByText(label, { exact: true }).click();
}),
createWorkspace: () =>
createWorkspace: (createOptions) =>
test.step("create the workspace and read what it carries", async () => {
await submitNewWorkspacePrompt(page);
await submitCreate(page, createOptions?.prompt);
const created = await assertNewWorkspaceSidebarAndHeader(page, {
serverId: getServerId(),
client,
@@ -63,13 +113,7 @@ export async function withNewWorkspaceIssuesExample(
assertSidebarRow: !options.compact,
});
createdDirectory = created.workspaceDirectory;
const { entries } = await client.fetchWorkspaces();
const workspace = entries.find((entry) => entry.id === created.workspaceId);
expect(workspace).toBeDefined();
return {
branch: workspace?.gitRuntime?.currentBranch ?? null,
metadata: workspace?.metadata,
};
return readCreatedWorkspace(client, created.workspaceId);
}),
});
} finally {
@@ -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) {
+13 -4
View File
@@ -12,22 +12,31 @@ export interface PluginNewWorkspace {
readonly project: { readonly id: string; readonly name: string } | null;
/** What the screen will create. A project that cannot make worktrees reads `"local"`. */
readonly isolation: "local" | "worktree";
/** What a new worktree starts from; `null` until the project's default is known. */
/**
* What a new worktree starts from; `null` until the project's default is known. A branch's
* `name` is the exact ref creation uses, such as `refs/remotes/origin/main`, so
* `setBaseRef(newWorkspace.baseRef)` changes nothing.
*/
readonly baseRef:
| { readonly type: "branch"; readonly name: string }
| { readonly type: "change-request"; readonly number: number }
| null;
/**
* The name of the branch a new worktree is created on; `null` lets Paseo pick one. It is kept
* while isolation is local or the base is a change request, and applies only to a worktree
* created from a branch.
* while isolation is local or the base is a change request. It names the new branch of any
* worktree that cuts one, from the default base or a chosen branch, and does not apply to a
* worktree that checks out an existing branch or change request.
*/
readonly branch: string | null;
/** The workspace's metadata, not the first agent's. */
readonly metadata: Readonly<Record<string, JsonValue>>;
/** Rejects `"worktree"` for a project that cannot make worktrees. */
setIsolation(isolation: "local" | "worktree"): Promise<void>;
/** Starts the workspace from a branch. Rejects while no project is chosen. */
/**
* Starts the workspace from a branch. `name` is any ref the Base picker offers, such as
* `refs/remotes/origin/main`, or a bare branch name, which Paseo resolves the way it does for
* a branch name it is given anywhere else. Rejects while no project is chosen.
*/
setBaseRef(ref: { type: "branch"; name: string }): Promise<void>;
setBranch(name: string | null): Promise<void>;
/** Top-level keys replace; `null` removes the key. Rejects on a host without metadata support. */