feat(plugins): New workspace items set the branch and workspace metadata

addNewWorkspaceItem mounts a plugin field on the New workspace screen, and
useNewWorkspace() exposes host, project, isolation, base ref, branch and
metadata with commands that follow the composer item rules: they act on the
screen's state when they run, resolve once applied, reject while the screen
is locked, and take JSON payloads. Host and project are read-only because a
command that switched them would unmount the item that called it.

The screen's pickable state (base selection, manual isolation, branch,
metadata) moves into one plain store so pickers and plugin commands write to
the same place; the screen reports derived facts to it on each render. The
branch is sent as source.branchName only for a branch-off worktree, and
metadata on workspace.create.

Items render through ComposerControl with a new "field" surface (badge on
desktop, full-width field when compact). Combobox ships here: Paseo owns the
loading and error states, keeps previous results, and drops superseded
searches.
This commit is contained in:
Mohamed Boudra
2026-11-04 07:25:21 +01:00
parent d5e022e363
commit 14fc9b02f8
55 changed files with 1517 additions and 106 deletions
+71
View File
@@ -1984,6 +1984,77 @@ control.
The [composer controls example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)
registers a `Select`, two `Toggle`s (one writes a provider option) and an `Action`.
## New workspace items
`client.addNewWorkspaceItem({ id, Component })` puts a field on the New workspace screen, beside
the project, host and branch pickers. `useNewWorkspace()` reads and changes what the screen will
create, and `Combobox`, `Select`, `Toggle` and `Action` from `@getpaseo/plugin/client/ui` draw the
fields. On desktop an item is a badge in the setup row; on a phone it is a full-width field.
```tsx
import { useNewWorkspace, useRpc, type PluginClientContext } from "@getpaseo/plugin/client";
import { Combobox } from "@getpaseo/plugin/client/ui";
import { searchIssuesRpc } from "../shared/issues";
function IssueField() {
const newWorkspace = useNewWorkspace();
const searchIssues = useRpc(searchIssuesRpc);
const issueId = newWorkspace.metadata["issues.id"];
return (
<Combobox
title="Issue"
icon="CircleDot"
value={typeof issueId === "string" ? { id: issueId, label: issueId } : null}
search={async ({ query }) => (await searchIssues({ query })).issues}
onSelect={async ({ item }) => {
await newWorkspace.setBranch(item.branch);
await newWorkspace.setMetadata({ "issues.id": item.id });
}}
/>
);
}
export default function contribute(client: PluginClientContext) {
client.addNewWorkspaceItem({ id: "issue", Component: IssueField });
return () => {};
}
```
`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." |
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
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.
`Combobox` is a field with a searchable list whose results come from your code:
| Prop | Meaning |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `value` | `{ id, label, description? } \| null`, what the field shows. It need not be among the search results, so it can show a choice made earlier. |
| `search` | `({ query }) => Promise<Item[]>`. Called with `""` when the list opens and again, debounced, as the user types. Each `Item` is `{ id, label, description? }` plus any fields you add. |
| `onSelect` | `({ item }) => void \| Promise<void>`. `item` is the object `search` returned. |
Paseo shows "Searching…" while a search runs, "Search failed" with the error message when it
rejects, and an empty state for no results. It keeps the previous results while a newer search runs
and ignores a search that a newer one replaced, so `search` needs no cancellation of its own.
`Combobox` takes the same `title`, `icon` and `disabled` as the other controls, and draws as a row
with a list in the composer's overflow sheet too.
The [New workspace issues example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/new-workspace-issues)
searches a fixed issue list from a daemon handler and sets the branch and metadata from the choice.
## Composer pills
`client.addComposerPill({ id, workspaceId, agentId, button })` uses the same [button descriptor](#button-descriptor)