From 9674fc4d4c2ae84c6debbd9e8f8e03997a34f3e4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 09:09:37 +0100 Subject: [PATCH] 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. --- docs/plugins.md | 23 +-- .../plugin-new-workspace-items.spec.ts | 43 +++++ .../helpers/plugin-new-workspace-items.ts | 68 ++++++-- .../plugins/new-workspace-items/model.test.ts | 61 +++++++ .../src/plugins/new-workspace-items/model.ts | 9 +- .../screens/new-workspace-picker-item.test.ts | 18 +++ .../src/screens/new-workspace-picker-item.ts | 11 ++ .../app/src/screens/new-workspace-screen.tsx | 150 ++++++++---------- .../screens/new-workspace/setup-model.test.ts | 75 +++++---- .../src/screens/new-workspace/setup-model.ts | 80 +++++++--- packages/plugin/src/client/new-workspace.tsx | 17 +- public-docs/plugins/reference.md | 18 +-- public-docs/plugins/reference.zh-CN.md | 16 +- 13 files changed, 399 insertions(+), 190 deletions(-) create mode 100644 packages/app/src/plugins/new-workspace-items/model.test.ts diff --git a/docs/plugins.md b/docs/plugins.md index 847cbc3009..5ab180fbae 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -685,16 +685,21 @@ the plugin commands write to it, so a command sees what an earlier command left. stay in the screen's hooks (`useNewWorkspaceHostSelector` reconciles them against each other), and they are read-only to plugins; a command that switched them would unmount the item that called it. -The screen reports what it derives from those hooks (the lock, the project, whether the project -can make worktrees, whether the host supports metadata, and the pickers' own handlers) to the store -with `setup.report` on every render. Commands read it when they run, never from the render that -built them. `chooseIsolation` and `chooseBase` are the pickers' handlers, so a plugin command has the -effects of a pick. Add a capability by extending the store and its facts; do not read screen state -from a plugin component. +The store is built in the render that creates the screen, with what the screen derives from its +hooks (the lock, the project, whether the project can make worktrees, whether the host supports +metadata), so a command is valid from a child's first effect, which runs before the screen's own +effects. `syncInputs` refreshes those inputs in a layout effect on every commit, and commands read +them when they run, never from the render that built them. The pickers and the commands pick +through the same `chooseBase` and `chooseIsolation`, so a plugin command has the effects of a +pick; the screen supplies those effects (syncing the picker's PR attachment, remembering the +isolation) with `onBaseChosen` and `onIsolationChosen`. Add a capability by extending the store; +do not read screen state from a plugin component. -`ensureWorkspace` sends the store's `branch` as `source.branchName` only for a branch-off worktree -(`checkoutRequest.action === "branch-off"`), and its `metadata` on `workspace.create` when it is -not empty. The server uses `branchName ?? worktreeSlug` as the new branch. +`ensureWorkspace` sends the store's `branch` as `source.branchName` whenever the worktree cuts a +new branch (`startsNewBranch`, which mirrors the daemon: anything but a checkout, including no base +at all on a detached HEAD), and its `metadata` on `workspace.create` when it is not empty. The +server uses `branchName ?? worktreeSlug` as the new branch. `baseRef.name` is the exact ref +creation uses; `branchNameFromRef` is for labels only. Items render through `ComposerControl` with `surface="field"`: a `FieldTrigger` (a badge on desktop, a full-width field when compact) and the same pending state, error toast and list as the 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 3460464fb3..ec7158164c 100644 --- a/packages/app/e2e/browser/plugin-new-workspace-items.spec.ts +++ b/packages/app/e2e/browser/plugin-new-workspace-items.spec.ts @@ -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" }); + }, + ); +}); 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 8655ded6e5..b90ad5a8ec 100644 --- a/packages/app/e2e/support/helpers/plugin-new-workspace-items.ts +++ b/packages/app/e2e/support/helpers/plugin-new-workspace-items.ts @@ -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 {}} />; +} +export default function contribute(client) { + client.addNewWorkspaceItem({ id: "on-mount", Component: OnMount }); + return () => {}; +} +`, + ); +} + +type Client = Awaited>; + +/** 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; chooseIssue(label: string): Promise; - createWorkspace(): Promise<{ branch: string | null; metadata: unknown }>; + createWorkspace(options?: { prompt?: string }): Promise<{ branch: string; metadata: unknown }>; }) => Promise, ) { 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 { diff --git a/packages/app/src/plugins/new-workspace-items/model.test.ts b/packages/app/src/plugins/new-workspace-items/model.test.ts new file mode 100644 index 0000000000..8cd450d5d8 --- /dev/null +++ b/packages/app/src/plugins/new-workspace-items/model.test.ts @@ -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[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), + ); + }); +}); diff --git a/packages/app/src/plugins/new-workspace-items/model.ts b/packages/app/src/plugins/new-workspace-items/model.ts index ce8cba4c38..7e61c27be5 100644 --- a/packages/app/src/plugins/new-workspace-items/model.ts +++ b/packages/app/src/plugins/new-workspace-items/model.ts @@ -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, diff --git a/packages/app/src/screens/new-workspace-picker-item.test.ts b/packages/app/src/screens/new-workspace-picker-item.test.ts index a6c1e81845..fd1e97dd11 100644 --- a/packages/app/src/screens/new-workspace-picker-item.test.ts +++ b/packages/app/src/screens/new-workspace-picker-item.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/screens/new-workspace-picker-item.ts b/packages/app/src/screens/new-workspace-picker-item.ts index be35e13309..897f99a561 100644 --- a/packages/app/src/screens/new-workspace-picker-item.ts +++ b/packages/app/src/screens/new-workspace-picker-item.ts @@ -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}`; } diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index f2f50ad4e4..0b4d3d101d 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -5,7 +5,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 { router } from "expo-router"; @@ -91,9 +91,9 @@ import { defaultBasePickerItem, pickerItemLabel, pickerItemToCheckoutRequest, + startsNewBranch, type BranchPickerDetail, type PickerCheckoutRequest, - type PickerItem, type PickerOptionData, } from "./new-workspace-picker-item"; import { @@ -108,7 +108,6 @@ import { createNewWorkspaceSetup, useNewWorkspaceSetupState, type NewWorkspaceSetup, - type SetupFacts, } from "./new-workspace/setup-model"; import { resolveNewWorkspaceAutomaticServerId, @@ -201,31 +200,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, @@ -331,7 +329,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 } : {}), } @@ -1019,10 +1017,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" }); @@ -1055,17 +1058,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: [ @@ -1127,31 +1124,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( + () => ({ project: pluginProject, isolation: effectiveIsolation, baseItem, locked: isPending }), + [baseItem, effectiveIsolation, isPending, pluginProject], + ); const handleSelectOption = useCallback( (id: string) => { @@ -1641,46 +1628,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[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[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( - () => ({ - 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"); 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 65bd44a4cc..83ba9cb8ac 100644 --- a/packages/app/src/screens/new-workspace/setup-model.test.ts +++ b/packages/app/src/screens/new-workspace/setup-model.test.ts @@ -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 { - 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", diff --git a/packages/app/src/screens/new-workspace/setup-model.ts b/packages/app/src/screens/new-workspace/setup-model.ts index 4bc3c94c51..2beedfaeca 100644 --- a/packages/app/src/screens/new-workspace/setup-model.ts +++ b/packages/app/src/screens/new-workspace/setup-model.ts @@ -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) => { 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) { diff --git a/packages/plugin/src/client/new-workspace.tsx b/packages/plugin/src/client/new-workspace.tsx index ca144fc4c5..5da7a2a5be 100644 --- a/packages/plugin/src/client/new-workspace.tsx +++ b/packages/plugin/src/client/new-workspace.tsx @@ -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>; /** Rejects `"worktree"` for a project that cannot make worktrees. */ setIsolation(isolation: "local" | "worktree"): Promise; - /** 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; setBranch(name: string | null): Promise; /** Top-level keys replace; `null` removes the key. Rejects on a host without metadata support. */ diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 4d21997054..178da98191 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -2019,18 +2019,18 @@ export default function contribute(client: PluginClientContext) { `useNewWorkspace()` returns the setup the item sits in: -| Field or command | Meaning | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `host`, `project` | The user's choices, `{ id, label }` and `{ id, name } \| null`. Read-only: an item cannot switch host or project. | -| `isolation`, `setIsolation` | `"local"` or `"worktree"`. A project that cannot make worktrees reads `"local"`, and `setIsolation("worktree")` rejects. | -| `baseRef`, `setBaseRef` | What a new worktree starts from: `{ type: "branch", name }`, `{ type: "change-request", number }`, or `null` until the default is known. `setBaseRef({ type: "branch", name })` rejects while no project is chosen. | -| `branch`, `setBranch` | 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 it applies only to a worktree created from a branch. | -| `metadata`, `setMetadata` | The created workspace's [metadata](/docs/plugins/hooks#metadata), not the first agent's. Top-level keys replace; `null` removes a key. Rejects on a host that does not support metadata with "Update the host to use metadata." | +| Field or command | Meaning | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `host`, `project` | The user's choices, `{ id, label }` and `{ id, name } \| null`. Read-only: an item cannot switch host or project. | +| `isolation`, `setIsolation` | `"local"` or `"worktree"`. A project that cannot make worktrees reads `"local"`, and `setIsolation("worktree")` rejects. | +| `baseRef`, `setBaseRef` | What a new worktree starts from: `{ type: "branch", name }`, `{ type: "change-request", number }`, or `null` until the 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. `setBaseRef` takes any ref the Base picker offers, or a bare branch name, which Paseo resolves as it does anywhere else. It rejects while no project is chosen. | +| `branch`, `setBranch` | 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 it names the new branch of any worktree that cuts one, from the default base or a chosen branch. It does not apply to a worktree that checks out an existing branch or change request. | +| `metadata`, `setMetadata` | The created workspace's [metadata](/docs/plugins/hooks#metadata), not the first agent's. Top-level keys replace; `null` removes a key. Rejects on a host that does not support metadata with "Update the host to use metadata." | Commands follow the [composer item](#composer-items) rules: each one acts on the screen's state when it runs, so `await newWorkspace.setBranch(name)` followed by `newWorkspace.setMetadata(patch)` -changes both, and its promise resolves once the change has taken effect. Payloads are JSON values. -`metadata` is a frozen copy. While the screen creates the workspace it is locked: your fields render +changes both, and its promise resolves once the change has taken effect. A command works from your component's first effect, including a mount effect. Payloads are JSON values. +`host`, `project`, `baseRef` and `metadata` are frozen. While the screen creates the workspace it is locked: your fields render disabled and every command rejects with "The New workspace screen is locked while the workspace is being created." A callback that returns a promise keeps the control disabled until it settles, and a rejection reaches the user as an error toast. diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index a5b2b0a29e..230757a0e2 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -1700,15 +1700,15 @@ export default function contribute(client: PluginClientContext) { `useNewWorkspace()` 返回该项所在的设置: -| 字段或命令 | 含义 | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `host`、`project` | 用户的选择,分别是 `{ id, label }` 和 `{ id, name } \| null`。只读:项不能切换主机或项目。 | -| `isolation`、`setIsolation` | `"local"` 或 `"worktree"`。无法创建 worktree 的项目读取为 `"local"`,此时 `setIsolation("worktree")` 会被拒绝。 | -| `baseRef`、`setBaseRef` | 新 worktree 的起点:`{ type: "branch", name }`、`{ type: "change-request", number }`,默认值未知时为 `null`。未选择项目时,`setBaseRef({ type: "branch", name })` 会被拒绝。 | -| `branch`、`setBranch` | 新 worktree 所创建的分支名称。`null` 表示由 Paseo 选择名称。隔离为本地或起点为变更请求时会保留该值,它只对从分支创建的 worktree 生效。 | -| `metadata`、`setMetadata` | 所创建工作区的[元数据](/docs/plugins/hooks#metadata),而不是第一个 Agent 的元数据。顶层键替换,`null` 删除该键。主机不支持元数据时以 “Update the host to use metadata.” 拒绝。 | +| 字段或命令 | 含义 | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `host`、`project` | 用户的选择,分别是 `{ id, label }` 和 `{ id, name } \| null`。只读:项不能切换主机或项目。 | +| `isolation`、`setIsolation` | `"local"` 或 `"worktree"`。无法创建 worktree 的项目读取为 `"local"`,此时 `setIsolation("worktree")` 会被拒绝。 | +| `baseRef`、`setBaseRef` | 新 worktree 的起点:`{ type: "branch", name }`、`{ type: "change-request", number }`,默认值未知时为 `null`。分支的 `name` 是创建时实际使用的完整 ref,例如 `refs/remotes/origin/main`,所以 `setBaseRef(newWorkspace.baseRef)` 不会改变任何内容。`setBaseRef` 接受 Base 选择器提供的任何 ref,或一个裸分支名,Paseo 会按其他地方一贯的方式解析。未选择项目时会被拒绝。 | +| `branch`、`setBranch` | 新 worktree 所创建的分支名称。`null` 表示由 Paseo 选择名称。隔离为本地或起点为变更请求时会保留该值,它为任何新建分支的 worktree 命名,起点可以是默认基线或所选分支;检出已有分支或变更请求的 worktree 不适用。 | +| `metadata`、`setMetadata` | 所创建工作区的[元数据](/docs/plugins/hooks#metadata),而不是第一个 Agent 的元数据。顶层键替换,`null` 删除该键。主机不支持元数据时以 “Update the host to use metadata.” 拒绝。 | -命令遵循 [Composer 项](#composer-items)的规则:每个命令都按界面运行时的状态执行,所以 `await newWorkspace.setBranch(name)` 之后再调用 `newWorkspace.setMetadata(patch)` 会同时生效,命令的 Promise 在更改生效后完成。载荷是 JSON 值。`metadata` 是冻结的副本。界面创建工作区期间会被锁定:你的字段显示为禁用,所有命令都会以 "The New workspace screen is locked while the workspace is being created." 拒绝。返回 Promise 的回调在完成之前会让控件保持禁用,被拒绝时会以错误提示向用户显示。 +命令遵循 [Composer 项](#composer-items)的规则:每个命令都按界面运行时的状态执行,所以 `await newWorkspace.setBranch(name)` 之后再调用 `newWorkspace.setMetadata(patch)` 会同时生效,命令的 Promise 在更改生效后完成。命令从组件的第一个 effect 起即可使用,包括挂载时的 effect。载荷是 JSON 值。`host`、`project`、`baseRef` 和 `metadata` 是冻结的。界面创建工作区期间会被锁定:你的字段显示为禁用,所有命令都会以 "The New workspace screen is locked while the workspace is being created." 拒绝。返回 Promise 的回调在完成之前会让控件保持禁用,被拒绝时会以错误提示向用户显示。 `Combobox` 是带可搜索列表的字段,结果由你的代码提供: