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` 是带可搜索列表的字段,结果由你的代码提供: