diff --git a/docs/plugins.md b/docs/plugins.md index 0b8b5ed796..d3609d7bfd 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -717,6 +717,13 @@ remembering the isolation), so a command from a mount effect does the same as a picker stays in the picker's own handler. Add a capability by extending the store; do not read screen state from a plugin component. +The item host (`PluginNewWorkspaceItems`) keys the items by `(serverId, project id)`, so a change of +project or host remounts them. That is what keeps an item's own state and a `Combobox`'s cached +results from outliving their project: the `Combobox` cannot tell that an author's inline `search` +now means another project, because its identity changes every render. The store clears `branch` +and `metadata` on the same change in `syncInputs`, and a remounted item's mount effect sets them +again. + `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 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 cbec454d95..485375b352 100644 --- a/packages/app/e2e/browser/plugin-new-workspace-items.spec.ts +++ b/packages/app/e2e/browser/plugin-new-workspace-items.spec.ts @@ -64,6 +64,24 @@ test("a branch and metadata chosen for one project are not sent for another", as ); }); +test("an item's Issue list does not keep the previous project's results after a project switch", async ({ + page, +}) => { + test.setTimeout(180_000); + await withNewWorkspaceIssuesExample( + page, + { compact: false, otherProject: true, searchesByProject: true }, + async (screen) => { + await screen.openIssueList(); + await screen.expectIssueListText(`Issue of ${screen.firstProjectName}`, true); + await screen.closeIssueList(); + await screen.switchToOtherProject(); + await screen.openIssueList(); + await screen.expectIssueListText(`Issue of ${screen.firstProjectName}`, false); + }, + ); +}); + test("an isolation set from a mount effect is remembered like a pick", async ({ page }) => { test.setTimeout(180_000); await withNewWorkspaceIssuesExample( 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 12629fb89f..ddcdd6d08b 100644 --- a/packages/app/e2e/support/helpers/plugin-new-workspace-items.ts +++ b/packages/app/e2e/support/helpers/plugin-new-workspace-items.ts @@ -47,6 +47,41 @@ export default function contribute(client) { ); } +/** + * An item whose search depends on the project: it answers with the project's name, and never + * answers for the project named "other", so a list that still shows the first project's answer + * there is stale. + */ +async function replaceWithProjectSearchItem(directory: string) { + await writeFile( + path.join(directory, "index.client.tsx"), + `import { useNewWorkspace } from "@getpaseo/plugin/client"; +import { Combobox } from "@getpaseo/plugin/client/ui"; +function ProjectIssues() { + const { project } = useNewWorkspace(); + const name = project?.name ?? ""; + return ( + + name.includes("other") + ? new Promise(() => {}) + : Promise.resolve([{ id: name, label: "Issue of " + name }]) + } + onSelect={() => {}} + /> + ); +} +export default function contribute(client) { + client.addNewWorkspaceItem({ id: "project-issues", Component: ProjectIssues }); + return () => {}; +} +`, + ); +} + type Client = Awaited>; /** The daemon fills in the git runtime after it creates the workspace, so wait for the branch. */ @@ -72,6 +107,8 @@ export async function withNewWorkspaceIssuesExample( compact: boolean; detachedHead?: boolean; setsOnMount?: boolean; + /** Installs an item whose Issue list depends on the project instead of the example's. */ + searchesByProject?: boolean; /** A second project the screen can switch to. */ otherProject?: boolean; }, @@ -80,6 +117,12 @@ export async function withNewWorkspaceIssuesExample( chooseIssue(label: string): Promise; createWorkspace(options?: { prompt?: string }): Promise<{ branch: string; metadata: unknown }>; switchToOtherProject(): Promise; + openIssueList(): Promise; + closeIssueList(): Promise; + /** The Issue list shows `text`, or does not show it when `shown` is false. */ + expectIssueListText(text: string, shown: boolean): Promise; + /** The name the first project's items read as `project.name`. */ + firstProjectName: string; expectIsolation(isolation: "local" | "worktree"): Promise; /** Removes the plugin, leaves the screen, and opens New workspace for the project again. */ reopenWithoutPlugin(): Promise; @@ -88,6 +131,7 @@ export async function withNewWorkspaceIssuesExample( const client = await connectNewWorkspaceDaemonClient(); const previous = await client.getDaemonConfig(); const example = await copyPluginExample("new-workspace-issues"); + if (options.searchesByProject) await replaceWithProjectSearchItem(example.directory); if (options.setsOnMount) await replaceWithMountEffectItem(example.directory); const repo = await createTempGitRepo("new-workspace-issues-", { branches: ["main"] }); const otherRepo = options.otherProject @@ -118,6 +162,17 @@ export async function withNewWorkspaceIssuesExample( await page.getByRole("button", { name: "Issue", exact: true }).click(); await page.getByText(label, { exact: true }).click(); }), + firstProjectName: project.projectDisplayName, + openIssueList: () => + test.step("open the Issue list", async () => { + await page.getByRole("button", { name: "Issue", exact: true }).click(); + }), + closeIssueList: () => test.step("close the Issue list", () => page.keyboard.press("Escape")), + expectIssueListText: (text, shown) => + test.step(`the Issue list ${shown ? "shows" : "does not show"} "${text}"`, async () => { + const list = page.getByTestId("combobox-desktop-container").last(); + await expect(list.getByText(text, { exact: true })).toHaveCount(shown ? 1 : 0); + }), switchToOtherProject: () => test.step("switch to the other project", async () => { if (!other) throw new Error("Open the example with otherProject to switch projects"); diff --git a/packages/app/src/plugins/new-workspace-items/index.tsx b/packages/app/src/plugins/new-workspace-items/index.tsx index 3522fee987..41f27d2182 100644 --- a/packages/app/src/plugins/new-workspace-items/index.tsx +++ b/packages/app/src/plugins/new-workspace-items/index.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { Fragment, useMemo } from "react"; import type { PluginHostProps } from "@getpaseo/plugin/client"; import { PluginNewWorkspaceProvider } from "@getpaseo/plugin/client/host"; import { withUnistyles } from "react-native-unistyles"; @@ -38,7 +38,9 @@ function useServerPlugins(serverId: string): InstalledPlugin[] { /** * The selected host's plugin items on the New workspace screen, drawn as setup fields. They read - * the screen's choices and command them through the setup model. + * the screen's choices and command them through the setup model. Items are mounted for one + * project on one host: a change of either remounts them, so nothing an item fetched or chose for + * the previous target stays on screen, and an item that sets up on mount sets up for the new one. */ export function PluginNewWorkspaceItems({ serverId, @@ -53,15 +55,17 @@ export function PluginNewWorkspaceItems({ return ( - {plugins.map((plugin) => ( - - ))} + + {plugins.map((plugin) => ( + + ))} + ); diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 193ad41434..5e51831b3b 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -2033,6 +2033,7 @@ export default function contribute(client: PluginClientContext) { 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. A command works from your component's first effect, including a mount effect. Payloads are JSON values. +Your items are mounted for one project on one host. When the user changes either, Paseo remounts them and clears `branch` and `metadata`, so state and `Combobox` results for the previous project do not carry over; an item that sets up in a mount effect sets up again for the new project. `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 diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index c2e4d2b76f..941ba67cb5 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -1708,7 +1708,7 @@ export default function contribute(client: PluginClientContext) { | `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 在更改生效后完成。命令从组件的第一个 effect 起即可使用,包括挂载时的 effect。载荷是 JSON 值。`host`、`project`、`baseRef` 和 `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 值。你的项针对某一台主机上的某一个项目挂载。用户更改其中任何一个时,Paseo 会重新挂载你的项并清除 `branch` 和 `metadata`,因此上一个项目的状态和 `Combobox` 结果不会保留;在挂载 effect 中做初始化的项会针对新项目再次初始化。`host`、`project`、`baseRef` 和 `metadata` 是冻结的。界面创建工作区期间会被锁定:你的字段显示为禁用,所有命令都会以 "The New workspace screen is locked while the workspace is being created." 拒绝。返回 Promise 的回调在完成之前会让控件保持禁用,被拒绝时会以错误提示向用户显示。 `Combobox` 是带可搜索列表的字段,结果由你的代码提供: