Compare commits

...
Author SHA1 Message Date
Mohamed Boudra baaaaa363d fix(plugins): a host switch drops the branch and metadata even when the project id repeats
A remote repository keeps the same project id on every host, so comparing the project id alone
missed a switch from host A to host B for the same repository. The target is now the host and the
project id together.
2026-11-02 09:53:39 +01:00
Mohamed Boudra 16b843949e test(plugins): the project-switch spec expects the other project's metadata to be empty 2026-11-02 09:50:47 +01:00
Mohamed Boudra 4b0d5f5e4c fix(plugins): a branch and metadata chosen for one project are not sent for another
The setup model outlives the project and host it was chosen for, so an issue picked for project A
was sent on workspace.create after the user switched to project B. The model now drops the branch
and metadata when the project changes (project ids are host-scoped, so a host switch counts). A
project that is still resolving is not a switch. The base selection is left alone: the built-in
picker keeps a branch pick across a project switch, and that behavior is unchanged.
2026-11-02 09:47:49 +01:00
Mohamed Boudra 4706165690 fix(plugins): a Combobox shows a failed refresh instead of the old results
The query keeps its cached data when a reopened list fails to refresh, so the old items stayed
selectable and the error never showed. A failed search now clears the list so its error shows.
The migration guide maps addNewWorkspaceItem, which its registration-coverage test requires, and
the superseded-search test waits on a later search instead of sleeping.
2026-11-02 09:41:47 +01:00
Mohamed Boudra 67a358128b fix(plugins): New workspace commands have the same effects as a pick, even from a mount effect
The screen attached the pick side effects (remembering the isolation, syncing the picker's PR
attachment) as listeners in its own useEffect, which runs after a child item's mount effect. A
mount-time setIsolation("worktree") switched the screen but was not saved, and setBaseRef skipped
the attachment sync. The effects are now injected when the setup model is built, so a command and
a pick run the same code from the first render. Closing the picker stays in the picker's handler.
2026-11-02 09:25:14 +01:00
Mohamed Boudra 9674fc4d4c 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.
2026-11-02 09:09:37 +01:00
Mohamed Boudra 0d89c51c36 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.
2026-11-02 08:47:57 +01:00
Mohamed Boudra a5fa8a5d83 fix(plugins): a built-in select in the compact sheet keeps its action name
The shared control named every icon-only select by its value ("Speed: Fast"),
including in the compact sheet, where the row already shows the value. The sheet
row is named by its action again ("Select speed"), as before the shared renderer.
2026-11-02 07:53:25 +01:00
Mohamed Boudra ca178b6578 fix(plugins): attachment commands honor the composer lock
The creation request captures the attachments when it starts, so an attachment a plugin added or
removed afterwards was lost or left in the request. They refuse like the prompt commands.
2026-11-02 07:20:56 +01:00
Mohamed Boudra b965a3db7c fix(plugins): a kept command honors the composer lock, and useAgent metadata is frozen
A plugin action that awaits an RPC holds the composer it was created with. The lock was captured
at that render, so a submit that started meanwhile did not stop its insertText. Commands now ask
the composer whether it is locked when they run.

The agent snapshot a plugin gets from useAgent froze only the outer metadata record. It now uses
the same deep frozen copy as the composer.
2026-11-02 07:14:20 +01:00
Mohamed Boudra c1f64d45e1 fix(plugins): draft metadata survives a remount, plugin commands follow the composer lock
Draft metadata was component state while the prompt lived in the draft store, so a composer that
remounted across the compact breakpoint kept its text and lost the plugin's choices. It now lives
in the draft store beside the prompt, is cleared when the draft is sent, and is not cleared when
the prompt is emptied. It is not persisted across app restarts.

While the New workspace composer is creating the agent, the built-in controls are disabled but
plugin controls and commands were not. They read the composer's own lock: Paseo controls render
disabled, and setPrompt, insertText and the agent commands refuse.

The options and metadata a plugin reads are frozen copies, through the same helper the timeline
projection uses. The composer control test mounted components in JSDOM, which the testing rules
ban; it now runs in the browser project.
2026-11-02 06:56:13 +01:00
Mohamed Boudra aa558ab845 fix(plugins): composer option resets and model checks match the documented commands
A draft kept `null` as an option value, so `setOptions({ key: null })` sent the null to the
provider and creation failed. A null edit now removes the key from every draft layer, as the patch
does on a running agent. A running agent's `setModel` accepted ids its catalog does not list; it
now rejects them the way a draft does.

The composer controls imported the toast host through the toast context, which pulled the host's
animation module into every module that loads plugins. They use the API-only context instead.
The migration guide's registration table gets the `addComposerItem` row its test requires.
2026-11-02 06:45:57 +01:00
Mohamed Boudra a2b76f77f3 test(plugins): lock draft option ownership without a subscription, name the overflow-sheet steps
The draft option test subscribed the options model to the form, which in the
app happens in a render effect, so it passed without the model observing the
form's provider itself. A case with no subscription now fails when that
observation is removed.

The overflow-sheet spec reads as steps through a helper.
2026-11-02 06:09:27 +01:00
Mohamed Boudra ad88797f8d test(plugins): composer commands tested on the models, overflow sheet in Playwright
The draft composer's command rules (unavailable provider, unlisted model,
edits belonging to the provider the form has now) moved from the React hooks
into the form store and the draft options model. They take the host's catalog
as a plain input, so the command tests run on the real models with no module
mocks and no renderer.

The overflow-sheet test faked the portal it was meant to verify. It is
replaced by a Playwright spec that installs the composer-controls example and
checks its rows in the narrow composer's Features sheet; it fails when the
sheet's context bridge is removed.
2026-11-02 06:06:55 +01:00
Mohamed Boudra d92b16afbf fix(plugins): composer commands run on current state, report failures, and reach the overflow sheet
Draft commands read the state captured at render, so a plugin's
`await setProvider(); await setModel()` acted on a stale provider. The draft
form and options are now plain stores that commands read live, and
`setProvider`/`setModel` reject an unavailable provider or a model the loaded
catalog does not list. Draft `options` include the plugin's pending edits.

Shared composer controls own the pending state and show a rejected command as
an error toast, like the built-in controls. Callback payloads are objects
(`{ value }`, `onPress(event)`) so they can grow without breaking plugins.

The overflow sheet's content portals to the app root and lost the composer's
agent, input, toast and query contexts, so plugin items drew nothing in it.
`useComposerSheetBridge` carries them across.
2026-11-02 05:59:32 +01:00
Mohamed Boudra abd4d2c1ea feat(plugins): composer items and controls in all three composers
A plugin registers a component with addComposerItem and reads and changes the
composer through useComposer(): provider, model, mode, thinking, options,
prompt, attachments and agent metadata. The agent, draft and New workspace
composers provide the same ComposerInput and ComposerAgent halves, so a
command reaches the right model without the plugin knowing which composer
it sits in.

Toggle, Select and Action are drawn by one ComposerControl renderer that
the thinking select and provider option controls now use too. Controls sit in
the toolbar when there is room and become rows in the existing overflow sheet
otherwise, so on a phone they appear in the overflow. Plugin items count in
the density presence so the composer condenses to make room. Model and mode
stay specialized but share the trigger, density and overflow.

Draft metadata is kept locally, sent at creation and reset when the prompt is
sent. PluginAgentSnapshot.metadata, deferred from the metadata phase, lands
here. The unused provider badge is removed.

vitest resolves @floating-ui/react-native to its ESM build: the CJS entry
requires the real react-native, whose Flow sources do not parse.
2026-11-02 05:36:04 +01:00
106 changed files with 4903 additions and 922 deletions
+66
View File
@@ -644,6 +644,72 @@ Mounted surfaces and command invocations have shorter API lifetimes.
Keep the client entry synchronous: return its cleanup function immediately and start asynchronous
work inside it. See the maintained [composer pill example](../plugin-examples/local-plugin/client/main.tsx).
## Contribute composer items
`addComposerItem` mounts a plugin component in the agent, draft and New workspace composers. The
public contract is the [composer items reference](../public-docs/plugins/reference.md#composer-items).
The composer is two contexts, provided by two owners (`packages/app/src/composer/session.tsx`).
`Composer` provides `ComposerInput` (prompt and attachments). `AgentControls` and
`DraftAgentControls` provide `ComposerAgent` (provider, model, mode, thinking, options, metadata and
their commands). `toPluginComposer` (`packages/app/src/plugins/composer-items/`) joins them into the
`PluginComposer` an item reads, so a plugin cannot tell which composer a command reaches. Add a
capability to `ComposerAgent` and both implementations; never branch on `context` in the model.
Plugin `Toggle`, `Select` and `Action`, the thinking select and provider option controls all
render through `ComposerControl` (`packages/app/src/composer/agent-controls/composer-control.tsx`),
a toolbar icon or a row in the overflow sheet. Model and mode stay specialized but share its trigger,
density and overflow. Plugin items count in the density presence in `layout.ts`, so a composer
condenses to make room for them. A custom component renders wherever its item is placed, and an
item that renders nothing still counts for density; there is no registration for either.
A command runs against the owner's current state, not the render that built the callback. The
draft's form (`openAgentForm`) and options (`openDraftOptions`) are plain stores for that reason,
and `draftComposerAgent` rejects a provider the host does not offer or a model its loaded catalog
does not list. `ComposerControl` owns the pending state and the error toast for callbacks that
return a promise.
The overflow sheet's content portals to the app root, away from the composer. Pass
`useComposerSheetBridge()` as the sheet's `contextBridge`, or `useComposer()` returns nothing and
the sheet draws no plugin rows.
## Contribute New workspace items
`addNewWorkspaceItem` mounts a plugin component on the New workspace screen. The public contract
is the [New workspace items reference](../public-docs/plugins/reference.md#new-workspace-items).
The screen's choices that a plugin may change live in one plain store,
`createNewWorkspaceSetup` (`packages/app/src/screens/new-workspace/setup-model.ts`): the base
picker selection, the manual isolation, the branch name and the workspace metadata. The pickers and
the plugin commands write to it, so a command sees what an earlier command left. Project and host
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 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 injects those effects when it builds the store (syncing the picker's PR attachment,
remembering the isolation), so a command from a mount effect does the same as a click. Closing the
picker stays in the picker's own handler. 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` 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
composer's controls. `ComposerControlLockProvider` carries the screen's lock to them. Plugin
`Combobox` is a `select` item with `remoteSearch`: the app `Combobox` does not filter locally, and
`PluginCombobox` (`plugins/react-native/composer-controls.tsx`) runs `search` through
`useFetchQuery` with `dataShape: "list"`, which keeps the previous results while a newer query runs.
`plugins/item-host.tsx` is the host shared by composer and New workspace items.
## Contribute timeline items
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
@@ -0,0 +1,12 @@
import { test } from "../support/fixtures";
import { withComposerControlsExample } from "../support/helpers/plugin-composer-items";
test("a narrow composer's overflow sheet shows a plugin item's row", async ({ page }) => {
test.setTimeout(120_000);
await withComposerControlsExample(page, async (composer) => {
await composer.narrowTheWindow();
await composer.openAgentFeatures();
await composer.expectFeatureRow("Needs review");
await composer.expectFeatureRow("Insert checklist");
});
});
@@ -0,0 +1,92 @@
import { expect, test } from "../support/fixtures";
import { withNewWorkspaceIssuesExample } from "../support/helpers/plugin-new-workspace-items";
for (const layout of [
{ name: "desktop", compact: false },
{ name: "compact", compact: true },
]) {
test(`a plugin's New workspace item sets the branch and metadata on ${layout.name}`, async ({
page,
}) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(page, { compact: layout.compact }, async (screen) => {
await screen.chooseWorktree();
await screen.chooseIssue("PAS-102 Crash on resume");
const created = await screen.createWorkspace();
expect(created.branch).toBe("pas-102-crash-on-resume");
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "PAS-102" });
});
});
}
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("a branch and metadata chosen for one project are not sent for another", async ({ page }) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(
page,
{ compact: false, otherProject: true },
async (screen) => {
await screen.chooseWorktree();
await screen.chooseIssue("PAS-101 Slow sidebar");
await screen.switchToOtherProject();
const created = await screen.createWorkspace();
expect(created.branch).not.toBe("pas-101-slow-sidebar");
expect(created.metadata ?? {}).toEqual({});
},
);
});
test("an isolation set from a mount effect is remembered like a pick", async ({ page }) => {
test.setTimeout(180_000);
await withNewWorkspaceIssuesExample(
page,
{ compact: false, setsOnMount: true },
async (screen) => {
await screen.expectIsolation("worktree");
await screen.reopenWithoutPlugin();
await screen.expectIsolation("worktree");
},
);
});
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.expectIsolation("worktree");
const created = await screen.createWorkspace();
expect(created.branch).toBe("set-on-mount");
expect(created.metadata).toEqual({ "new-workspace-issues.issue": "ON-MOUNT" });
},
);
});
@@ -0,0 +1,59 @@
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { openAgentRoute } from "./mock-agent";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { copyPluginExample } from "./plugin-fixture";
import { seedWorkspace } from "./seed-client";
const PLUGIN_ID = "composer-controls-example";
const NARROW = { width: 500, height: 800 };
/** Runs `run` in an agent's composer with the composer-controls example installed. */
export async function withComposerControlsExample(
page: Page,
run: (composer: {
narrowTheWindow(): Promise<void>;
openAgentFeatures(): Promise<void>;
expectFeatureRow(name: string): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({
repoPrefix: "composer-items-",
title: "Composer items",
});
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Composer items agent",
model: "ten-second-stream",
modeId: "load-test",
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("composer-controls");
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await gotoAppShell(page);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
await run({
narrowTheWindow: () =>
test.step("narrow the window until the composer folds its controls away", () =>
page.setViewportSize(NARROW)),
openAgentFeatures: () =>
test.step("open the agent features sheet", async () => {
await page.getByRole("button", { name: "Open agent features", exact: true }).click();
}),
expectFeatureRow: (name) =>
test.step(`the sheet has a row for "${name}"`, () =>
expect(page.getByRole("button", { name, exact: true })).toBeVisible()),
});
} finally {
await client.removePlugin(PLUGIN_ID);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await example.cleanup();
await workspace.cleanup();
}
}
@@ -0,0 +1,173 @@
import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import {
archiveWorkspaceFromDaemon,
assertNewWorkspaceSidebarAndHeader,
connectNewWorkspaceDaemonClient,
expectWorkspaceIsolationSelected,
openNewWorkspaceComposer,
openProjectViaDaemon,
submitNewWorkspacePrompt,
} from "./new-workspace";
import { copyPluginExample } from "./plugin-fixture";
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.setIsolation("worktree");
void newWorkspace.setBranch("set-on-mount");
void newWorkspace.setMetadata({ "new-workspace-issues.issue": "ON-MOUNT" });
}, []);
return <Action title="Mount" icon="Flag" onPress={() => {}} />;
}
export default function contribute(client) {
client.addNewWorkspaceItem({ id: "on-mount", Component: OnMount });
return () => {};
}
`,
);
}
type Client = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
/** The daemon fills in the git runtime after it creates the workspace, so wait for the branch. */
async function readCreatedWorkspace(client: Client, workspaceId: string) {
const deadline = Date.now() + 30_000;
for (;;) {
const { entries } = await client.fetchWorkspaces();
const workspace = entries.find((entry) => entry.id === workspaceId);
const branch = workspace?.gitRuntime?.currentBranch ?? null;
if (workspace && branch) return { branch, metadata: workspace.metadata };
if (Date.now() > deadline) throw new Error(`Workspace ${workspaceId} never reported a branch`);
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
const PLUGIN_ID = "new-workspace-issues-example";
const COMPACT = { width: 500, height: 800 };
/** Runs `run` on the New workspace screen with the new-workspace-issues example installed. */
export async function withNewWorkspaceIssuesExample(
page: Page,
options: {
compact: boolean;
detachedHead?: boolean;
setsOnMount?: boolean;
/** A second project the screen can switch to. */
otherProject?: boolean;
},
run: (screen: {
chooseWorktree(): Promise<void>;
chooseIssue(label: string): Promise<void>;
createWorkspace(options?: { prompt?: string }): Promise<{ branch: string; metadata: unknown }>;
switchToOtherProject(): Promise<void>;
expectIsolation(isolation: "local" | "worktree"): Promise<void>;
/** Removes the plugin, leaves the screen, and opens New workspace for the project again. */
reopenWithoutPlugin(): Promise<void>;
}) => Promise<void>,
) {
const client = await connectNewWorkspaceDaemonClient();
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("new-workspace-issues");
if (options.setsOnMount) await replaceWithMountEffectItem(example.directory);
const repo = await createTempGitRepo("new-workspace-issues-", { branches: ["main"] });
const otherRepo = options.otherProject
? await createTempGitRepo("new-workspace-other-", { branches: ["main"] })
: null;
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);
const other = otherRepo ? await openProjectViaDaemon(client, otherRepo.path) : null;
let target = project;
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, project);
// The sidebar rows used to reach the screen exist only at desktop width.
if (options.compact) await page.setViewportSize(COMPACT);
await run({
chooseWorktree: () =>
test.step("choose a new worktree", async () => {
await page.getByTestId("workspace-create-isolation-trigger").click();
await page.getByTestId("workspace-create-isolation-worktree").click();
}),
chooseIssue: (label) =>
test.step(`choose the issue "${label}"`, async () => {
await page.getByRole("button", { name: "Issue", exact: true }).click();
await page.getByText(label, { exact: true }).click();
}),
switchToOtherProject: () =>
test.step("switch to the other project", async () => {
if (!other) throw new Error("Open the example with otherProject to switch projects");
await page.getByRole("button", { name: "Workspace project" }).click();
await page
.getByTestId("combobox-desktop-container")
.last()
.getByText(other.projectDisplayName, { exact: true })
.click();
await expect(page.getByRole("button", { name: "Workspace project" })).toContainText(
other.projectDisplayName,
);
target = other;
}),
expectIsolation: (isolation) =>
test.step(`the screen is on ${isolation}`, () =>
expectWorkspaceIsolationSelected(page, isolation)),
reopenWithoutPlugin: () =>
test.step("reopen the screen without the plugin", async () => {
await client.removePlugin(PLUGIN_ID);
// Leaving the screen drops its in-memory choice, so only the saved preference is left.
await page.goBack();
await expect(page.getByTestId("workspace-create-isolation-trigger")).toHaveCount(0);
await openNewWorkspaceComposer(page, project);
await expect(page.getByRole("button", { name: "Mount", exact: true })).toHaveCount(0);
}),
createWorkspace: (createOptions) =>
test.step("create the workspace and read what it carries", async () => {
await submitCreate(page, createOptions?.prompt);
const created = await assertNewWorkspaceSidebarAndHeader(page, {
serverId: getServerId(),
client,
previousWorkspaceId: target.workspaceId,
projectDisplayName: target.projectDisplayName,
// The sidebar is closed at compact width.
assertSidebarRow: !options.compact,
});
createdDirectory = created.workspaceDirectory;
return readCreatedWorkspace(client, created.workspaceId);
}),
});
} finally {
if (createdDirectory) {
await archiveWorkspaceFromDaemon(client, createdDirectory).catch(() => undefined);
}
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await example.cleanup();
await repo.cleanup();
await otherRepo?.cleanup();
}
}
@@ -14,6 +14,9 @@ import type {
* - `remembered`: the provider's last-used control values.
* - `edits`: toggles made in this draft.
*
* An edit of `null` removes the key from every layer, the way a patch does on a running agent, so
* the provider's default applies again.
*
* Choosing a provider by hand starts the options over; applying a profile replaces the provider
* and the options in one step, so the provider change that follows does not clear them.
*/
@@ -47,6 +50,11 @@ export interface DraftOptionsModel {
export function openDraftOptions(input: {
provider: string | null;
seed?: ProviderOptions;
/**
* The provider the draft's form has now. An edit belongs to it even before the screen has
* rendered the change, so `setOption` observes it first.
*/
currentProvider?: () => string | null;
}): DraftOptionsModel {
let state: DraftOptionsState = {
provider: input.provider,
@@ -64,6 +72,15 @@ export function openDraftOptions(input: {
for (const listener of listeners) listener();
}
function observeProvider(provider: string | null): void {
publish((current) => {
if (provider === current.provider) return current;
// The first provider a draft picks does not discard what it was opened with.
if (current.provider === null) return { ...current, provider };
return { ...current, provider, seed: {}, edits: {}, resolved: null };
});
}
return {
getState: () => state,
subscribe: (listener) => {
@@ -72,13 +89,7 @@ export function openDraftOptions(input: {
listeners.delete(listener);
};
},
observeProvider: (provider) =>
publish((current) => {
if (provider === current.provider) return current;
// The first provider a draft picks does not discard what it was opened with.
if (current.provider === null) return { ...current, provider };
return { ...current, provider, seed: {}, edits: {}, resolved: null };
}),
observeProvider,
applyProfile: (profile) =>
publish((current) => ({
...current,
@@ -88,12 +99,14 @@ export function openDraftOptions(input: {
resolved: current.provider === profile.provider ? current.resolved : null,
})),
setRemembered: (remembered) => publish((current) => ({ ...current, remembered })),
setOption: (key, value) =>
setOption: (key, value) => {
if (input.currentProvider) observeProvider(input.currentProvider());
publish((current) =>
Object.is(current.edits[key], value)
? current
: { ...current, edits: { ...current.edits, [key]: value } },
),
);
},
applyResolved: (resolved) => publish((current) => ({ ...current, resolved })),
};
}
@@ -109,10 +122,14 @@ export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | und
([key]) => controlKeys.has(key) && !(key in state.seed),
),
);
const own = { ...state.seed, ...remembered, ...state.edits };
const own = withoutRemovals({ ...state.seed, ...remembered, ...state.edits });
return Object.keys(own).length > 0 ? own : undefined;
}
function withoutRemovals(options: ProviderOptions): ProviderOptions {
return Object.fromEntries(Object.entries(options).filter(([, value]) => value !== null));
}
export type DraftControlsRequest = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions"
@@ -163,6 +180,20 @@ export function viewDraftOptions(
options:
resolved.requestKey === current.requestKey
? resolved.options
: { ...resolved.options, ...state.edits },
: withoutRemovals({ ...resolved.options, ...state.edits }),
};
}
const NO_OPTIONS: ProviderOptions = {};
/**
* The options the draft will create with, for a plugin or a control to read: the daemon's answer
* for this provider when there is one, otherwise the draft's own layers, so an edit shows before
* the daemon has answered.
*/
export function shownDraftOptions(
state: DraftOptionsState,
current: { provider: string | null; requestKey: string },
): ProviderOptions {
return viewDraftOptions(state, current)?.options ?? ownDraftOptions(state) ?? NO_OPTIONS;
}
+9 -1
View File
@@ -89,6 +89,8 @@ export interface ComboboxProps {
}) => ReactElement;
onSearchQueryChange?: (query: string) => void;
searchable?: boolean;
/** The caller answers the search query itself, so the options are shown as given. */
remoteSearch?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
@@ -1292,6 +1294,10 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
);
}
function filtersLocally(input: { searchable: boolean; remoteSearch?: boolean }): boolean {
return input.searchable && !input.remoteSearch;
}
export function Combobox({
options,
value,
@@ -1299,6 +1305,7 @@ export function Combobox({
renderOption,
onSearchQueryChange,
searchable = true,
remoteSearch,
placeholder,
searchPlaceholder,
emptyText,
@@ -1458,7 +1465,7 @@ export function Combobox({
buildVisibleComboboxOptions({
options,
searchQuery,
searchable,
searchable: filtersLocally({ searchable, remoteSearch }),
allowCustomValue,
customValuePrefix,
customValueDescription,
@@ -1470,6 +1477,7 @@ export function Combobox({
customValueKind,
customValuePrefix,
options,
remoteSearch,
searchQuery,
searchable,
],
@@ -0,0 +1,175 @@
import React from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Scan } from "lucide-react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
import type { ToastApi } from "@/components/toast-host";
import { ToastApiProvider } from "@/contexts/toast-api-context";
import { ComposerControls, type ComposerControlItem } from "./composer-control";
import { useAgentControlItems } from "./control-items";
import { resolveComposerControlPresentation } from "./layout";
beforeEach(() => vi.stubGlobal("React", React));
afterEach(cleanup);
const base = { id: "x", title: "Context", icon: Scan };
type RecordingToast = ToastApi & { error: ReturnType<typeof vi.fn> };
function createToast(): RecordingToast {
return { error: vi.fn() } as unknown as RecordingToast;
}
function ignoreOpenChange() {}
function Controls({
items,
surface,
toast,
}: {
items: ComposerControlItem[];
surface: "toolbar" | "sheet";
toast: ToastApi;
}) {
return (
<ToastApiProvider api={toast}>
<ComposerControls
items={items}
surface={surface}
openId={null}
onOpenIdChange={ignoreOpenChange}
/>
</ToastApiProvider>
);
}
function renderControls(items: ComposerControlItem[], surface: "toolbar" | "sheet" = "toolbar") {
const toast = createToast();
render(<Controls items={items} surface={surface} toast={toast} />);
return toast;
}
describe("ComposerControls", () => {
it("shows a select's chosen label, or only its icon when asked", () => {
const select: ComposerControlItem = {
...base,
type: "select",
value: "large",
choices: [
{ value: "small", label: "Small" },
{ value: "large", label: "Large" },
],
onChange: () => undefined,
};
renderControls([select]);
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("Large");
});
it("names the choice on an icon-only select, without drawing it", () => {
renderControls([
{
...base,
type: "select",
value: "large",
iconOnly: true,
choices: [{ value: "large", label: "Large" }],
onChange: () => undefined,
},
]);
const trigger = screen.getByRole("button", { name: "Context: Large" });
expect(trigger.textContent).not.toContain("Large");
});
it("flips a toggle on press in the toolbar", () => {
const onChange = vi.fn();
renderControls([{ ...base, type: "toggle", value: true, onChange }]);
fireEvent.click(screen.getByRole("button", { name: "Context" }));
expect(onChange).toHaveBeenCalledWith(false);
});
it("lists a toggle as On or Off in the sheet", () => {
renderControls([{ ...base, type: "toggle", value: true, onChange: () => undefined }], "sheet");
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("On");
});
it("reports a failed command the way a built-in control does, and frees the control", async () => {
let reject: (error: Error) => void = () => undefined;
const onPress = vi.fn(
() =>
new Promise<void>((_resolve, rejectPromise) => {
reject = rejectPromise;
}),
);
const toast = renderControls([{ ...base, type: "action", onPress }]);
const button = screen.getByRole("button", { name: "Context" });
fireEvent.click(button);
// While the command is in flight the control cannot be pressed again.
await waitFor(() => expect(button.getAttribute("aria-disabled")).toBe("true"));
fireEvent.click(button);
expect(onPress).toHaveBeenCalledTimes(1);
reject(new Error("Unknown or unavailable provider: x"));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith("Unknown or unavailable provider: x"),
);
await waitFor(() => expect(button.getAttribute("aria-disabled")).not.toBe("true"));
});
it("reports a command that throws before it returns", () => {
const toast = renderControls([
{
...base,
type: "action",
onPress: () => {
throw new Error("No such control");
},
},
]);
fireEvent.click(screen.getByRole("button", { name: "Context" }));
expect(toast.error).toHaveBeenCalledWith("No such control");
});
});
const FAST_ON = { fast_mode: true };
describe("provider option controls", () => {
const fastMode: AgentOptionControl = {
type: "toggle",
key: "fast_mode",
label: "Fast",
defaultValue: false,
};
function OptionControls({
options,
onSetOption,
}: {
options: Record<string, unknown>;
onSetOption: (key: string, value: unknown) => void;
}) {
const optionControls = React.useMemo(
() => ({ controls: [fastMode], options, onSetOption }),
[onSetOption, options],
);
const items = useAgentControlItems({
surface: "toolbar",
presentation: resolveComposerControlPresentation("full"),
thinking: null,
optionControls,
});
return <Controls items={items.features} surface="toolbar" toast={TOAST} />;
}
const TOAST = createToast();
function recordWrites(writes: Array<[string, unknown]>) {
return (key: string, value: unknown) => void writes.push([key, value]);
}
it("write the toggled option from the agent's current options", () => {
const writes: Array<[string, unknown]> = [];
const onSetOption = recordWrites(writes);
render(<OptionControls options={FAST_ON} onSetOption={onSetOption} />);
fireEvent.click(screen.getByTestId("agent-feature-fast_mode"));
expect(writes).toEqual([["fast_mode", false]]);
});
});
@@ -0,0 +1,403 @@
import {
createContext,
forwardRef,
useCallback,
useContext,
useMemo,
useRef,
useState,
type ReactElement,
} from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { AgentControlIcon } from "@/agent-controls/icons";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FieldTrigger } from "@/screens/new-workspace/form/field-trigger";
import { ICON_SIZE } from "@/styles/theme";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AgentControlTrigger } from "@/composer/agent-controls/control";
import { useToast } from "@/contexts/toast-api-context";
import { toErrorMessage } from "@/utils/error-messages";
interface ComposerControlBase {
id: string;
title: string;
icon: AgentControlIcon;
/** Tints the icon; muted when absent. */
highlight?: "blue" | "green" | "yellow";
/** Defaults to the title, or to "title: choice" on an icon-only select. */
tooltip?: string;
/** Defaults to the tooltip. */
accessibilityLabel?: string;
testID?: string;
}
export interface ComposerControlChoice {
value: string;
label: string;
description?: string;
}
/**
* One control, drawn from data. Provider option controls, thinking and plugin controls all come
* through here, so each is an icon in the composer toolbar, a row in its sheet, or, in a New
* workspace item, a field. The name stays from when the composer was the only place it drew.
*/
export type ComposerControlItem =
| (ComposerControlBase & {
type: "toggle";
value: boolean;
onChange: (value: boolean) => void | Promise<void>;
})
| (ComposerControlBase & {
type: "select";
value: string | null;
choices: readonly ComposerControlChoice[];
/** Names a value the choices do not list, such as a pick that a search no longer returns. */
valueLabel?: string;
onChange: (value: string) => void | Promise<void>;
/** The toolbar trigger shows only the icon. */
iconOnly?: boolean;
showCaret?: boolean;
searchable?: boolean;
/** The list answers `onSearchQueryChange` itself, so it is not filtered again. */
remoteSearch?: boolean;
onSearchQueryChange?: (query: string) => void;
emptyText?: string;
desktopMinWidth?: number;
renderOption?: (args: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
})
| (ComposerControlBase & {
type: "action";
onPress: () => void | Promise<void>;
});
/** `field`: a New workspace setup field, a badge on desktop and a full-width field when compact. */
export type ComposerControlSurface = "toolbar" | "sheet" | "field";
const ComposerControlSurfaceContext = createContext<ComposerControlSurface>("toolbar");
export const ComposerControlSurfaceProvider = ComposerControlSurfaceContext.Provider;
const ComposerControlLockContext = createContext(false);
/** Disables every control below it, for a surface that is not a composer and has its own lock. */
export const ComposerControlLockProvider = ComposerControlLockContext.Provider;
export function useComposerControlLocked(): boolean {
return useContext(ComposerControlLockContext);
}
/** Where the control being drawn sits: the toolbar, a row in the overflow sheet, or a field. */
export function useComposerControlSurface(): ComposerControlSurface {
return useContext(ComposerControlSurfaceContext);
}
interface ComposerControlProps {
item: ComposerControlItem;
surface: ComposerControlSurface;
disabled?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Runs after a toolbar toggle or action, so the composer can take focus back. */
onActionComplete?: () => void;
}
interface ControlView {
options: ComboboxOption[];
/** The list's selected id. */
selectedId: string;
/** What the trigger shows beside the icon. */
value: string;
iconOnly: boolean;
tooltip: string;
/** Whether pressing the trigger opens a list instead of acting. */
opensList: boolean;
}
function describeControl(
item: ComposerControlItem,
surface: ComposerControlSurface,
t: TFunction,
): ControlView {
const isSheet = surface === "sheet";
if (item.type === "action") {
return {
options: [],
selectedId: "",
value: "",
iconOnly: true,
tooltip: item.tooltip ?? item.title,
opensList: false,
};
}
if (item.type === "toggle") {
const label = item.value ? t("agentControls.features.on") : t("agentControls.features.off");
return {
options: [
{ id: "true", label: t("agentControls.features.on") },
{ id: "false", label: t("agentControls.features.off") },
],
selectedId: String(item.value),
value: label,
iconOnly: true,
tooltip: item.tooltip ?? item.title,
opensList: isSheet,
};
}
const choiceLabel =
item.choices.find((choice) => choice.value === item.value)?.label ??
item.valueLabel ??
item.title;
const iconOnly = item.iconOnly === true;
return {
options: item.choices.map((choice) => ({
id: choice.value,
label: choice.label,
description: choice.description,
})),
selectedId: String(item.value ?? ""),
value: choiceLabel,
iconOnly,
// An icon-only trigger names its value; a sheet row shows the value, so it names the action.
tooltip: iconOnly && !isSheet ? `${item.title}: ${choiceLabel}` : (item.tooltip ?? item.title),
opensList: true,
};
}
type SelectItem = Extract<ComposerControlItem, { type: "select" }>;
/** The list options only a select carries; a toggle's two-choice list has none of them. */
function listProps(select: SelectItem | null) {
if (!select) return { searchable: false };
return {
desktopMinWidth: select.desktopMinWidth,
searchable: select.searchable,
remoteSearch: select.remoteSearch,
onSearchQueryChange: select.onSearchQueryChange,
emptyText: select.emptyText,
renderOption: select.renderOption,
};
}
// A command that returns a promise keeps the control pending until it settles, and a failure
// reaches the user the way a built-in live control's does.
function usePendingRun() {
const toast = useToast();
const [pending, setPending] = useState(false);
const run = useCallback(
(invoke: () => void | Promise<void>) => {
try {
const result = invoke();
if (!result) return;
setPending(true);
result
.catch((error: unknown) => toast.error(toErrorMessage(error)))
.finally(() => setPending(false));
} catch (error) {
toast.error(toErrorMessage(error));
}
},
[toast],
);
return { pending, run };
}
export function ComposerControl({
item,
surface,
disabled = false,
open,
onOpenChange,
onActionComplete,
}: ComposerControlProps) {
const { t } = useTranslation();
const anchorRef = useRef<View>(null);
const isSheet = surface === "sheet";
const isField = surface === "field";
const sheetHeader = useMemo<SheetHeader>(() => ({ title: item.title }), [item.title]);
const view = useMemo(() => describeControl(item, surface, t), [item, surface, t]);
const { pending, run } = usePendingRun();
const handlePress = useCallback(() => {
if (item.type === "toggle") run(() => item.onChange(!item.value));
else if (item.type === "action") run(item.onPress);
onActionComplete?.();
}, [item, onActionComplete, run]);
const handleOpenPress = useCallback(() => onOpenChange(!open), [onOpenChange, open]);
const handleSelect = useCallback(
(value: string) => {
if (item.type === "select") run(() => item.onChange(value));
else if (item.type === "toggle") run(() => item.onChange(value === "true"));
},
[item, run],
);
const select = item.type === "select" ? item : null;
const accessibilityLabel = item.accessibilityLabel ?? view.tooltip;
const trigger = isField ? (
<FieldControlTrigger
ref={anchorRef}
item={item}
view={view}
accessibilityLabel={accessibilityLabel}
disabled={disabled || pending}
onPress={view.opensList ? handleOpenPress : handlePress}
/>
) : (
<AgentControlTrigger
ref={anchorRef}
icon={item.icon}
iconColor={item.highlight ? styles[item.highlight].color : undefined}
surface={surface}
label={item.title}
value={view.value}
showToolbarLabel={!view.iconOnly}
showCaret={select?.showCaret === true && !isSheet}
open={view.opensList && open}
disabled={disabled || pending}
onPress={view.opensList ? handleOpenPress : handlePress}
accessibilityLabel={accessibilityLabel}
testID={item.testID}
/>
);
return (
<>
{isSheet || isField ? (
trigger
) : (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
{trigger}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{view.tooltip}</Text>
</TooltipContent>
</Tooltip>
)}
{view.opensList ? (
<Combobox
options={view.options}
value={view.selectedId}
onSelect={handleSelect}
open={open}
onOpenChange={onOpenChange}
anchorRef={anchorRef}
presentation={isSheet ? "push" : undefined}
desktopPlacement={DESKTOP_PLACEMENT[surface]}
{...listProps(select)}
header={isSheet ? sheetHeader : undefined}
/>
) : null}
</>
);
}
/** Where a list opens on desktop; the sheet has its own presentation. */
const DESKTOP_PLACEMENT = {
toolbar: "top-start",
sheet: undefined,
field: "bottom-start",
} as const;
/** What a field shows: a badge has no label, so a toggle names itself beside its state. */
function fieldValue(item: ComposerControlItem, view: ControlView, compact: boolean): string {
if (item.type === "action") return item.title;
if (item.type === "toggle" && !compact) return `${item.title}: ${view.value}`;
return view.value;
}
const FieldControlTrigger = forwardRef<
View,
{
item: ComposerControlItem;
view: ControlView;
accessibilityLabel: string;
disabled: boolean;
onPress: () => void;
}
>(function FieldControlTrigger({ item, view, accessibilityLabel, disabled, onPress }, ref) {
const compact = useIsCompactFormFactor();
const Icon = item.icon;
const iconColor = item.highlight ? styles[item.highlight].color : styles.fieldIcon.color;
const icon = useMemo(() => <Icon size={ICON_SIZE.sm} color={iconColor} />, [Icon, iconColor]);
return (
<FieldTrigger
ref={ref}
variant={compact ? "field" : "badge"}
label={item.title}
icon={icon}
value={fieldValue(item, view, compact)}
tooltip={view.tooltip}
accessibilityLabel={accessibilityLabel}
disabled={disabled}
onPress={onPress}
testID={item.testID}
/>
);
});
interface ComposerControlsProps {
items: readonly ComposerControlItem[];
surface: ComposerControlSurface;
disabled?: boolean;
/** The id of the item whose list is open. */
openId: string | null;
onOpenIdChange: (id: string | null) => void;
onActionComplete?: () => void;
}
/** A row of Paseo-owned controls that share one open list at a time. */
export function ComposerControls({
items,
onOpenIdChange,
openId,
...rest
}: ComposerControlsProps) {
return (
<>
{items.map((item) => (
<ComposerControlSlot
key={item.id}
item={item}
open={openId === item.id}
onOpenIdChange={onOpenIdChange}
{...rest}
/>
))}
</>
);
}
function ComposerControlSlot({
item,
open,
onOpenIdChange,
...rest
}: Omit<ComposerControlsProps, "items" | "openId"> & { item: ComposerControlItem; open: boolean }) {
const handleOpenChange = useCallback(
(next: boolean) => onOpenIdChange(next ? item.id : null),
[item.id, onOpenIdChange],
);
return <ComposerControl item={item} open={open} onOpenChange={handleOpenChange} {...rest} />;
}
const styles = StyleSheet.create((theme) => ({
fieldIcon: { color: theme.colors.foregroundMuted },
blue: { color: theme.colors.palette.blue[400] },
green: { color: theme.colors.palette.green[400] },
yellow: { color: theme.colors.palette.yellow[400] },
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: theme.fontSize.base * 1.4,
},
}));
@@ -0,0 +1,165 @@
import { useMemo } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types";
import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons";
import {
getControlTooltip,
isControlActive,
readSelectOption,
readToggleOption,
type ComposerOptionControls,
} from "@/agent-controls/option-controls";
import { ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import {
type ComposerControlItem,
type ComposerControlSurface,
} from "@/composer/agent-controls/composer-control";
import type { ComposerControlPresentation } from "@/composer/agent-controls/layout";
import { getAgentControlHintKey, getFeatureHighlightColor } from "@/composer/agent-controls/utils";
const SEARCH_THRESHOLD = 6;
function getFeatureHighlight(
control: AgentOptionControl,
options: ProviderOptions,
): ComposerControlItem["highlight"] {
if (!isControlActive(control, options)) return undefined;
const color = getFeatureHighlightColor(control.key);
return color === "default" ? undefined : color;
}
function toFeatureItem(
control: AgentOptionControl,
optionControls: ComposerOptionControls,
): ComposerControlItem {
const { options, onSetOption } = optionControls;
const base = {
id: `feature-${control.key}`,
title: control.label,
icon: getAgentFeatureIcon(control.icon),
highlight: getFeatureHighlight(control, options),
testID: `agent-feature-${control.key}`,
};
if (control.type === "toggle") {
return {
...base,
type: "toggle",
tooltip: getControlTooltip(control),
value: readToggleOption(control, options),
onChange: (value) => onSetOption(control.key, value),
};
}
const iconOnly = control.desktopTrigger === "icon";
return {
...base,
type: "select",
tooltip: getControlTooltip(control),
value: readSelectOption(control, options),
choices: control.choices,
iconOnly,
onChange: (value) => onSetOption(control.key, value),
};
}
interface ThinkingControl {
options: readonly { id: string; label: string }[];
selectedId: string | undefined;
onSelect: (id: string) => void;
/** The select's displayed label, falling back like the compact model sheet. */
displayLabel: string;
}
/** The thinking select and the option controls, as the data `ComposerControl` draws. */
export function useAgentControlItems({
surface,
presentation,
thinking,
optionControls,
}: {
surface: ComposerControlSurface;
presentation: ComposerControlPresentation;
thinking: ThinkingControl | null;
optionControls: ComposerOptionControls | undefined;
}): { thinking: ComposerControlItem[]; features: ComposerControlItem[] } {
const { t } = useTranslation();
const thinkingItem = useMemo(
() => (thinking ? [toThinkingItem({ thinking, surface, presentation, t })] : []),
[presentation, surface, t, thinking],
);
const features = useMemo(
() =>
optionControls
? optionControls.controls.map((control) => toFeatureItem(control, optionControls))
: [],
[optionControls],
);
return { thinking: thinkingItem, features };
}
function toThinkingItem({
thinking,
surface,
presentation,
t,
}: {
thinking: ThinkingControl;
surface: ComposerControlSurface;
presentation: ComposerControlPresentation;
t: TFunction;
}): ComposerControlItem {
const isSheet = surface === "sheet";
return {
id: "thinking",
type: "select",
title: t("agentControls.thinking.title"),
icon: ThinkingIcon,
tooltip: t(getAgentControlHintKey("thinking")),
accessibilityLabel: t("agentControls.thinking.selectWithValue", {
value: thinking.displayLabel,
}),
testID: isSheet ? "agent-controls-thinking" : "agent-thinking-selector",
value: thinking.options.some((option) => option.id === thinking.selectedId)
? (thinking.selectedId ?? null)
: (thinking.options[0]?.id ?? null),
choices: thinking.options.map((option) => ({ value: option.id, label: option.label })),
onChange: thinking.onSelect,
iconOnly: !presentation.showThinkingLabel,
showCaret: presentation.showCarets,
searchable: !isSheet && thinking.options.length > SEARCH_THRESHOLD,
desktopMinWidth: 200,
renderOption: (args) => <ThinkingOption {...args} />,
};
}
function ThinkingOption({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) {
const leadingSlot = useMemo(
() => <ThinkingIcon size={16} color={styles.thinkingIcon.color} />,
[],
);
return (
<ComboboxItem
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={leadingSlot}
/>
);
}
const styles = StyleSheet.create((theme) => ({
thinkingIcon: { color: theme.colors.foreground },
}));
File diff suppressed because it is too large Load Diff
@@ -34,6 +34,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" as const }],
pluginItems: 0,
fontScale: 1,
};
@@ -95,6 +96,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }],
pluginItems: 0,
fontScale: 1,
},
}),
@@ -107,6 +109,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }, { type: "select", label: "Tools" }],
pluginItems: 0,
fontScale: 1,
},
}),
@@ -119,6 +122,7 @@ describe("composer control layout", () => {
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" }],
pluginItems: 0,
fontScale: 1.25,
},
}),
@@ -133,6 +137,7 @@ describe("composer control layout", () => {
hasModel: true,
hasThinking: true,
hasMode: true,
pluginItems: 0,
fontScale: 1,
},
};
@@ -154,6 +159,34 @@ describe("composer control layout", () => {
).toBe("condensed");
});
it("condenses to make room for plugin items and keeps them in the overflow", () => {
const base = {
availableWidth: 430,
currentDensity: "full" as const,
controls: {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [],
fontScale: 1,
},
};
expect(
resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 0 } }),
).toBe("full");
expect(
resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 2 } }),
).toBe("condensed");
expect(
resolveComposerControlDensity({
...base,
availableWidth: 250,
controls: { ...base.controls, pluginItems: 2 },
}),
).toBe("tight");
});
it("gives every toolbar control one shell and one platform glyph envelope", () => {
expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({
controlSize: 28,
@@ -5,6 +5,8 @@ export interface ComposerControlPresence {
hasThinking: boolean;
hasMode: boolean;
features: readonly ComposerFeatureControlPresence[];
/** Plugin composer items. Each is budgeted like a select with a short label. */
pluginItems: number;
fontScale: number;
}
@@ -26,6 +28,7 @@ export const COMPOSER_TOOLBAR_GEOMETRY = {
} as const;
const DENSITY_HYSTERESIS = 12;
const PLUGIN_ITEM_LABEL = "Plugin item";
function normalizedFontScale(fontScale: number): number {
return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1;
@@ -62,7 +65,9 @@ function resolveCondensedFloor(controls: ComposerControlPresence): number {
if (controls.hasModel) widths.push(36 + 60 * fontScale);
if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
if (controls.hasMode) widths.push(36 + 96 * fontScale);
if (controls.features.length > 0) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
if (controls.features.length > 0 || controls.pluginItems > 0) {
widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
}
return sumControlWidths(widths);
}
@@ -75,6 +80,11 @@ function resolveFullFloor(controls: ComposerControlPresence): number {
for (const feature of controls.features) {
widths.push(resolveFeatureControlWidth(feature, fontScale));
}
for (let index = 0; index < controls.pluginItems; index++) {
widths.push(
resolveFeatureControlWidth({ type: "select", label: PLUGIN_ITEM_LABEL }, fontScale),
);
}
return sumControlWidths(widths);
}
@@ -25,15 +25,19 @@ describe("resolveAgentControlsMode", () => {
onSelectMode: () => undefined,
models: [],
selectedModel: "",
onSelectModel: () => undefined,
onSelectModel: () => true,
isModelLoading: false,
modelSelectorProviders: [],
isAllModelsLoading: false,
onSelectProviderAndModel: () => undefined,
onSelectProviderAndModel: () => true,
options: {},
onSetOption: () => undefined,
thinkingOptions: [],
selectedThinkingOptionId: "",
onSelectThinkingOption: () => undefined,
onApplyAgentProfile: () => undefined,
metadata: {},
onSetMetadata: () => undefined,
}),
).toBe("draft");
});
@@ -5,6 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { useProviderIcon } from "@/components/provider-icons";
import { ModelBrowser, useModelBrowser } from "@/components/model-browser";
@@ -43,6 +44,8 @@ interface CompactModelSheetProps {
serverId?: string | null;
glyphSize: number;
canSwitchProvider: boolean;
/** Carries the composer's contexts into the sheets, which draw away from it. */
contextBridge?: ContextBridge | null;
children: ReactNode;
}
@@ -95,6 +98,7 @@ export function CompactModelSheet({
serverId = null,
glyphSize,
canSwitchProvider,
contextBridge = null,
children,
}: CompactModelSheetProps) {
const { t } = useTranslation();
@@ -299,6 +303,7 @@ export function CompactModelSheet({
snapPoints={SNAP_POINTS}
scrollable={false}
contentStyle={styles.sheetBody}
contextBridge={contextBridge}
testID="agent-controls-model-sheet"
>
<View
@@ -347,6 +352,7 @@ export function CompactModelSheet({
scrollable={false}
presentation="push"
contentStyle={styles.sheetBody}
contextBridge={contextBridge}
testID="agent-controls-model-browser-sheet"
>
<View
@@ -0,0 +1,43 @@
import { useCallback } from "react";
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
import { ComposerControlLayoutProvider, useComposerControlLayout } from "./layout-context";
import {
ComposerAgentProvider,
ComposerInputProvider,
useComposerAgent,
useComposerInput,
} from "@/composer/session";
import { ToastApiProvider, useToast } from "@/contexts/toast-api-context";
/**
* A phone's overflow draws in a sheet whose content lands at the app root, away from the
* composer. This carries across what a control in it reads from the composer: the agent and input
* a plugin's `useComposer()` sits on, the layout, and the toast and query client the controls use.
* Call it from inside the composer, outside any sheet.
*/
export function useComposerSheetBridge(): ContextBridge {
const layout = useComposerControlLayout();
const agent = useComposerAgent();
const input = useComposerInput();
const toast = useToast();
const queryClient = useQueryClient();
return useCallback<ContextBridge>(
(content) => (
<QueryClientProvider client={queryClient}>
<ToastApiProvider api={toast}>
<ComposerControlLayoutProvider value={layout}>
<ComposerAgentProvider value={agent}>
{input ? (
<ComposerInputProvider value={input}>{content}</ComposerInputProvider>
) : (
content
)}
</ComposerAgentProvider>
</ComposerControlLayoutProvider>
</ToastApiProvider>
</QueryClientProvider>
),
[agent, input, layout, queryClient, toast],
);
}
@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import {
draftControlsRequest,
openDraftOptions,
shownDraftOptions,
} from "@/agent-controls/draft-options-model";
import { openAgentForm, type AgentFormCatalog } from "@/provider-selection/agent-form-store";
import { draftComposerAgent } from "./composer-agent";
function definition(id: string, modeId: string): AgentProviderDefinition {
return {
id,
label: id,
description: id,
defaultModeId: modeId,
modes: [{ id: modeId, label: modeId, icon: "ShieldCheck", colorTier: "safe" }],
};
}
function model(provider: string, id: string, isDefault = false): AgentModelDefinition {
return { provider, id, label: id, isDefault };
}
/** The host: two providers with catalogs, and one that is ready but lists no models. */
const catalog: AgentFormCatalog = {
selectableProviders: new Map([
["claude", definition("claude", "default")],
["codex", definition("codex", "auto")],
["opencode", definition("opencode", "build")],
]),
models: new Map([
["claude", [model("claude", "sonnet", true)]],
["codex", [model("codex", "gpt-5.4", true), model("codex", "gpt-5.5")]],
["opencode", []],
]),
providerPreferences: () => undefined,
};
/** A draft composer on the real form and options models. */
function openDraft(context: "draft" | "new-workspace") {
const form = openAgentForm(() => catalog);
const options = openDraftOptions({
provider: null,
currentProvider: () => form.getState().form.provider,
});
// The screen hands the options the provider the form reaches.
form.subscribe(() => options.observeProvider(form.getState().form.provider));
form.setProviderAndModel("claude", "");
function agent() {
const { form: current } = form.getState();
return draftComposerAgent({
context,
selectedProvider: current.provider,
selectedModel: current.model,
selectedMode: current.modeId,
thinking: current.thinkingOptionId || null,
isLocked: () => false,
options: shownDraftOptions(options.getState(), {
provider: current.provider,
requestKey: "",
}),
metadata: {},
onSelectProviderAndModel: form.setProviderAndModel,
onSelectModel: form.setModel,
onSelectMode: () => undefined,
onSelectThinkingOption: () => undefined,
onSetOption: options.setOption,
onSetMetadata: () => undefined,
});
}
/** What the daemon is asked to resolve for the agent this draft would create. */
function request() {
const { form: current } = form.getState();
return draftControlsRequest(options.getState(), {
provider: current.provider,
cwd: "/repo",
modeId: current.modeId,
modelId: current.model,
});
}
return { form, agent, request };
}
describe("a draft's options", () => {
it("belong to the provider the form has now, before the screen has rendered the change", () => {
const form = openAgentForm(() => catalog);
const options = openDraftOptions({
provider: null,
currentProvider: () => form.getState().form.provider,
});
// No subscription: the screen observes the provider in an effect, after the command ran.
form.setProviderAndModel("claude", "");
options.observeProvider("claude");
options.setOption("context", "1m");
form.setProviderAndModel("codex", "");
options.setOption("effort", "high");
expect(options.getState()).toMatchObject({ provider: "codex", edits: { effort: "high" } });
});
});
describe.each(["draft", "new-workspace"] as const)("the %s composer's agent", (context) => {
it("applies each awaited command before the next one runs", async () => {
const draft = openDraft(context);
// A plugin keeps the object it was handed across its awaits.
const retained = draft.agent();
await retained.setProvider("codex");
await retained.setModel("gpt-5.5");
await retained.setOptions({ context: "1m" });
const agent = draft.agent();
expect(agent).toMatchObject({
context,
provider: "codex",
model: "gpt-5.5",
options: { context: "1m" },
});
expect(draft.request()).toMatchObject({
provider: "codex",
model: "gpt-5.5",
providerOptions: { context: "1m" },
});
});
it("starts the options over when the provider is chosen by hand", async () => {
const draft = openDraft(context);
const retained = draft.agent();
await retained.setOptions({ context: "1m" });
expect(draft.agent().options).toEqual({ context: "1m" });
await retained.setProvider("codex");
expect(draft.agent().options).toEqual({});
});
it("creates with the provider's default again once a plugin resets an option", async () => {
const draft = openDraft(context);
const retained = draft.agent();
await retained.setOptions({ context: "1m", effort: "high" });
await retained.setOptions({ context: null });
expect(draft.agent().options).toEqual({ effort: "high" });
expect(draft.request()?.providerOptions).toEqual({ effort: "high" });
await retained.setOptions({ effort: null });
expect(draft.agent().options).toEqual({});
expect(draft.request()?.providerOptions).toBeUndefined();
});
it("rejects a provider the host cannot run and a model the catalog does not list", async () => {
const draft = openDraft(context);
const retained = draft.agent();
await expect(retained.setProvider("nope")).rejects.toThrow(
"Unknown or unavailable provider: nope",
);
expect(draft.agent().provider).toBe("claude");
await retained.setProvider("codex");
await expect(retained.setModel("missing")).rejects.toThrow("Unknown model: missing");
expect(draft.agent()).toMatchObject({ provider: "codex", model: "gpt-5.4" });
});
it("keeps a model id as given when the provider lists no catalog", async () => {
const draft = openDraft(context);
const retained = draft.agent();
await retained.setProvider("opencode");
await retained.setModel("anthropic/claude-x");
expect(draft.agent().model).toBe("anthropic/claude-x");
});
});
@@ -0,0 +1,52 @@
import type { ComposerAgent } from "@/composer/session";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
/**
* The agent a draft composer creates, as a plugin sees it. Its commands go to the form and the
* options model, which apply them as they run, so a plugin can `await` one and issue the next.
*/
export function draftComposerAgent(
props: Pick<
DraftAgentControlsProps,
| "context"
| "disabled"
| "selectedProvider"
| "selectedModel"
| "selectedMode"
| "options"
| "metadata"
| "onSelectProviderAndModel"
| "onSelectModel"
| "onSelectMode"
| "onSelectThinkingOption"
| "onSetOption"
| "onSetMetadata"
> & { thinking: string | null; isLocked: () => boolean },
): ComposerAgent {
return {
context: props.context ?? "draft",
locked: props.disabled ?? false,
isLocked: props.isLocked,
provider: props.selectedProvider,
model: props.selectedModel || null,
mode: props.selectedMode || null,
thinking: props.thinking,
options: props.options,
metadata: props.metadata,
// An empty model id picks the provider's default model.
setProvider: async (provider) => {
if (!props.onSelectProviderAndModel(provider, "")) {
throw new Error(`Unknown or unavailable provider: ${provider}`);
}
},
setModel: async (modelId) => {
if (!props.onSelectModel(modelId)) throw new Error(`Unknown model: ${modelId}`);
},
setMode: async (modeId) => props.onSelectMode(modeId),
setThinking: async (thinkingOptionId) => props.onSelectThinkingOption(thinkingOptionId),
setOptions: async (patch) => {
for (const [key, value] of Object.entries(patch)) props.onSetOption(key, value);
},
setMetadata: async (patch) => props.onSetMetadata(patch),
};
}
@@ -1,3 +1,4 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
@@ -21,10 +22,25 @@ export function resolveDraftKey(input: {
export function buildDraftAgentControls(input: {
formState: UseAgentFormStateResult;
optionControls?: DraftAgentControlsProps["optionControls"];
options: DraftAgentControlsProps["options"];
onSetOption: DraftAgentControlsProps["onSetOption"];
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
context: NonNullable<DraftAgentControlsProps["context"]>;
metadata: Record<string, JsonValue>;
onSetMetadata: (patch: Record<string, JsonValue>) => void;
}): DraftAgentControlsProps {
const { formState, optionControls, onApplyAgentProfile, onDropdownClose } = input;
const {
formState,
optionControls,
options,
onSetOption,
onApplyAgentProfile,
onDropdownClose,
context,
metadata,
onSetMetadata,
} = input;
return {
providerDefinitions: formState.providerDefinitions,
selectedProvider: formState.selectedProvider,
@@ -43,11 +59,16 @@ export function buildDraftAgentControls(input: {
onSelectThinkingOption: formState.setThinkingOptionFromUser,
onApplyAgentProfile,
optionControls,
options,
onSetOption,
onDropdownClose,
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
onRetryModelProvider: formState.refreshProviderModels,
isRetryingModelProvider: formState.isProviderModelsRefreshing,
modelSelectorServerId: formState.selectedServerId,
context,
metadata,
onSetMetadata,
};
}
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { UserComposerAttachment } from "@/attachments/types";
import type { TextReplacement } from "@/composer/types";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
@@ -28,6 +29,8 @@ import { useShallow } from "zustand/shallow";
import type { ComposerTextSource } from "@/composer/text-source";
import { isWeb } from "@/constants/platform";
const NO_METADATA: Record<string, JsonValue> = {};
type AttachmentUpdater =
| UserComposerAttachment[]
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
@@ -36,6 +39,8 @@ interface AgentInputDraftComposerOptions {
initialServerId: string | null;
initialValues?: CreateAgentInitialValues;
initialProviderOptions?: Record<string, unknown>;
/** Which composer a plugin's `useComposer()` reports. */
context?: "draft" | "new-workspace";
isVisible?: boolean;
lockedWorkingDir?: string;
}
@@ -51,6 +56,8 @@ type DraftComposerState = UseAgentFormStateResult & {
effectiveModelId: string;
effectiveThinkingOptionId: string;
providerOptions: Record<string, unknown> | undefined;
/** What plugins set on the agent this draft creates. */
metadata: Record<string, JsonValue>;
agentControls: DraftAgentControlsProps;
commandDraft: DraftCommandTarget;
};
@@ -191,10 +198,18 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
[saveDraft],
);
const metadata = useDraftStore((state) => state.metadataByDraftKey[draftKey] ?? NO_METADATA);
const setMetadata = useCallback(
(patch: Record<string, JsonValue>) =>
useDraftStore.getState().patchDraftMetadata({ draftKey, patch }),
[draftKey],
);
const clear = useCallback(
(lifecycle: "sent" | "abandoned") => {
textPublication.cancel();
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
if (lifecycle === "sent") useDraftStore.getState().clearDraftMetadata({ draftKey });
},
[draftKey, textPublication],
);
@@ -276,10 +291,13 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
const {
optionControls: draftOptionControls,
options: draftOptions,
shownOptions: draftShownOptions,
setOption: setDraftOption,
applyProfile: applyProfileOptions,
} = useDraftProviderOptions({
serverId: formState.selectedServerId,
provider: formState.selectedProvider,
getProvider: formState.getSelectedProvider,
cwd: workingDir,
modeId: formState.selectedMode,
modelId: effectiveModelId,
@@ -319,10 +337,16 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveModelId,
effectiveThinkingOptionId,
providerOptions: draftOptions,
metadata,
agentControls: buildDraftAgentControls({
formState,
optionControls: draftOptionControls,
options: draftShownOptions,
onSetOption: setDraftOption,
onApplyAgentProfile: applyDraftAgentProfile,
context: composerOptions.context ?? "draft",
metadata,
onSetMetadata: setMetadata,
}),
commandDraft,
};
@@ -334,8 +358,12 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveThinkingOptionId,
draftOptionControls,
draftOptions,
draftShownOptions,
setDraftOption,
applyDraftAgentProfile,
formState,
metadata,
setMetadata,
workingDir,
]);
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { applyMetadataPatch } from "./metadata";
describe("applyMetadataPatch", () => {
it("replaces top-level keys and removes keys patched to null", () => {
expect(
applyMetadataPatch(
{ "linear.issue": { id: "ENG-1" }, review: true },
{ "linear.issue": { id: "ENG-2" }, review: null, extra: [1] },
),
).toEqual({ "linear.issue": { id: "ENG-2" }, extra: [1] });
});
it("leaves the current record untouched", () => {
const current = { review: true };
applyMetadataPatch(current, { review: null });
expect(current).toEqual({ review: true });
});
});
@@ -0,0 +1,14 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
/** Top-level keys replace; `null` removes the key. The daemon applies the same rule to an agent. */
export function applyMetadataPatch(
current: Readonly<Record<string, JsonValue>>,
patch: Readonly<Record<string, JsonValue>>,
): Record<string, JsonValue> {
const next = { ...current };
for (const [key, value] of Object.entries(patch)) {
if (value === null) delete next[key];
else next[key] = value;
}
return next;
}
@@ -1,5 +1,6 @@
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
@@ -133,6 +134,7 @@ async function submitDraftCreateRequest(input: {
effectiveModelId: string | null;
effectiveThinkingOptionId: string | null;
providerOptions: Record<string, unknown> | undefined;
metadata: Record<string, JsonValue>;
};
hostDisconnectedMessage: string;
selectModelMessage: string;
@@ -182,6 +184,7 @@ async function submitDraftCreateRequest(input: {
workspaceId,
initialPrompt: text,
clientMessageId: attempt.clientMessageId,
...(Object.keys(composerState.metadata).length > 0 ? { metadata: composerState.metadata } : {}),
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
};
@@ -510,7 +513,9 @@ export function WorkspaceDraftAgentTab({
providers: composerState.modelSelectorProviders,
selectedProvider: draftProvider,
selectedModelId: composerState.effectiveModelId,
select: composerState.setProviderAndModelFromUser,
select: (provider, modelId) => {
composerState.setProviderAndModelFromUser(provider, modelId);
},
},
thinking: {
options: draftThinkingOptions,
+57 -12
View File
@@ -139,6 +139,8 @@ import type {
} from "@/attachments/types";
import type { SelectedFile } from "@/attachments/selected-file";
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
import { ComposerInputProvider, type ComposerInput } from "@/composer/session";
import { insertAtSelection } from "@/composer/insert-text";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
import { droppedItemsToSelectedFiles } from "@/composer/attachments/drop";
@@ -1419,6 +1421,40 @@ function ComposerContentImpl({
[onChangeText],
);
const insertUserInput = useCallback(
(text: string) => {
const input = messageInputRef.current;
if (!input) {
onChangeText(textSource.getSnapshot() + text);
return;
}
const inserted = insertAtSelection(input.getInputSnapshot(), text);
input.replaceText(inserted.text, inserted.selection);
},
[onChangeText, textSource],
);
const isComposerLockedRef = useRef(isComposerLocked);
isComposerLockedRef.current = isComposerLocked;
const composerInput = useMemo<ComposerInput>(
() => ({
locked: isComposerLocked,
isLocked: () => isComposerLockedRef.current,
textSource,
setText: replaceUserInput,
insertText: insertUserInput,
attachments,
changeAttachments: setSelectedAttachments,
}),
[
attachments,
insertUserInput,
isComposerLocked,
replaceUserInput,
setSelectedAttachments,
textSource,
],
);
const runClientSlashCommand = useCallback(
(command: ClientSlashCommand): boolean => {
if (command.execution !== "immediate" || !onClientSlashCommand) {
@@ -2223,18 +2259,27 @@ function ComposerContentImpl({
[attachments, setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
);
const leftContent = useMemo(
() =>
renderLeftContent({
agentControls,
agentId,
serverId,
focusInput,
isCompactLayout,
showAgentControls: mode.showAgentControls,
}),
[agentControls, agentId, focusInput, isCompactLayout, mode.showAgentControls, serverId],
);
const leftContent = useMemo(() => {
const controls = renderLeftContent({
agentControls,
agentId,
serverId,
focusInput,
isCompactLayout,
showAgentControls: mode.showAgentControls,
});
return controls ? (
<ComposerInputProvider value={composerInput}>{controls}</ComposerInputProvider>
) : null;
}, [
agentControls,
agentId,
composerInput,
focusInput,
isCompactLayout,
mode.showAgentControls,
serverId,
]);
const handleAttachButtonRef = useCallback((node: View | null) => {
attachButtonRef.current = node;
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { insertAtSelection } from "./insert-text";
describe("insertAtSelection", () => {
it("inserts at the caret and moves the caret after the insertion", () => {
expect(insertAtSelection({ text: "ab", selection: { start: 1, end: 1 } }, "XY")).toEqual({
text: "aXYb",
selection: { start: 3, end: 3 },
});
});
it("replaces the selection, however it was dragged", () => {
expect(insertAtSelection({ text: "abcd", selection: { start: 3, end: 1 } }, "-")).toEqual({
text: "a-d",
selection: { start: 2, end: 2 },
});
});
});
+15
View File
@@ -0,0 +1,15 @@
import type { ComposerInputSnapshot } from "@/composer/input/input";
/** The text with `inserted` in place of the selection, and the caret after it. */
export function insertAtSelection(
{ text, selection }: ComposerInputSnapshot,
inserted: string,
): ComposerInputSnapshot {
const start = Math.min(selection.start, selection.end);
const end = Math.max(selection.start, selection.end);
const caret = start + inserted.length;
return {
text: text.slice(0, start) + inserted + text.slice(end),
selection: { start: caret, end: caret },
};
}
+89
View File
@@ -0,0 +1,89 @@
import { createContext, useContext, type ReactNode } from "react";
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
import type { UserComposerAttachment } from "@/attachments/types";
import type { ComposerTextSource } from "@/composer/text-source";
/**
* The text and attachments of the composer a control sits in. `Composer` provides it, so a
* control reaches the prompt without knowing which screen mounted the composer.
*/
export interface ComposerInput {
/** The prompt is held while the composer submits; the built-in input is read-only meanwhile. */
locked: boolean;
/** `locked` as it is now. A command a plugin kept from an earlier render asks this. */
isLocked: () => boolean;
textSource: ComposerTextSource;
setText: (text: string) => void;
/** Inserts at the cursor, replacing the selection. */
insertText: (text: string) => void;
attachments: readonly UserComposerAttachment[];
changeAttachments: (
updater: (current: UserComposerAttachment[]) => UserComposerAttachment[],
) => void;
}
/**
* The agent a composer configures, as one set of reads and commands. A running agent sends each
* command to the daemon; a draft keeps it locally until the agent is created. Controls call the
* same command either way.
*/
export interface ComposerAgent {
context: "agent" | "draft" | "new-workspace";
/** The agent is being created; the built-in controls are disabled meanwhile. */
locked: boolean;
/** `locked` as it is now. A command a plugin kept from an earlier render asks this. */
isLocked: () => boolean;
provider: string | null;
model: string | null;
mode: string | null;
thinking: string | null;
/** The options the agent runs with, defaults included. */
options: ProviderOptions;
metadata: Record<string, JsonValue>;
setProvider: (provider: string) => Promise<void>;
setModel: (modelId: string) => Promise<void>;
setMode: (modeId: string) => Promise<void>;
setThinking: (thinkingOptionId: string) => Promise<void>;
/** Top-level keys replace; `null` removes the agent's own value. */
setOptions: (patch: ProviderOptions) => Promise<void>;
/** Top-level keys replace; `null` removes the key. */
setMetadata: (patch: Record<string, JsonValue>) => Promise<void>;
}
const ComposerInputContext = createContext<ComposerInput | null>(null);
const ComposerAgentContext = createContext<ComposerAgent | null>(null);
export function ComposerInputProvider({
value,
children,
}: {
value: ComposerInput;
children: ReactNode;
}) {
return <ComposerInputContext.Provider value={value}>{children}</ComposerInputContext.Provider>;
}
export function ComposerAgentProvider({
value,
children,
}: {
value: ComposerAgent | null;
children: ReactNode;
}) {
return <ComposerAgentContext.Provider value={value}>{children}</ComposerAgentContext.Provider>;
}
export function useComposerInput(): ComposerInput | null {
return useContext(ComposerInputContext);
}
export function useComposerAgent(): ComposerAgent | null {
return useContext(ComposerAgentContext);
}
/** True while the composer holds its prompt or its agent's settings for a submit in flight. */
export function useComposerLocked(): boolean {
const input = useComposerInput();
const agent = useComposerAgent();
return Boolean(input?.locked || agent?.locked);
}
+59 -52
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import type {
AgentMode,
@@ -28,14 +28,13 @@ import {
mergeSelectedComposerPreferences,
buildProviderDefinitionMap,
buildProviderDefinitionMapForStatuses,
INITIAL_AGENT_FORM_RESOLUTION,
INITIAL_USER_MODIFIED,
RESOLVABLE_PROVIDER_STATUSES,
SELECTABLE_PROVIDER_STATUSES,
type FormInitialValues,
type FormState,
type ProviderModelsByProvider,
} from "@/provider-selection/resolve-agent-form";
import { openAgentForm, type AgentFormCatalog } from "@/provider-selection/agent-form-store";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { MaterializedAgentProfile } from "@/agent-profiles";
@@ -52,10 +51,13 @@ export interface UseAgentFormStateOptions {
export interface UseAgentFormStateResult {
selectedServerId: string | null;
selectedProvider: AgentProvider | null;
/** The provider as it is now, which a render may not have reached yet. */
getSelectedProvider: () => AgentProvider | null;
selectedMode: string;
setModeFromUser: (modeId: string) => void;
selectedModel: string;
setModelFromUser: (modelId: string) => void;
/** `false` when the provider's loaded catalog does not list the model. */
setModelFromUser: (modelId: string) => boolean;
selectedThinkingOptionId: string;
setThinkingOptionFromUser: (thinkingOptionId: string) => void;
workingDir: string;
@@ -74,13 +76,20 @@ export interface UseAgentFormStateResult {
modelError: string | null;
refreshProviderModels: (provider?: AgentProvider) => void;
refetchProviderModelsIfStale: () => void;
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
/** `false` when the provider is unknown or not ready. */
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => boolean;
applyProfileFromUser: (profile: MaterializedAgentProfile) => void;
clearProviderSelectionFromUser: () => void;
workingDirIsEmpty: boolean;
persistFormPreferences: () => Promise<void>;
}
const EMPTY_CATALOG: AgentFormCatalog = {
selectableProviders: new Map(),
models: new Map(),
providerPreferences: () => undefined,
};
function resolveSelectedProviderModes(input: {
selectedEntry: ProviderSnapshotEntry | null;
provider: AgentProvider | null;
@@ -172,11 +181,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
[updatePreferences],
);
const [{ form: formState, userModified, resolution }, dispatch] = useReducer(resolveAgentForm, {
form: { provider: null, modeId: "", model: "", thinkingOptionId: "" },
userModified: INITIAL_USER_MODIFIED,
resolution: INITIAL_AGENT_FORM_RESOLUTION,
});
// The store reads what the host offers when a command runs; each render publishes the latest.
const catalogRef = useRef<AgentFormCatalog>(EMPTY_CATALOG);
const [formStore] = useState(() => openAgentForm(() => catalogRef.current));
const { form: formState, resolution } = useSyncExternalStore(
formStore.subscribe,
formStore.getState,
formStore.getState,
);
const dispatch = formStore.dispatch;
const {
entries: snapshotEntries,
@@ -246,6 +259,12 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
const providerDefinitionMap = snapshotProviderDefinitionMap;
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
const allProviderModels = snapshotAllProviderModels;
catalogRef.current = {
selectableProviders: selectableProviderDefinitionMap,
models: allProviderModels,
providerPreferences: (provider) =>
preferenceOverlayRef.current.current().providerPreferences?.[provider],
};
const modelSelectorProviders = snapshotModelSelectorProviders;
const availableModels = snapshotSelectedProviderModels;
const modeOptions = snapshotSelectedProviderModes;
@@ -277,27 +296,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
preferences,
snapshotProviderModelsByProvider,
snapshotResolvableProviderDefinitionMap,
dispatch,
]);
const setProviderAndModelFromUser = useCallback(
(provider: AgentProvider, modelId: string) => {
if (!selectableProviderDefinitionMap.has(provider)) {
return;
}
const providerDef = selectableProviderDefinitionMap.get(provider);
(provider: AgentProvider, modelId: string): boolean => {
if (!formStore.setProviderAndModel(provider, modelId)) return false;
const providerModels = allProviderModels.get(provider) ?? null;
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
const normalizedModelId = normalizeSelectedModelId(modelId);
const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels);
dispatch({
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
provider,
modelId,
providerDef,
providerModels,
providerPrefs,
});
const nextModelId =
normalizeSelectedModelId(modelId) || resolveDefaultModelId(providerModels);
void updateCurrentPreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
@@ -307,13 +314,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
},
}),
);
return true;
},
[allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences],
[allProviderModels, formStore, updateCurrentPreferences],
);
const clearProviderSelectionFromUser = useCallback(() => {
dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" });
}, []);
}, [dispatch]);
const applyProfileFromUser = useCallback(
(profile: MaterializedAgentProfile) => {
@@ -322,7 +330,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
return;
}
const previousProvider = formState.provider;
const formBefore = formStore.getState();
const previousProvider = formBefore.form.provider;
const providerDef = selectableProviderDefinitionMap.get(provider);
const providerModels = allProviderModels.get(provider) ?? null;
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
@@ -336,7 +345,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
providerModels,
providerPrefs,
};
const nextState = resolveAgentForm({ form: formState, userModified, resolution }, action);
const nextState = resolveAgentForm(formBefore, action);
const previousProviderModeIds = previousProvider
? (providerDefinitionMap.get(previousProvider)?.modes.map((mode) => mode.id) ?? [])
: [];
@@ -360,19 +369,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
},
[
allProviderModels,
formState,
dispatch,
formStore,
providerDefinitionMap,
resolution,
selectableProviderDefinitionMap,
updateCurrentPreferences,
userModified,
],
);
const setModeFromUser = useCallback(
(modeId: string) => {
dispatch({ type: "SET_MODE_FROM_USER", modeId });
const provider = formState.provider;
const { provider } = formStore.getState().form;
if (provider) {
void updateCurrentPreferences((current) =>
mergeSelectedComposerPreferences({
@@ -385,24 +393,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
);
}
},
[formState.provider, updateCurrentPreferences],
[dispatch, formStore, updateCurrentPreferences],
);
const setModelFromUser = useCallback(
(modelId: string) => {
const provider = formState.provider;
const providerPrefs = provider
? preferenceOverlayRef.current.current().providerPreferences?.[provider]
: undefined;
dispatch({
type: "SET_MODEL_FROM_USER",
modelId,
availableModels,
providerPrefs,
});
(modelId: string): boolean => {
if (!formStore.setModel(modelId)) return false;
const { provider } = formStore.getState().form;
if (provider) {
const normalizedModelId = normalizeSelectedModelId(modelId);
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
const listed = allProviderModels.get(provider);
const nextModelId =
normalizeSelectedModelId(modelId) ||
resolveDefaultModelId(listed?.length ? listed : null);
void updateCurrentPreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
@@ -413,14 +415,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
}),
);
}
return true;
},
[availableModels, formState.provider, updateCurrentPreferences],
[allProviderModels, formStore, updateCurrentPreferences],
);
const setThinkingOptionFromUser = useCallback(
(thinkingOptionId: string) => {
dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId });
const { provider, model: modelId } = formState;
const { provider, model: modelId } = formStore.getState().form;
if (provider && modelId) {
void updateCurrentPreferences((current) =>
mergeSelectedComposerPreferences({
@@ -435,9 +438,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
);
}
},
[formState, updateCurrentPreferences],
[dispatch, formStore, updateCurrentPreferences],
);
const getSelectedProvider = useCallback(() => formStore.getState().form.provider, [formStore]);
const refreshProviderModels = useCallback(
(provider?: AgentProvider) => {
void refreshSnapshot(provider ? [provider] : undefined);
@@ -479,6 +484,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
() => ({
selectedServerId: serverId,
selectedProvider: formState.provider,
getSelectedProvider,
selectedMode: formState.modeId,
setModeFromUser,
selectedModel: formState.model,
@@ -509,6 +515,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
}),
[
serverId,
getSelectedProvider,
formState.provider,
formState.modeId,
formState.model,
@@ -5,6 +5,7 @@ import {
draftControlsRequest,
openDraftOptions,
ownDraftOptions,
shownDraftOptions,
viewDraftOptions,
} from "@/agent-controls/draft-options-model";
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
@@ -19,6 +20,8 @@ import { mergeProviderPreferences, useFormPreferences } from "./use-form-prefere
export function useDraftProviderOptions(input: {
serverId: string | null | undefined;
provider: AgentProvider | null;
/** The form's provider as it is now, which a render may not have reached yet. */
getProvider: () => AgentProvider | null;
cwd: string | null | undefined;
modeId: string | null | undefined;
modelId: string | null | undefined;
@@ -26,8 +29,19 @@ export function useDraftProviderOptions(input: {
initialOptions?: ProviderOptions;
}) {
const { t } = useTranslation();
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialOptions } = input;
const [model] = useState(() => openDraftOptions({ provider, seed: initialOptions }));
const {
serverId,
provider,
getProvider,
cwd,
modeId,
modelId,
thinkingOptionId,
initialOptions,
} = input;
const [model] = useState(() =>
openDraftOptions({ provider, seed: initialOptions, currentProvider: getProvider }),
);
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
@@ -77,21 +91,23 @@ export function useDraftProviderOptions(input: {
if (provider && data) model.applyResolved({ provider, requestKey, ...data });
}, [data, model, provider, requestKey]);
// An edit belongs to the provider the form has now, even before the render that shows it.
const setOption = useCallback(
(key: string, value: unknown) => {
model.setOption(key, value);
if (!provider) return;
void updatePreferences((current) =>
const current = getProvider();
if (!current) return;
void updatePreferences((stored) =>
mergeProviderPreferences({
preferences: current,
provider,
preferences: stored,
provider: current,
updates: { options: { [key]: value } },
}),
).catch((error) => {
console.warn("[useDraftProviderOptions] persist option preference failed", error);
});
},
[model, provider, updatePreferences],
[getProvider, model, updatePreferences],
);
const view = viewDraftOptions(state, { provider, requestKey });
@@ -105,5 +121,14 @@ export function useDraftProviderOptions(input: {
[setOption, viewControls, viewOptions],
);
return { optionControls, options: ownOptions, applyProfile: model.applyProfile };
// What the draft will create with, the plugin's own edits included, before the daemon answers.
const shownOptions = shownDraftOptions(state, { provider, requestKey });
return {
optionControls,
options: ownOptions,
shownOptions,
setOption,
applyProfile: model.applyProfile,
};
}
+5
View File
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ar: TranslationResources = {
pluginCombobox: {
searching: "جارٍ البحث…",
empty: "لا توجد نتائج",
failed: "فشل البحث: {{message}}",
},
paneFind: {
connectionFailure: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.",
historyChangedFailure: "تغيّرت المحادثة أثناء البحث. أعد البحث.",
+5
View File
@@ -1,4 +1,9 @@
export const en = {
pluginCombobox: {
searching: "Searching…",
empty: "No results",
failed: "Search failed: {{message}}",
},
paneFind: {
connectionFailure: "Could not search this chat. Check the host connection and retry.",
historyChangedFailure: "The chat changed while searching. Search again.",
+5
View File
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const es: TranslationResources = {
pluginCombobox: {
searching: "Buscando…",
empty: "Sin resultados",
failed: "La búsqueda falló: {{message}}",
},
paneFind: {
connectionFailure:
"No se pudo buscar en este chat. Comprueba la conexión con el host y vuelve a intentarlo.",
+5
View File
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const fr: TranslationResources = {
pluginCombobox: {
searching: "Recherche…",
empty: "Aucun résultat",
failed: "Échec de la recherche : {{message}}",
},
paneFind: {
connectionFailure:
"Impossible de rechercher dans cette conversation. Vérifiez la connexion à l’hôte et réessayez.",
+5
View File
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ja: TranslationResources = {
pluginCombobox: {
searching: "検索中…",
empty: "結果がありません",
failed: "検索に失敗しました: {{message}}",
},
paneFind: {
connectionFailure:
"このチャットを検索できませんでした。ホストへの接続を確認して再試行してください。",
+5
View File
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ko: TranslationResources = {
pluginCombobox: {
searching: "검색 중…",
empty: "결과 없음",
failed: "검색 실패: {{message}}",
},
paneFind: {
connectionFailure: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.",
historyChangedFailure: "검색 중에 채팅이 변경되었습니다. 다시 검색하세요.",
+5
View File
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ptBR: TranslationResources = {
pluginCombobox: {
searching: "Pesquisando…",
empty: "Nenhum resultado",
failed: "Falha na pesquisa: {{message}}",
},
paneFind: {
connectionFailure:
"Não foi possível pesquisar nesta conversa. Verifique a conexão com o host e tente novamente.",
+5
View File
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const ru: TranslationResources = {
pluginCombobox: {
searching: "Поиск…",
empty: "Нет результатов",
failed: "Не удалось выполнить поиск: {{message}}",
},
paneFind: {
connectionFailure:
"Не удалось выполнить поиск в чате. Проверьте подключение к хосту и повторите попытку.",
+5
View File
@@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en";
import { pluginSettings } from "./plugin-settings";
export const zhCN: TranslationResources = {
pluginCombobox: {
searching: "搜索中…",
empty: "无结果",
failed: "搜索失败:{{message}}",
},
paneFind: {
connectionFailure: "无法搜索此聊天。请检查主机连接后重试。",
historyChangedFailure: "搜索期间聊天已更改。请重新搜索。",
@@ -23,6 +23,8 @@ function installation(): InstalledPlugin {
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -53,6 +53,7 @@ const agent: Agent = {
attentionReason: "permission",
parentAgentId: null,
labels: { phase: "implementation" },
metadata: { "linear.issue": { id: "ENG-1" } },
};
describe("plugin context snapshots", () => {
@@ -107,12 +108,16 @@ describe("plugin context snapshots", () => {
attentionReason: "permission",
parentAgentId: null,
labels: { phase: "implementation" },
metadata: { "linear.issue": { id: "ENG-1" } },
});
expect(snapshot.labels).not.toBe(agent.labels);
expect(Object.isFrozen(snapshot)).toBe(true);
expect(Object.isFrozen(snapshot.labels)).toBe(true);
expect(Reflect.set(snapshot, "title", "Hostile rename")).toBe(false);
expect(Reflect.set(snapshot.labels, "phase", "hostile")).toBe(false);
const issue = snapshot.metadata["linear.issue"] as { id: string };
expect(Reflect.set(issue, "id", "hostile")).toBe(false);
expect(agent.metadata?.["linear.issue"]).toEqual({ id: "ENG-1" });
expect(snapshot.title).toBe("Implement plugin panels");
expect(snapshot.labels).toEqual({ phase: "implementation" });
expect(agent.title).toBe("Implement plugin panels");
@@ -1,5 +1,6 @@
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@getpaseo/plugin";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { cloneAndFreeze } from "../freeze";
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, PluginWorkspaceSnapshot>();
const agentSnapshots = new WeakMap<Agent, Map<string, PluginAgentSnapshot>>();
@@ -49,6 +50,7 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
attentionReason: agent.attentionReason ?? null,
parentAgentId: agent.parentAgentId,
labels: Object.freeze({ ...agent.labels }),
metadata: cloneAndFreeze(agent.metadata ?? {}),
});
const byWorkspace = agentSnapshots.get(agent) ?? new Map<string, PluginAgentSnapshot>();
byWorkspace.set(workspaceId, snapshot);
@@ -46,6 +46,7 @@ const agent: PluginAgentSnapshot = {
attentionReason: null,
parentAgentId: null,
labels: {},
metadata: {},
};
const inspect = defineRpc({
@@ -82,6 +83,8 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspacePanels: [
{
id: "details",
@@ -0,0 +1,96 @@
import { useMemo, useSyncExternalStore } from "react";
import type { PluginHostProps } from "@getpaseo/plugin/client";
import { PluginComposerProvider } from "@getpaseo/plugin/client/host";
import { withUnistyles } from "react-native-unistyles";
import {
ComposerControlSurfaceProvider,
type ComposerControlSurface,
} from "@/composer/agent-controls/composer-control";
import { useComposerAgent, useComposerInput } from "@/composer/session";
import { PluginItemList, pluginThemeMapping, usePluginItemHost } from "../item-host";
import { PluginEnvironmentProvider } from "../popover";
import { useInstalledPlugins } from "../registry";
import type { InstalledPlugin } from "../types";
import { toPluginComposer } from "./model";
function useServerPlugins(serverId: string | null): InstalledPlugin[] {
const installed = useInstalledPlugins();
return useMemo(
() =>
serverId === null
? []
: installed.filter(
(plugin) => plugin.serverId === serverId && plugin.composerItems.length > 0,
),
[installed, serverId],
);
}
/** How many composer items the host's plugins registered; the toolbar budgets room for them. */
export function usePluginComposerItemCount(serverId: string | null): number {
const plugins = useServerPlugins(serverId);
return plugins.reduce((count, plugin) => count + plugin.composerItems.length, 0);
}
/**
* The host's plugin composer items. They read and command the composer they are mounted in, so
* they render only where a composer provides its input and agent.
*/
export function PluginComposerItems({
serverId,
surface,
}: {
serverId: string | null;
surface: ComposerControlSurface;
}) {
const plugins = useServerPlugins(serverId);
if (serverId === null || plugins.length === 0) return null;
return (
<ComposerControlSurfaceProvider value={surface}>
{plugins.map((plugin) => (
<ThemedPluginComposerItems key={plugin.id} plugin={plugin} uniProps={pluginThemeMapping} />
))}
</ComposerControlSurfaceProvider>
);
}
function PluginComposerItemsHost({
plugin,
theme,
}: {
plugin: InstalledPlugin;
theme: PluginHostProps["theme"];
}) {
const agent = useComposerAgent();
const composerInput = useComposerInput();
const { environment, props } = usePluginItemHost(plugin, theme);
const textSource = composerInput?.textSource;
const prompt = useSyncExternalStore(
textSource?.subscribe ?? subscribeNothing,
textSource?.getSnapshot ?? emptyText,
textSource?.getSnapshot ?? emptyText,
);
const composer = useMemo(
() =>
agent && composerInput ? toPluginComposer({ plugin, agent, composerInput, prompt }) : null,
[agent, composerInput, plugin, prompt],
);
if (!composer) return null;
return (
<PluginEnvironmentProvider environment={environment}>
<PluginComposerProvider composer={composer}>
<PluginItemList plugin={plugin} items={plugin.composerItems} props={props} />
</PluginComposerProvider>
</PluginEnvironmentProvider>
);
}
const ThemedPluginComposerItems = withUnistyles(PluginComposerItemsHost);
function subscribeNothing() {
return () => undefined;
}
function emptyText() {
return "";
}
@@ -0,0 +1,312 @@
import { defineRpc } from "@getpaseo/plugin";
import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host";
import { QueryClient } from "@tanstack/react-query";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { z } from "zod";
import { describe, expect, it } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { ComposerAgent, ComposerInput } from "@/composer/session";
import type { InstalledPlugin } from "../types";
import { toPluginComposer } from "./model";
function installed(): InstalledPlugin {
return {
id: "review",
serverId: "host-1",
clientBundle: "bundle",
lifetime: new AbortController(),
locale: "en",
i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }),
queryClient: new QueryClient(),
paseo: {} as InstalledPlugin["paseo"],
invoke: async () => undefined,
cleanup: () => {},
settingsScreens: [],
surfaces: [],
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
attachmentSources: [
{
id: "issues",
title: "Issues",
pickerTitle: "Issues",
searchPlaceholder: "Search issues",
icon: "Bug",
search: defineRpc({
name: "issues.search",
input: z.object({}),
output: z.array(z.object({})),
}),
},
],
themes: [],
timelineTransformers: [],
timelineRenderers: [],
};
}
const issue = {
id: "ISS-1",
identifier: "ISS-1",
title: "Fix it",
url: "https://example.com/ISS-1",
text: "Fix it",
resourceType: "issue",
};
function fakeAgent(context: ComposerAgent["context"], locked = false) {
const calls: string[] = [];
const agent: ComposerAgent = {
context,
locked,
isLocked: () => locked,
provider: "claude",
model: "sonnet",
mode: "default",
thinking: null,
options: { effort: "high" },
metadata: { flag: true },
// A running agent keeps its provider, as the real adapter does.
setProvider: async (provider) => {
if (context === "agent") throw new Error("A running agent cannot change provider.");
calls.push(`provider:${provider}`);
},
setModel: async (model) => void calls.push(`model:${model}`),
setMode: async (mode) => void calls.push(`mode:${mode}`),
setThinking: async (thinking) => void calls.push(`thinking:${thinking}`),
setOptions: async (patch) => void calls.push(`options:${JSON.stringify(patch)}`),
setMetadata: async (patch: Record<string, JsonValue>) =>
void calls.push(`metadata:${JSON.stringify(patch)}`),
};
return { agent, calls };
}
function fakeInput(initial: UserComposerAttachment[] = [], locked = false) {
const state = { text: "", attachments: initial };
const input: ComposerInput = {
locked,
isLocked: () => locked,
textSource: { getSnapshot: () => state.text, subscribe: () => () => {} },
setText: (text) => {
state.text = text;
},
insertText: (text) => {
state.text += text;
},
get attachments() {
return state.attachments;
},
changeAttachments: (updater) => {
state.attachments = updater(state.attachments);
},
};
return { input, state };
}
describe("toPluginComposer", () => {
it("reads the agent the composer configures and the context it sits in", () => {
const { agent } = fakeAgent("new-workspace");
const { input } = fakeInput();
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "hello",
});
expect(composer.context).toBe("new-workspace");
expect(composer.prompt).toBe("hello");
expect(composer.agent).toEqual({
provider: "claude",
model: "sonnet",
mode: "default",
thinking: null,
options: { effort: "high" },
metadata: { flag: true },
});
});
it.each(["agent", "draft"] as const)(
"sends every command to the %s composer's agent",
async (context) => {
const { agent, calls } = fakeAgent(context);
const { input } = fakeInput();
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
await composer.setModel("gpt");
await composer.setMode("plan");
await composer.setThinking("high");
await composer.setOptions({ effort: "low" });
await composer.setMetadata({ flag: null });
expect(calls).toEqual([
"model:gpt",
"mode:plan",
"thinking:high",
'options:{"effort":"low"}',
'metadata:{"flag":null}',
]);
},
);
it("lets a draft change provider and tells a running agent it cannot", async () => {
const { input } = fakeInput();
const draft = fakeAgent("draft");
await toPluginComposer({
plugin: installed(),
agent: draft.agent,
composerInput: input,
prompt: "",
}).setProvider("codex");
expect(draft.calls).toEqual(["provider:codex"]);
const running = fakeAgent("agent");
await expect(
toPluginComposer({
plugin: installed(),
agent: running.agent,
composerInput: input,
prompt: "",
}).setProvider("codex"),
).rejects.toThrow("A running agent cannot change provider.");
expect(running.calls).toEqual([]);
});
it("changes the prompt and inserts at the cursor", () => {
const { agent } = fakeAgent("draft");
const { input, state } = fakeInput();
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
composer.setPrompt("a");
composer.insertText("b");
expect(state.text).toBe("ab");
});
it("refuses every prompt and agent command while the composer is locked", async () => {
const { agent, calls } = fakeAgent("new-workspace", true);
const { input, state } = fakeInput([], true);
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
expect(() => composer.setPrompt("a")).toThrow("The composer is locked");
expect(() => composer.insertText("b")).toThrow("The composer is locked");
expect(() => composer.addAttachment({ sourceId: "issues", item: issue })).toThrow(
"The composer is locked",
);
await expect(composer.setProvider("codex")).rejects.toThrow("The composer is locked");
await expect(composer.setModel("gpt")).rejects.toThrow("The composer is locked");
await expect(composer.setMode("plan")).rejects.toThrow("The composer is locked");
await expect(composer.setThinking("high")).rejects.toThrow("The composer is locked");
await expect(composer.setOptions({ effort: "low" })).rejects.toThrow("The composer is locked");
await expect(composer.setMetadata({ flag: null })).rejects.toThrow("The composer is locked");
expect(state.text).toBe("");
expect(calls).toEqual([]);
});
it("refuses a command a plugin kept from before the composer locked", async () => {
const { agent } = fakeAgent("new-workspace");
const { input } = fakeInput();
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
// The plugin's action was awaiting an RPC when the user submitted.
const lock = { locked: true };
agent.isLocked = () => lock.locked;
input.isLocked = () => lock.locked;
expect(() => composer.insertText("late")).toThrow("The composer is locked");
expect(() => composer.addAttachment({ sourceId: "issues", item: issue })).toThrow(
"The composer is locked",
);
expect(() => composer.removeAttachment({ sourceId: "issues", itemId: issue.id })).toThrow(
"The composer is locked",
);
await expect(composer.setOptions({ effort: "low" })).rejects.toThrow("The composer is locked");
});
it("hands out options and metadata a plugin cannot change", () => {
const { agent } = fakeAgent("draft");
agent.metadata = { nested: { list: [1] } };
const { input } = fakeInput();
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
expect(Object.isFrozen(composer.agent.options)).toBe(true);
expect(Object.isFrozen(composer.agent.metadata)).toBe(true);
expect(Object.isFrozen((composer.agent.metadata as { nested: object }).nested)).toBe(true);
expect(agent.metadata).toEqual({ nested: { list: [1] } });
expect(Object.isFrozen(agent.metadata)).toBe(false);
});
it("adds and removes the plugin's own attachments only", () => {
const { agent } = fakeAgent("agent");
const other: UserComposerAttachment = {
kind: "plugin_resource",
pluginId: "other",
sourceId: "issues",
sourceTitle: "Issues",
sourceIcon: "Bug",
item: issue,
};
const { input, state } = fakeInput([other]);
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
composer.addAttachment({ sourceId: "issues", item: issue });
composer.addAttachment({ sourceId: "issues", item: issue });
expect(state.attachments).toHaveLength(2);
const next = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
expect(next.attachments).toEqual([{ sourceId: "issues", item: issue }]);
composer.removeAttachment({ sourceId: "issues", itemId: "ISS-1" });
expect(state.attachments).toEqual([other]);
});
it("rejects an attachment from a source the plugin did not register", () => {
const { agent } = fakeAgent("agent");
const { input } = fakeInput();
const composer = toPluginComposer({
plugin: installed(),
agent,
composerInput: input,
prompt: "",
});
expect(() => composer.addAttachment({ sourceId: "nope", item: issue })).toThrow(
"Unknown attachment source: nope",
);
});
});
@@ -0,0 +1,103 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginComposer } from "@getpaseo/plugin/client";
import type { UserComposerAttachment } from "@/attachments/types";
import type { ComposerAgent, ComposerInput } from "@/composer/session";
import type { InstalledPlugin } from "../types";
import { createPluginResourceAttachment } from "../attachments/model";
import { cloneAndFreeze } from "../freeze";
function isPluginAttachment(
attachment: UserComposerAttachment,
pluginId: string,
sourceId: string,
itemId?: string,
): boolean {
return (
attachment.kind === "plugin_resource" &&
attachment.pluginId === pluginId &&
attachment.sourceId === sourceId &&
(itemId === undefined || attachment.item.id === itemId)
);
}
const LOCKED_MESSAGE = "The composer is locked while the agent is being created.";
function assertUnlocked(isLocked: () => boolean): void {
if (isLocked()) throw new Error(LOCKED_MESSAGE);
}
async function whenUnlocked(agent: ComposerAgent, command: () => Promise<void>): Promise<void> {
assertUnlocked(agent.isLocked);
await command();
}
/**
* What a plugin sees of the composer it sits in. `agent` and `input` are the composer's own
* halves, so the plugin's commands reach the same models the built-in controls use.
*/
export function toPluginComposer(input: {
plugin: InstalledPlugin;
agent: ComposerAgent;
composerInput: ComposerInput;
prompt: string;
}): PluginComposer {
const { plugin, agent, composerInput, prompt } = input;
return {
context: agent.context,
prompt,
attachments: composerInput.attachments.flatMap((attachment) =>
attachment.kind === "plugin_resource" && attachment.pluginId === plugin.id
? [{ sourceId: attachment.sourceId, item: attachment.item }]
: [],
),
// Provider options are JSON on the wire.
agent: {
provider: agent.provider,
model: agent.model,
mode: agent.mode,
thinking: agent.thinking,
options: cloneAndFreeze(agent.options as Record<string, JsonValue>),
metadata: cloneAndFreeze(agent.metadata),
},
setPrompt: (text) => {
assertUnlocked(composerInput.isLocked);
composerInput.setText(text);
},
insertText: (text) => {
assertUnlocked(composerInput.isLocked);
composerInput.insertText(text);
},
addAttachment({ sourceId, item }) {
assertUnlocked(composerInput.isLocked);
const source = plugin.attachmentSources.find((candidate) => candidate.id === sourceId);
if (!source) throw new Error(`Unknown attachment source: ${sourceId}`);
const attachment = createPluginResourceAttachment(
{
pluginId: plugin.id,
sourceId,
sourceTitle: plugin.i18n.resolve(source.title, plugin.locale),
sourceIcon: source.icon,
},
item,
);
composerInput.changeAttachments((current) =>
current.some((candidate) => isPluginAttachment(candidate, plugin.id, sourceId, item.id))
? current
: [...current, attachment],
);
},
removeAttachment({ sourceId, itemId }) {
assertUnlocked(composerInput.isLocked);
composerInput.changeAttachments((current) =>
current.filter((candidate) => !isPluginAttachment(candidate, plugin.id, sourceId, itemId)),
);
},
setProvider: (provider) => whenUnlocked(agent, () => agent.setProvider(provider)),
setModel: (modelId) => whenUnlocked(agent, () => agent.setModel(modelId)),
setMode: (modeId) => whenUnlocked(agent, () => agent.setMode(modeId)),
setThinking: (thinkingOptionId) =>
whenUnlocked(agent, () => agent.setThinking(thinkingOptionId)),
setOptions: (patch) => whenUnlocked(agent, () => agent.setOptions(patch)),
setMetadata: (patch) => whenUnlocked(agent, () => agent.setMetadata(patch)),
};
}
+32
View File
@@ -531,6 +531,38 @@ describe("evaluatePluginClientBundle", () => {
).toThrow("Duplicate Command Center item: review");
});
it("collects composer items and releases an id on removal", () => {
const plugin = evaluatePluginClientBundle(
"review",
bundle(`
function Item() { return null; }
const remove = plugin.addComposerItem({ id: "flag", Component: Item });
remove();
plugin.addComposerItem({ id: "flag", Component: Item });
`),
);
expect(plugin.composerItems.map((item) => item.id)).toEqual(["flag"]);
});
it("rejects duplicate and non-component composer items", () => {
expect(() =>
evaluatePluginClientBundle(
"review",
bundle(`
function Item() { return null; }
plugin.addComposerItem({ id: "flag", Component: Item });
plugin.addComposerItem({ id: "flag", Component: Item });
`),
),
).toThrow("Duplicate composer item: flag");
expect(() =>
evaluatePluginClientBundle(
"review",
bundle(`plugin.addComposerItem({ id: "flag", Component: "nope" })`),
),
).toThrow("Composer item flag is not a component");
});
it("runs the client entry with the full runtime context", () => {
const plugin = evaluatePluginClientBundle(
"review",
+34
View File
@@ -131,6 +131,8 @@ export function runPluginClientBundle(
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -146,6 +148,8 @@ export function runPluginClientBundle(
footer: new Set(),
};
const filePanelIds = new Set<string>();
const composerItemIds = new Set<string>();
const newWorkspaceItemIds = new Set<string>();
const workspacePanelIds = new Set<string>();
const commandCenterItemIds = new Set<string>();
const clientSlashCommandNames = new Set<string>();
@@ -269,6 +273,34 @@ export function runPluginClientBundle(
() => surfaceIds.delete(normalizedId),
);
},
addComposerItem(contribution) {
const normalizedId = requireId(contribution.id, "composer item id");
if (composerItemIds.has(normalizedId))
throw new Error(`Duplicate composer item: ${normalizedId}`);
if (!isComponentType(contribution.Component)) {
throw new Error(`Composer item ${normalizedId} is not a component`);
}
composerItemIds.add(normalizedId);
return register(
collector.composerItems,
{ id: normalizedId, Component: contribution.Component },
() => composerItemIds.delete(normalizedId),
);
},
addNewWorkspaceItem(contribution) {
const normalizedId = requireId(contribution.id, "New workspace item id");
if (newWorkspaceItemIds.has(normalizedId))
throw new Error(`Duplicate New workspace item: ${normalizedId}`);
if (!isComponentType(contribution.Component)) {
throw new Error(`New workspace item ${normalizedId} is not a component`);
}
newWorkspaceItemIds.add(normalizedId);
return register(
collector.newWorkspaceItems,
{ id: normalizedId, Component: contribution.Component },
() => newWorkspaceItemIds.delete(normalizedId),
);
},
addSidebarHeaderItem(contribution: PluginSidebarItemContribution) {
return addSidebarItem("header", contribution);
},
@@ -585,6 +617,8 @@ export function runPluginClientBundle(
sidebarItems: collector.sidebarItems,
legacySidebarItems: collector.legacySidebarItems,
filePanels: collector.filePanels,
composerItems: collector.composerItems,
newWorkspaceItems: collector.newWorkspaceItems,
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
commandCenterItems: collector.commandCenterItems,
clientSlashCommands: collector.clientSlashCommands,
+12
View File
@@ -0,0 +1,12 @@
/** A deep copy no plugin can change. Plugins read data through it, never the host's own objects. */
export function cloneAndFreeze<T>(value: T): T {
if (Array.isArray(value)) {
return Object.freeze(value.map((entry) => cloneAndFreeze(entry))) as T;
}
if (value !== null && typeof value === "object") {
return Object.freeze(
Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneAndFreeze(entry)])),
) as T;
}
return value;
}
@@ -36,6 +36,8 @@ function installation(invoked: string[]): InstalledPlugin {
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
+74
View File
@@ -0,0 +1,74 @@
import { useMemo, type ComponentType } from "react";
import type { PluginHostProps } from "@getpaseo/plugin/client";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-api-context";
import { useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import { createPluginClientStateSource } from "./client-state/source";
import { resolvePluginPlatform } from "./platform";
import type { PluginEnvironment } from "./popover";
import { SurfaceErrorBoundary } from "./surface-error-boundary";
import { toPluginTheme } from "./theme";
import type { InstalledPlugin } from "./types";
/** `withUnistyles` mapping that hands a host the plugin theme. */
export const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
function renderNothing() {
return null;
}
/** What a plugin's items on a host surface (composer, New workspace) share: environment and props. */
export function usePluginItemHost(
plugin: InstalledPlugin,
theme: PluginHostProps["theme"],
): { environment: PluginEnvironment; props: PluginHostProps } {
const toast = useToast();
const compact = useIsCompactFormFactor();
const hosts = useHosts();
const hostLabel =
hosts.find((host) => host.serverId === plugin.serverId)?.label ?? plugin.serverId;
const environment = useMemo<PluginEnvironment>(
() => ({
installation: plugin,
toast,
state: createPluginClientStateSource(plugin.serverId),
}),
[plugin, toast],
);
const props = useMemo<PluginHostProps>(
() => ({
theme,
host: { id: plugin.serverId, label: hostLabel },
layout: { compact, platform: resolvePluginPlatform() },
}),
[compact, hostLabel, plugin.serverId, theme],
);
return { environment, props };
}
/** Mounts each item behind its own boundary; a throwing item draws nothing. */
export function PluginItemList({
plugin,
items,
props,
}: {
plugin: InstalledPlugin;
items: readonly { id: string; Component: ComponentType<PluginHostProps> }[];
props: PluginHostProps;
}) {
return (
<>
{items.map(({ id, Component }) => (
<SurfaceErrorBoundary
key={id}
installation={plugin}
Surface={Component}
renderError={renderNothing}
>
<Component {...props} />
</SurfaceErrorBoundary>
))}
</>
);
}
@@ -0,0 +1,101 @@
import { useMemo } from "react";
import type { PluginHostProps } from "@getpaseo/plugin/client";
import { PluginNewWorkspaceProvider } from "@getpaseo/plugin/client/host";
import { withUnistyles } from "react-native-unistyles";
import {
ComposerControlLockProvider,
ComposerControlSurfaceProvider,
} from "@/composer/agent-controls/composer-control";
import type { PickerItem } from "@/screens/new-workspace-picker-item";
import {
useNewWorkspaceSetupState,
type NewWorkspaceSetup,
} from "@/screens/new-workspace/setup-model";
import { PluginItemList, pluginThemeMapping, usePluginItemHost } from "../item-host";
import { PluginEnvironmentProvider } from "../popover";
import { useInstalledPlugins } from "../registry";
import type { InstalledPlugin } from "../types";
import { toPluginNewWorkspace } from "./model";
/** What the screen derives and the items read; the rest is in the setup model. */
export interface NewWorkspaceItemsScreen {
project: { id: string; name: string } | null;
isolation: "local" | "worktree";
baseItem: PickerItem | null;
locked: boolean;
}
function useServerPlugins(serverId: string): InstalledPlugin[] {
const installed = useInstalledPlugins();
return useMemo(
() =>
installed.filter(
(plugin) => plugin.serverId === serverId && plugin.newWorkspaceItems.length > 0,
),
[installed, serverId],
);
}
/**
* 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.
*/
export function PluginNewWorkspaceItems({
serverId,
screen,
setup,
}: {
serverId: string;
screen: NewWorkspaceItemsScreen;
setup: NewWorkspaceSetup;
}) {
const plugins = useServerPlugins(serverId);
return (
<ComposerControlSurfaceProvider value="field">
<ComposerControlLockProvider value={screen.locked}>
{plugins.map((plugin) => (
<ThemedPluginNewWorkspaceItems
key={plugin.id}
plugin={plugin}
screen={screen}
setup={setup}
uniProps={pluginThemeMapping}
/>
))}
</ComposerControlLockProvider>
</ComposerControlSurfaceProvider>
);
}
function PluginNewWorkspaceItemsHost({
plugin,
theme,
screen,
setup,
}: {
plugin: InstalledPlugin;
theme: PluginHostProps["theme"];
screen: NewWorkspaceItemsScreen;
setup: NewWorkspaceSetup;
}) {
const { environment, props } = usePluginItemHost(plugin, theme);
const { branch, metadata } = useNewWorkspaceSetupState(setup);
const newWorkspace = useMemo(
() =>
toPluginNewWorkspace({
host: props.host,
view: { ...screen, branch, metadata },
commands: setup.commands,
}),
[branch, metadata, props.host, screen, setup.commands],
);
return (
<PluginEnvironmentProvider environment={environment}>
<PluginNewWorkspaceProvider newWorkspace={newWorkspace}>
<PluginItemList plugin={plugin} items={plugin.newWorkspaceItems} props={props} />
</PluginNewWorkspaceProvider>
</PluginEnvironmentProvider>
);
}
const ThemedPluginNewWorkspaceItems = withUnistyles(PluginNewWorkspaceItemsHost);
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
buildBranchPickerItems,
pickerItemToCheckoutRequest,
type PickerItem,
} from "@/screens/new-workspace-picker-item";
import { createNewWorkspaceSetup } from "@/screens/new-workspace/setup-model";
import { toPluginNewWorkspace } from "./model";
const HOST = { id: "host", label: "Host" } as Parameters<typeof toPluginNewWorkspace>[0]["host"];
function viewOf(baseItem: PickerItem) {
return {
project: { id: "p", name: "repo" },
isolation: "worktree" as const,
baseItem,
branch: null,
metadata: {},
locked: false,
};
}
describe("the base ref a plugin reads", () => {
it("names the exact ref creation uses, so setting it back picks the same base", async () => {
// `release` exists locally and on origin, and the two have diverged.
const rows = buildBranchPickerItems([
{
name: "release",
committerDate: 0,
hasLocal: true,
hasRemote: true,
localAhead: 2,
localBehind: 1,
},
]);
const origin = rows.find((row) => row.kind === "branch" && row.refName.includes("remotes"));
if (!origin) throw new Error("expected an origin row");
const chosen: PickerItem[] = [];
const setup = createNewWorkspaceSetup(
{
serverId: "host-a",
locked: false,
project: { id: "p", name: "repo" },
canCreateWorktree: true,
supportsMetadata: true,
},
{ persistIsolation: () => {}, syncBaseAttachment: (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),
);
});
});
@@ -0,0 +1,41 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginHostProps, PluginNewWorkspace } from "@getpaseo/plugin/client";
import type { PickerItem } from "@/screens/new-workspace-picker-item";
import type { SetupCommands } from "@/screens/new-workspace/setup-model";
import { cloneAndFreeze } from "../freeze";
/** What the New workspace screen shows at render; the plugin reads it, never writes it. */
export interface NewWorkspaceView {
project: { id: string; name: string } | null;
/** What the screen will create. */
isolation: "local" | "worktree";
baseItem: PickerItem | null;
branch: string | null;
metadata: Readonly<Record<string, JsonValue>>;
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: item.refName }
: { type: "change-request", number: item.item.number };
}
export function toPluginNewWorkspace(input: {
host: PluginHostProps["host"];
view: NewWorkspaceView;
commands: SetupCommands;
}): PluginNewWorkspace {
const { host, view, commands } = input;
return {
host: cloneAndFreeze({ id: host.id, label: host.label }),
project: cloneAndFreeze(view.project),
isolation: view.isolation,
baseRef: cloneAndFreeze(toBaseRef(view.baseItem)),
branch: view.branch,
metadata: cloneAndFreeze(view.metadata),
...commands,
};
}
@@ -0,0 +1,164 @@
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ComboboxItem } from "@getpaseo/plugin/client/ui";
import type { ToastApi } from "@/components/toast-host";
import { ComposerControlSurfaceProvider } from "@/composer/agent-controls/composer-control";
import { ToastApiProvider } from "@/contexts/toast-api-context";
import { i18n } from "@/i18n/i18next";
import { Combobox } from "./composer-controls";
beforeEach(async () => {
vi.stubGlobal("React", React);
await i18n.changeLanguage("en");
});
afterEach(cleanup);
interface Issue extends ComboboxItem {
branch: string;
}
const ONE: Issue = { id: "1", label: "Slow sidebar", branch: "one" };
const TWO: Issue = { id: "2", label: "Crash on resume", branch: "two" };
const THREE: Issue = { id: "3", label: "Crash on launch", branch: "three" };
interface Search {
query: string;
resolve(items: Issue[]): void;
reject(error: Error): void;
}
/** A search the test settles by hand, so it decides which one finishes first. */
function controlledSearch() {
const searches: Search[] = [];
const search = ({ query }: { query: string }) =>
new Promise<Issue[]>((resolve, reject) => {
searches.push({ query, resolve, reject });
});
const forQuery = (query: string) => {
const found = searches.find((candidate) => candidate.query === query);
if (!found) throw new Error(`No search for "${query}" yet`);
return found;
};
const reject = (index: number, error: Error) => searches[index]?.reject(error);
return { search, forQuery, reject, queries: () => searches.map((candidate) => candidate.query) };
}
const toast = { error: vi.fn() } as unknown as ToastApi;
const ignoreSelect = () => undefined;
function Harness({
search,
value,
onSelect = ignoreSelect,
}: {
search: (event: { query: string }) => Promise<Issue[]>;
value: ComboboxItem | null;
onSelect?: (event: { item: Issue }) => void;
}) {
const [queryClient] = React.useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
<ToastApiProvider api={toast}>
<ComposerControlSurfaceProvider value="field">
<Combobox
title="Issue"
icon="CircleDot"
value={value}
search={search}
onSelect={onSelect}
/>
</ComposerControlSurfaceProvider>
</ToastApiProvider>
</QueryClientProvider>
);
}
function renderCombobox(input: {
search: (event: { query: string }) => Promise<Issue[]>;
value?: ComboboxItem | null;
onSelect?: (event: { item: Issue }) => void;
}) {
render(<Harness search={input.search} value={input.value ?? null} onSelect={input.onSelect} />);
fireEvent.click(screen.getByRole("button", { name: "Issue" }));
}
describe("plugin Combobox", () => {
it("searches when the list opens, shows the results, and hands the picked item back", async () => {
const controlled = controlledSearch();
const onSelect = vi.fn();
renderCombobox({ search: controlled.search, onSelect });
await waitFor(() => expect(controlled.queries()).toEqual([""]));
expect(screen.getByText("Searching…")).toBeTruthy();
controlled.forQuery("").resolve([ONE, TWO]);
fireEvent.click(await screen.findByText("Crash on resume"));
expect(onSelect).toHaveBeenCalledWith({ item: TWO });
});
it("shows the error and not stale results when the search fails", async () => {
const controlled = controlledSearch();
renderCombobox({ search: controlled.search });
await waitFor(() => expect(controlled.queries()).toEqual([""]));
controlled.forQuery("").reject(new Error("tracker is down"));
expect(await screen.findByText("Search failed: tracker is down")).toBeTruthy();
});
it("keeps the previous results while a newer search runs and ignores a superseded one", async () => {
const controlled = controlledSearch();
renderCombobox({ search: controlled.search });
await waitFor(() => expect(controlled.queries()).toEqual([""]));
controlled.forQuery("").resolve([ONE, TWO]);
await screen.findByText("Slow sidebar");
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "c" } });
await waitFor(() => expect(controlled.queries()).toEqual(["", "c"]));
expect(screen.getByText("Slow sidebar")).toBeTruthy();
fireEvent.change(input, { target: { value: "cr" } });
await waitFor(() => expect(controlled.queries()).toEqual(["", "c", "cr"]));
controlled.forQuery("cr").resolve([TWO]);
await waitFor(() => expect(screen.queryByText("Slow sidebar")).toBeNull());
controlled.forQuery("c").resolve([ONE]);
// Searches settle in order, so once a later one has landed the stale one has been handled.
fireEvent.change(input, { target: { value: "cra" } });
await waitFor(() => expect(controlled.queries()).toEqual(["", "c", "cr", "cra"]));
controlled.forQuery("cra").resolve([THREE]);
await screen.findByText("Crash on launch");
expect(screen.queryByText("Slow sidebar")).toBeNull();
});
it("shows the error and not the previous results when a reopened list fails to refresh", async () => {
const controlled = controlledSearch();
renderCombobox({ search: controlled.search });
await waitFor(() => expect(controlled.queries()).toEqual([""]));
controlled.forQuery("").resolve([ONE]);
await screen.findByText("Slow sidebar");
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull());
fireEvent.click(screen.getByRole("button", { name: "Issue" }));
await waitFor(() => expect(controlled.queries()).toEqual(["", ""]));
controlled.reject(1, new Error("tracker is down"));
expect(await screen.findByText("Search failed: tracker is down")).toBeTruthy();
expect(screen.queryByText("Slow sidebar")).toBeNull();
});
it("shows a chosen value that the search no longer returns", async () => {
const controlled = controlledSearch();
renderCombobox({ search: controlled.search, value: { id: "9", label: "PAS-9 Old issue" } });
expect(screen.getByRole("button", { name: "Issue" }).textContent).toContain("PAS-9 Old issue");
await waitFor(() => expect(controlled.queries()).toEqual([""]));
controlled.forQuery("").resolve([ONE]);
await screen.findByText("Slow sidebar");
expect(screen.getByRole("button", { name: "Issue" }).textContent).toContain("PAS-9 Old issue");
});
});
@@ -0,0 +1,188 @@
import type {
ActionProps,
ComboboxItem,
ComboboxProps,
SelectProps,
ToggleProps,
} from "@getpaseo/plugin/client/ui";
import { useEffect, useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ComposerControl,
useComposerControlLocked,
useComposerControlSurface,
type ComposerControlItem,
} from "@/composer/agent-controls/composer-control";
import { useComposerLocked } from "@/composer/session";
import { useFetchQuery } from "@/data/query";
import { toErrorMessage } from "@/utils/error-messages";
import { parsePluginIcon, pluginIconComponent } from "../icons";
/** Icons resolve at render, so an invalid one throws to the item's boundary. */
function usePluginControlIcon(icon: ToggleProps["icon"]) {
return useMemo(() => pluginIconComponent(parsePluginIcon("Composer control", icon)), [icon]);
}
/** Draws one plugin control where its item sits: inline, a row in the sheet, or a field. */
function PluginComposerControl({
item,
disabled,
onOpenChange,
}: {
item: ComposerControlItem;
disabled: boolean | undefined;
onOpenChange?: (open: boolean) => void;
}) {
const surface = useComposerControlSurface();
const composerLocked = useComposerLocked();
const fieldLocked = useComposerControlLocked();
const locked = composerLocked || fieldLocked;
const [open, setOpen] = useState(false);
const handleOpenChange = useMemo(
() => (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
},
[onOpenChange],
);
return (
<ComposerControl
item={item}
surface={surface}
disabled={disabled || locked}
open={open}
onOpenChange={handleOpenChange}
/>
);
}
export function Toggle({ title, icon, disabled, value, onValueChange }: ToggleProps) {
const Icon = usePluginControlIcon(icon);
const item = useMemo<ComposerControlItem>(
() => ({
id: "plugin-toggle",
type: "toggle",
title,
icon: Icon,
highlight: value ? "blue" : undefined,
value,
onChange: (next) => onValueChange({ value: next }),
}),
[Icon, onValueChange, title, value],
);
return <PluginComposerControl item={item} disabled={disabled} />;
}
export function Select<Value extends string>({
title,
icon,
disabled,
value,
options,
onValueChange,
}: SelectProps<Value>) {
const Icon = usePluginControlIcon(icon);
const item = useMemo<ComposerControlItem>(
() => ({
id: "plugin-select",
type: "select",
title,
icon: Icon,
value,
choices: options,
onChange: (next) => onValueChange({ value: next as Value }),
showCaret: true,
}),
[Icon, onValueChange, options, title, value],
);
return <PluginComposerControl item={item} disabled={disabled} />;
}
export function Action({ title, icon, disabled, onPress }: ActionProps) {
const Icon = usePluginControlIcon(icon);
const item = useMemo<ComposerControlItem>(
() => ({ id: "plugin-action", type: "action", title, icon: Icon, onPress: () => onPress({}) }),
[Icon, onPress, title],
);
return <PluginComposerControl item={item} disabled={disabled} />;
}
const SEARCH_DEBOUNCE_MS = 200;
const NO_RESULTS: readonly never[] = [];
/** The query settles after the user stops typing; an empty query applies at once. */
function useDebounced(query: string): string {
const [settled, setSettled] = useState(query);
useEffect(() => {
if (query === "") return setSettled("");
const timer = setTimeout(() => setSettled(query), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [query]);
return settled;
}
/**
* Searches while the list is open. The search runs once per settled query, keeps the previous
* results on screen while the next one loads, and drops a search a newer one replaced.
*/
export function Combobox<Item extends ComboboxItem>({
title,
icon,
disabled,
value,
search,
onSelect,
}: ComboboxProps<Item>) {
const { t } = useTranslation();
const Icon = usePluginControlIcon(icon);
const instanceId = useId();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const settled = useDebounced(query);
const results = useFetchQuery({
queryKey: ["plugin-combobox", instanceId, settled],
queryFn: () => search({ query: settled }),
dataShape: "list",
staleTimeMs: 0,
enabled: open,
gcTime: 0,
retry: false,
});
// A failed search shows its error, not the results of an earlier one.
const items: readonly Item[] = results.isError ? NO_RESULTS : (results.data ?? NO_RESULTS);
const item = useMemo<ComposerControlItem>(() => {
const choices = items.map(({ id, label, description }) => ({ value: id, label, description }));
return {
id: "plugin-combobox",
type: "select",
title,
icon: Icon,
value: value?.id ?? null,
valueLabel: value?.label,
choices,
onChange: (id) => {
const picked = items.find((candidate) => candidate.id === id);
return picked ? onSelect({ item: picked }) : undefined;
},
searchable: true,
remoteSearch: true,
onSearchQueryChange: setQuery,
emptyText: comboboxEmptyText({ t, results, query, settled }),
showCaret: true,
};
}, [Icon, items, onSelect, query, results, settled, t, title, value]);
return <PluginComposerControl item={item} disabled={disabled} onOpenChange={setOpen} />;
}
function comboboxEmptyText(input: {
t: (key: string, options?: Record<string, unknown>) => string;
results: { isError: boolean; isFetching: boolean; error: unknown };
query: string;
settled: string;
}): string {
const { t, results, query, settled } = input;
if (results.isError)
return t("pluginCombobox.failed", { message: toErrorMessage(results.error) });
if (results.isFetching || query !== settled) return t("pluginCombobox.searching");
return t("pluginCombobox.empty");
}
@@ -20,6 +20,7 @@ export { FilePanelLoading as Loading } from "@/file-panels/loading";
export { SettingsGroup } from "@/components/settings";
export { Icon } from "../icons";
export { Action, Combobox, Select, Toggle } from "./composer-controls";
export { Modal } from "./modal";
export { ScrollView, FlatList } from "./scroll-view";
export { TextInput } from "./text-input";
+2
View File
@@ -106,6 +106,8 @@ export class PluginRegistry {
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -34,6 +34,8 @@ function installed(
},
legacySidebarItems,
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -36,6 +36,8 @@ function installation(
icon: "Blocks",
})),
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
+2
View File
@@ -61,6 +61,8 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -24,6 +24,8 @@ function plugin(input: {
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -1,5 +1,6 @@
import type { AgentTimelineItem, ToolCallTimelineItem } from "@getpaseo/protocol/agent-types";
import type { AgentToolCallData, PluginTimelineStreamItem, StreamItem } from "@/types/stream";
import { cloneAndFreeze } from "../freeze";
import type { TimelineItemTransform } from "./model";
type AssistantPhase = "streaming" | "complete";
@@ -9,18 +10,6 @@ const projectionCache = new WeakMap<
WeakMap<StreamItem, Partial<Record<AssistantPhase, StreamItem[]>>>
>();
function cloneAndFreeze<T>(value: T): T {
if (Array.isArray(value)) {
return Object.freeze(value.map((entry) => cloneAndFreeze(entry))) as T;
}
if (value !== null && typeof value === "object") {
return Object.freeze(
Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneAndFreeze(entry)])),
) as T;
}
return value;
}
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
const { callId, name, status, error, detail, metadata } = data;
const base = {
+6
View File
@@ -11,6 +11,8 @@ import type {
PluginFilePanelContribution as SDKPluginFilePanelContribution,
PluginCommandCenterItemContribution as SDKPluginCommandCenterItemContribution,
PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution,
PluginComposerItemContribution,
PluginNewWorkspaceItemContribution,
PluginComposerPillContribution as SDKPluginComposerPillContribution,
PluginSidebarContribution as SDKPluginSidebarContribution,
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
@@ -53,6 +55,8 @@ export interface EvaluatedPlugin {
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
legacySidebarItems: PluginSidebarContribution[];
filePanels: PluginFilePanelContribution[];
composerItems: PluginComposerItemContribution[];
newWorkspaceItems: PluginNewWorkspaceItemContribution[];
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
commandCenterItems: PluginCommandCenterItemContribution[];
clientSlashCommands: PluginClientSlashCommandContribution[];
@@ -82,6 +86,8 @@ export type {
PluginAttachmentSourceContribution,
PluginCommandCenterItemContribution,
PluginClientSlashCommandContribution,
PluginComposerItemContribution,
PluginNewWorkspaceItemContribution,
PluginComposerPillContribution,
PluginSidebarContribution,
PluginSidebarItemContribution,
@@ -21,6 +21,8 @@ function installed(): InstalledPlugin {
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
newWorkspaceItems: [],
workspacePanels: [
{
id: "details",
@@ -0,0 +1,93 @@
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
import type { FormPreferences } from "@/hooks/use-form-preferences";
import {
INITIAL_AGENT_FORM_RESOLUTION,
INITIAL_USER_MODIFIED,
isUnlistedModel,
resolveAgentForm,
type AgentFormAction,
type AgentFormReducerState,
} from "@/provider-selection/resolve-agent-form";
/** What the host offers right now. The store reads it when a command runs, never earlier. */
export interface AgentFormCatalog {
/** The providers the host can run. */
selectableProviders: ReadonlyMap<AgentProvider, AgentProviderDefinition>;
/** Each provider's selectable models; empty when the host has listed none. */
models: ReadonlyMap<string, AgentModelDefinition[]>;
providerPreferences: (
provider: AgentProvider,
) => NonNullable<FormPreferences["providerPreferences"]>[AgentProvider] | undefined;
}
/**
* The draft form as a plain model: `resolveAgentForm` applied synchronously. A command reads
* `getState()` and the catalog when it runs, so the one after `await setProvider(...)` sees the
* new provider instead of the render that created it.
*/
export interface AgentFormStore {
getState: () => AgentFormReducerState;
dispatch: (action: AgentFormAction) => void;
subscribe: (listener: () => void) => () => void;
/** `false` when the provider is unknown or not ready; the form is left as it was. */
setProviderAndModel: (provider: AgentProvider, modelId: string) => boolean;
/**
* `false` when the provider's catalog is loaded and does not list the model. A catalog that has
* not arrived keeps the id as given.
*/
setModel: (modelId: string) => boolean;
}
export function openAgentForm(catalog: () => AgentFormCatalog): AgentFormStore {
let state: AgentFormReducerState = {
form: { provider: null, modeId: "", model: "", thinkingOptionId: "" },
userModified: INITIAL_USER_MODIFIED,
resolution: INITIAL_AGENT_FORM_RESOLUTION,
};
const listeners = new Set<() => void>();
function dispatch(action: AgentFormAction): void {
const next = resolveAgentForm(state, action);
if (next === state) return;
state = next;
for (const listener of listeners) listener();
}
return {
getState: () => state,
dispatch,
setProviderAndModel: (provider, modelId) => {
const host = catalog();
const providerDef = host.selectableProviders.get(provider);
if (!providerDef) return false;
dispatch({
type: "SET_PROVIDER_AND_MODEL_FROM_USER",
provider,
modelId,
providerDef,
providerModels: host.models.get(provider) ?? null,
providerPrefs: host.providerPreferences(provider),
});
return true;
},
setModel: (modelId) => {
const { provider } = state.form;
const host = catalog();
const listed = provider ? host.models.get(provider) : undefined;
const models = listed?.length ? listed : null;
if (isUnlistedModel(models, modelId)) return false;
dispatch({
type: "SET_MODEL_FROM_USER",
modelId,
availableModels: models,
providerPrefs: provider ? host.providerPreferences(provider) : undefined,
});
return true;
},
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
@@ -3,6 +3,7 @@ import {
resolveAgentForm,
resolveFormState,
resolveEffectiveModel,
isUnlistedModel,
resolveThinkingOptionId,
mergeSelectedComposerPreferences,
buildProviderDefinitionMap,
@@ -131,6 +132,19 @@ describe("resolveDefaultModel", () => {
});
});
describe("isUnlistedModel", () => {
it("rejects an id the provider's catalog lacks, and accepts a listed id or alias", () => {
expect(isUnlistedModel(ALIASED_CODEX_MODELS, "not-in-the-catalog")).toBe(true);
expect(isUnlistedModel(ALIASED_CODEX_MODELS, "gpt-5.3-codex")).toBe(false);
expect(isUnlistedModel(ALIASED_CODEX_MODELS, "gpt-5.3-codex-legacy")).toBe(false);
});
it("accepts any id while the provider has listed no models", () => {
expect(isUnlistedModel(null, "anything")).toBe(false);
expect(isUnlistedModel([], "anything")).toBe(false);
});
});
describe("model aliases", () => {
it("canonicalizes a retired preferred model and restores thinking from its alias key", () => {
const resolved = resolveFormState(
@@ -153,6 +153,19 @@ export function resolveEffectiveModel(
return findModelByReference(availableModels, modelId) ?? null;
}
/**
* True when the provider has listed its models and this id is not among them. A provider that
* lists none accepts any id.
*/
export function isUnlistedModel(
availableModels: AgentModelDefinition[] | null,
modelId: string,
): boolean {
if (!availableModels || availableModels.length === 0) return false;
if (!normalizeSelectedModelId(modelId)) return false;
return !resolveEffectiveModel(availableModels, modelId);
}
function resolvePreferredThinkingOptionId(input: {
availableModels: AgentModelDefinition[] | null;
providerPrefs: ProviderPrefs | undefined;
@@ -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);
});
});
@@ -132,7 +132,7 @@ export interface BaseRefCheckoutStatus {
// Display only. The exact ref is what every request carries; this is just how a ref reads in
// a row label, so "refs/remotes/origin/other-name" shows as "other-name".
function branchNameFromRef(refName: string): string {
export function branchNameFromRef(refName: string): string {
if (refName.startsWith("refs/heads/")) return refName.slice("refs/heads/".length);
if (refName.startsWith(REMOTE_TRACKING_PREFIX)) {
const remainder = refName.slice(REMOTE_TRACKING_PREFIX.length);
@@ -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}`;
}
+116 -48
View File
@@ -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, useReducer, 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";
@@ -79,7 +79,7 @@ import type { MessagePayload } from "@/composer/types";
import type { UserComposerAttachment } from "@/attachments/types";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { AgentProvider, JsonValue } from "@getpaseo/protocol/agent-types";
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
import {
@@ -91,6 +91,7 @@ import {
defaultBasePickerItem,
pickerItemLabel,
pickerItemToCheckoutRequest,
startsNewBranch,
type BranchPickerDetail,
type PickerCheckoutRequest,
type PickerItem,
@@ -98,10 +99,17 @@ import {
} from "./new-workspace-picker-item";
import {
clearPickerPrAttachmentForTargetChange,
initialPickerSelectionState,
reducePickerSelection,
syncPickerPrAttachment,
} from "./new-workspace-picker-state";
import {
PluginNewWorkspaceItems,
type NewWorkspaceItemsScreen,
} from "@/plugins/new-workspace-items";
import {
createNewWorkspaceSetup,
useNewWorkspaceSetupState,
type NewWorkspaceSetup,
} from "./new-workspace/setup-model";
import {
resolveNewWorkspaceAutomaticServerId,
resolveNewWorkspaceInitialServerId,
@@ -193,30 +201,22 @@ 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";
canCreateWorktree: boolean;
}): WorkspaceIsolationState {
const { supportsMultiplicity, worktreeSupport } = 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, setManualIsolation] = useState<"local" | "worktree" | null>(null);
const { preferences } = 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 });
},
[updatePreferences],
);
return {
isolation,
setIsolation,
setIsolation: setup.chooseIsolation,
effectiveIsolation: isWorktree ? "worktree" : "local",
canCreateWorktree,
showRefPicker: !supportsMultiplicity || isWorktree,
@@ -288,6 +288,10 @@ async function createMultiplicityWorkspace(input: {
project: HostProjectListItem;
sourceDirectory: string;
checkoutRequest: PickerCheckoutRequest | undefined;
/** Names the new branch of a worktree cut from a branch. */
branchName: string | null;
/** Stored on the created workspace. */
metadata: Readonly<Record<string, JsonValue>>;
withInitialAgent: boolean;
agent?: CreateWorkspaceRequestOptions["agent"];
onEvent?: (snapshot: CreationSnapshot) => void;
@@ -318,6 +322,9 @@ async function createMultiplicityWorkspace(input: {
projectId,
worktreeSlug: input.worktreeSlug,
...input.checkoutRequest,
...(input.branchName && startsNewBranch(input.checkoutRequest)
? { branchName: input.branchName }
: {}),
}
: {
kind: "directory",
@@ -325,6 +332,7 @@ async function createMultiplicityWorkspace(input: {
projectId,
},
...(firstAgentContext ? { firstAgentContext } : {}),
...(Object.keys(input.metadata).length > 0 ? { metadata: { ...input.metadata } } : {}),
});
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? input.createFailedMessage);
@@ -456,6 +464,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
providerOptions: composerState.providerOptions,
},
...(Object.keys(composerState.metadata).length > 0 ? { metadata: composerState.metadata } : {}),
initialPrompt: text,
clientMessageId: `${input.draftId}:initial-message`,
images: images?.length ? images : undefined,
@@ -535,6 +544,7 @@ function buildComposerConfig(input: {
initialServerId: serverId || null,
initialValues: buildComposerInitialValues({ initialSetup }),
initialProviderOptions: initialSetup?.providerOptions,
context: "new-workspace" as const,
isVisible: true,
lockedWorkingDir: workingDir,
};
@@ -1000,22 +1010,31 @@ export function NewWorkspaceScreen({
}),
});
const composerState = chatDraft.composerState;
const [pickerSelection, dispatchPickerSelection] = useReducer(
reducePickerSelection,
initialPickerSelectionState,
);
const selectedItem = pickerSelection.selectedItem;
const { setup, pluginProject, canCreateWorktree, isPending } = usePluginSetup({
serverId: selectedServerId,
project: selectedProject,
supportsMultiplicity: supportsWorkspaceMultiplicity,
pendingAction,
isDraftHandoffActive,
updatePreferences: updateFormPreferences,
setAttachments: chatDraft.setAttachments,
});
const dispatchPickerSelection = setup.dispatchPicker;
const selectedItem = useNewWorkspaceSetupState(setup).pickerSelection.selectedItem;
const handleForgeChangeRequestDetected = useCallback(() => {
dispatchPickerSelection({ type: "pr-detected" });
}, []);
}, [dispatchPickerSelection]);
const handleForgeChangeRequestAutoAttach = useCallback((item: ForgeSearchItem) => {
dispatchPickerSelection({
type: "pr-added",
item: { kind: "github-pr", item },
});
}, []);
const handleForgeChangeRequestAutoAttach = useCallback(
(item: ForgeSearchItem) => {
dispatchPickerSelection({
type: "pr-added",
item: { kind: "github-pr", item },
});
},
[dispatchPickerSelection],
);
const withConnectedClient = useCallback(() => {
const connectedClient = getHostRuntimeStore().getClient(selectedServerId);
@@ -1034,15 +1053,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,
});
const { effectiveIsolation, setIsolation, showRefPicker } = useWorkspaceIsolation({
setup,
supportsMultiplicity: supportsWorkspaceMultiplicity,
canCreateWorktree,
});
const branchSuggestionsQuery = useQuery({
queryKey: [
@@ -1106,16 +1121,15 @@ export function NewWorkspaceScreen({
}, [itemById, selectedOptionId]);
const selectPickerItem = useCallback(
(item: PickerItem) => {
const nextAttachments = syncPickerPrAttachment({
attachments: chatDraft.attachments,
item,
});
dispatchPickerSelection({ type: "picker-selected", item });
chatDraft.setAttachments(nextAttachments);
setup.chooseBase(item);
setPickerOpen(false);
},
[chatDraft],
[setup],
);
const pluginScreen = useMemo<NewWorkspaceItemsScreen>(
() => ({ project: pluginProject, isolation: effectiveIsolation, baseItem, locked: isPending }),
[baseItem, effectiveIsolation, isPending, pluginProject],
);
const handleSelectOption = useCallback(
@@ -1138,7 +1152,7 @@ export function NewWorkspaceScreen({
chatDraft.setAttachments(nextAttachments);
dispatchPickerSelection({ type: "target-changed" });
},
[chatDraft],
[chatDraft, dispatchPickerSelection],
);
const handleSelectProjectOption = useCallback(
@@ -1258,6 +1272,8 @@ export function NewWorkspaceScreen({
project: selectedProject,
sourceDirectory: selectedSourceDirectory,
checkoutRequest,
branchName: setup.getSnapshot().branch,
metadata: setup.getSnapshot().metadata,
withInitialAgent: input.withInitialAgent,
prompt: input.prompt,
attachments: input.attachments,
@@ -1280,6 +1296,7 @@ export function NewWorkspaceScreen({
selectedProject,
selectedServerId,
selectedSourceDirectory,
setup,
supportsWorkspaceMultiplicity,
t,
withConnectedClient,
@@ -1460,6 +1477,9 @@ export function NewWorkspaceScreen({
const formStack = useNewWorkspaceForm({
isCompact,
isPending,
pluginItems: (
<PluginNewWorkspaceItems serverId={selectedServerId} screen={pluginScreen} setup={setup} />
),
project: {
anchorRef: projectPickerAnchorRef,
open: openProjectPicker,
@@ -1600,6 +1620,54 @@ export function NewWorkspaceScreen({
);
}
/**
* 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: {
serverId: string;
project: Parameters<typeof getHostProjectId>[0] | null;
supportsMultiplicity: boolean;
pendingAction: Parameters<typeof isNewWorkspacePending>[0]["pendingAction"];
isDraftHandoffActive: boolean;
updatePreferences: ReturnType<typeof useFormPreferences>["updatePreferences"];
setAttachments: ReturnType<typeof useAgentInputDraft>["setAttachments"];
}) {
const {
serverId,
project,
supportsMultiplicity,
pendingAction,
isDraftHandoffActive,
updatePreferences,
setAttachments,
} = 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 ? getHostProjectId(project, serverId) : null;
const projectName = project?.projectName ?? null;
const pluginProject = useMemo(
() => (projectId && projectName ? { id: projectId, name: projectName } : null),
[projectId, projectName],
);
const inputs = { serverId, locked, project: pluginProject, canCreateWorktree, supportsMetadata };
// Both are stable for the life of the screen: preferences write through the query client, and
// the draft is keyed per mount. A command and a pick run the same effects.
const [setup] = useState(() =>
createNewWorkspaceSetup(inputs, {
persistIsolation: (isolation) => void updatePreferences({ isolation }),
syncBaseAttachment: (item) =>
setAttachments((attachments) => syncPickerPrAttachment({ attachments, item })),
}),
);
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");
@@ -33,6 +33,8 @@ interface PickerControl {
export interface NewWorkspaceFormProps {
isCompact: boolean;
isPending: boolean;
/** Plugin fields, drawn after the base ref. */
pluginItems: ReactNode;
project: PickerControl & {
options: ComboboxOption[];
projectByOptionId: Map<string, HostProjectListItem>;
@@ -165,7 +167,7 @@ function useRefRenderOption(itemById: Map<string, PickerItem>, isPending: boolea
*/
export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement {
const { t } = useTranslation();
const { isCompact, isPending, project, host, isolation, base, launch } = props;
const { isCompact, isPending, pluginItems, project, host, isolation, base, launch } = props;
const variant: FieldTriggerVariant = isCompact ? "field" : "badge";
const projectPicker = useProjectPicker(project, isPending);
const isolationPicker = useIsolationPicker(isolation, isPending);
@@ -337,6 +339,7 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement
{hostField}
{isolationField}
{baseField}
{pluginItems}
{launchField}
{/* Isolation and Base come and go with the project; their rows stay reserved so the
stack keeps one height and nothing above the composer jumps. */}
@@ -353,6 +356,7 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement
<View style={styles.desktopControl}>{hostField}</View>
{isolationField ? <View style={styles.desktopControl}>{isolationField}</View> : null}
{baseField ? <View style={styles.desktopControl}>{baseField}</View> : null}
{pluginItems}
<View style={styles.launchSpacer} pointerEvents="none" />
{launchField}
{pickers}
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import type { PickerItem } from "../new-workspace-picker-item";
import { createNewWorkspaceSetup, type SetupEffects, type SetupInputs } from "./setup-model";
const READY: SetupInputs = {
serverId: "host-a",
locked: false,
project: { id: "p1", name: "repo" },
canCreateWorktree: true,
supportsMetadata: true,
};
function recordingEffects() {
const chosen: PickerItem[] = [];
const isolations: string[] = [];
const effects: SetupEffects = {
persistIsolation: (isolation) => isolations.push(isolation),
syncBaseAttachment: (item) => chosen.push(item),
};
return { effects, chosen, isolations };
}
describe("New workspace setup commands", () => {
it("accepts commands from the first moment, before the screen has committed", async () => {
const setup = createNewWorkspaceSetup(READY, recordingEffects().effects);
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(READY, recordingEffects().effects);
void setup.commands.setBranch("pas-101");
void setup.commands.setMetadata({ issue: "PAS-101", keep: true });
await setup.commands.setMetadata({ keep: null });
expect(setup.getSnapshot().branch).toBe("pas-101");
expect(setup.getSnapshot().metadata).toEqual({ issue: "PAS-101" });
});
it("changes the model and runs the effects of a pick, for a base branch and an isolation choice", async () => {
const { effects, chosen, isolations } = recordingEffects();
const setup = createNewWorkspaceSetup(READY, effects);
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, recordingEffects().effects);
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("drops the branch and metadata chosen for another project, but not while the project resolves", async () => {
const setup = createNewWorkspaceSetup({ ...READY, project: null }, recordingEffects().effects);
await setup.commands.setBranch("pas-101");
setup.syncInputs(READY);
expect(setup.getSnapshot().branch).toBe("pas-101");
await setup.commands.setMetadata({ issue: "PAS-101" });
setup.syncInputs({ ...READY, project: { id: "p2", name: "other" } });
expect(setup.getSnapshot().branch).toBeNull();
expect(setup.getSnapshot().metadata).toEqual({});
});
it("drops the branch and metadata when the host changes under the same project id", async () => {
const setup = createNewWorkspaceSetup(READY, recordingEffects().effects);
await setup.commands.setBranch("pas-101");
await setup.commands.setMetadata({ issue: "PAS-101" });
setup.syncInputs({ ...READY, serverId: "host-b" });
expect(setup.getSnapshot().branch).toBeNull();
expect(setup.getSnapshot().metadata).toEqual({});
});
it("rejects every command while the screen is locked, as the pickers do", async () => {
const setup = createNewWorkspaceSetup({ ...READY, locked: true }, recordingEffects().effects);
const results = await Promise.allSettled([
setup.commands.setBranch("x"),
setup.commands.setMetadata({ a: 1 }),
setup.commands.setIsolation("local"),
setup.commands.setBaseRef({ type: "branch", name: "main" }),
]);
expect(results.map((result) => result.status)).toEqual(Array(4).fill("rejected"));
expect(setup.getSnapshot().branch).toBeNull();
expect(setup.getSnapshot().metadata).toEqual({});
});
it("reads the inputs when a command runs, so a project change after render is seen", async () => {
const setup = createNewWorkspaceSetup(READY, recordingEffects().effects);
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(
{ ...READY, canCreateWorktree: false, supportsMetadata: false },
recordingEffects().effects,
);
await expect(setup.commands.setIsolation("worktree")).rejects.toThrow(
"cannot create worktrees",
);
await expect(setup.commands.setMetadata({ a: 1 })).rejects.toThrow("Update the host");
await expect(setup.commands.setBranch(" ")).rejects.toThrow("needs a name");
await setup.commands.setBranch(null);
expect(setup.getSnapshot().branch).toBeNull();
});
});
@@ -0,0 +1,161 @@
import { useSyncExternalStore } from "react";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { applyMetadataPatch } from "@/composer/draft/metadata";
import { branchNameFromRef, type PickerItem } from "../new-workspace-picker-item";
import {
initialPickerSelectionState,
reducePickerSelection,
type PickerSelectionEvent,
type PickerSelectionState,
} from "../new-workspace-picker-state";
type Isolation = "local" | "worktree";
/** What the user has chosen on the New workspace screen, apart from project and host. */
interface SetupState {
pickerSelection: PickerSelectionState;
manualIsolation: Isolation | null;
/** The name for a new worktree branch; null lets Paseo pick one. */
branch: string | null;
/** The created workspace's metadata. */
metadata: Readonly<Record<string, JsonValue>>;
}
/**
* 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 SetupInputs {
/** The host the screen creates on; a project id alone can repeat across hosts. */
serverId: string;
locked: boolean;
project: { id: string; name: string } | null;
canCreateWorktree: boolean;
supportsMetadata: boolean;
}
export interface SetupCommands {
setIsolation(isolation: Isolation): Promise<void>;
setBaseRef(ref: { type: "branch"; name: string }): Promise<void>;
setBranch(name: string | null): Promise<void>;
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
}
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;
/** 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;
syncInputs(inputs: SetupInputs): void;
commands: SetupCommands;
}
/** What a pick does beyond changing the model, the same for the picker and for a command. */
export interface SetupEffects {
/** Remembers the isolation as the default for the next New workspace screen. */
persistIsolation(isolation: Isolation): void;
/** Keeps the picker's change-request attachment in step with the chosen base. */
syncBaseAttachment(item: PickerItem): void;
}
const LOCKED_MESSAGE = "The New workspace screen is locked while the workspace is being created.";
/**
* The New workspace screen's choices as one plain model. The pickers and the plugin commands
* 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 and effects, so a command does the same thing from a child's mount effect
* as from a button later.
*/
export function createNewWorkspaceSetup(
initial: SetupInputs,
effects: SetupEffects,
): NewWorkspaceSetup {
let state: SetupState = {
pickerSelection: initialPickerSelectionState,
manualIsolation: null,
branch: null,
metadata: {},
};
let inputs = initial;
let target = initial.project ? `${initial.serverId}\n${initial.project.id}` : null;
const listeners = new Set<() => void>();
const update = (patch: Partial<SetupState>) => {
state = { ...state, ...patch };
for (const listener of listeners) listener();
};
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 });
effects.syncBaseAttachment(item);
};
const chooseIsolation = (isolation: Isolation) => {
update({ manualIsolation: isolation });
effects.persistIsolation(isolation);
};
return {
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
getSnapshot: () => state,
dispatchPicker,
chooseBase,
chooseIsolation,
syncInputs: (next) => {
inputs = next;
// A branch and metadata are chosen for one project on one host. A project id can repeat
// across hosts (a remote repository keeps its key everywhere), so the host is part of the
// target. An absent project is a screen that is still resolving, not a switch.
if (!next.project) return;
const nextTarget = `${next.serverId}\n${next.project.id}`;
if (target && target !== nextTarget) update({ branch: null, metadata: {} });
target = nextTarget;
},
commands: {
async setIsolation(isolation) {
const current = unlocked();
if (isolation === "worktree" && !current.canCreateWorktree) {
throw new Error("This project cannot create worktrees.");
}
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.");
chooseBase({
kind: "branch",
name: branchNameFromRef(refName),
refName,
accessibilityLabel: `${branchNameFromRef(refName)}, branch`,
});
},
async setBranch(name) {
unlocked();
const branch = name === null ? null : name.trim();
if (branch === "") throw new Error("A branch needs a name.");
update({ branch });
},
async setMetadata(patch) {
const current = unlocked();
if (!current.supportsMetadata) throw new Error("Update the host to use metadata.");
update({ metadata: applyMetadataPatch(state.metadata, patch) });
},
},
};
}
export function useNewWorkspaceSetupState(setup: NewWorkspaceSetup): SetupState {
return useSyncExternalStore(setup.subscribe, setup.getSnapshot, setup.getSnapshot);
}
@@ -36,6 +36,8 @@ import {
} from "./migration";
import { createDraftPersistStorage } from "./persistence";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { applyMetadataPatch } from "@/composer/draft/metadata";
export type { DraftInput, DraftLifecycleState } from "./state";
@@ -49,6 +51,9 @@ interface DraftStoreActions {
draftKey: string;
lifecycle?: Exclude<DraftLifecycleState, "active">;
}) => void;
/** Plugin metadata the draft will create its agent with; see `composer/draft/metadata`. */
patchDraftMetadata: (input: { draftKey: string; patch: Record<string, JsonValue> }) => void;
clearDraftMetadata: (input: { draftKey: string }) => void;
attachWorkspaceFile: (input: {
draftKey: string;
attachment: WorkspaceFileComposerAttachment;
@@ -60,6 +65,11 @@ interface DraftStoreActions {
interface DraftStoreRuntimeState {
attachmentFocusRequestByDraftKey: Record<string, number>;
/**
* Kept beside the prompt so a composer that remounts finds it. Not persisted, and not cleared
* with an emptied prompt: a plugin's choice outlives the text until the draft is sent.
*/
metadataByDraftKey: Record<string, Record<string, JsonValue>>;
}
type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions;
@@ -256,6 +266,7 @@ export const useDraftStore = create<DraftStore>()(
drafts: {},
createModalDraft: null,
attachmentFocusRequestByDraftKey: {},
metadataByDraftKey: {},
getDraftInput: (draftKey) => {
const record = get().drafts[draftKey];
@@ -374,6 +385,25 @@ export const useDraftStore = create<DraftStore>()(
scheduleAttachmentGc();
},
patchDraftMetadata: ({ draftKey, patch }) => {
set((state) => {
const next = applyMetadataPatch(state.metadataByDraftKey[draftKey] ?? {}, patch);
const { [draftKey]: _previous, ...others } = state.metadataByDraftKey;
return {
metadataByDraftKey:
Object.keys(next).length > 0 ? { ...others, [draftKey]: next } : others,
};
});
},
clearDraftMetadata: ({ draftKey }) => {
set((state) => {
if (!(draftKey in state.metadataByDraftKey)) return state;
const { [draftKey]: _removed, ...others } = state.metadataByDraftKey;
return { metadataByDraftKey: others };
});
},
attachWorkspaceFile: async ({ draftKey, attachment }) => {
await get().hydrateDraftInput({ draftKey });
set((state) => {
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useDraftStore } from "./index";
const draftKey = "draft:metadata";
describe("a draft's plugin metadata", () => {
beforeEach(() => {
useDraftStore.setState({ drafts: {}, metadataByDraftKey: {} });
});
it("is there when the draft's composer mounts again", () => {
const store = useDraftStore.getState();
store.editDraftText({ draftKey, text: "review this" });
store.patchDraftMetadata({ draftKey, patch: { "plugin.review": true, "plugin.tag": "a" } });
store.patchDraftMetadata({ draftKey, patch: { "plugin.tag": null } });
// A remount reads the store again; nothing from the unmounted composer is kept.
const remounted = useDraftStore.getState();
expect(remounted.getDraftInput(draftKey)?.text).toBe("review this");
expect(remounted.metadataByDraftKey[draftKey]).toEqual({ "plugin.review": true });
});
it("is dropped with the prompt once the draft is sent, and not before", () => {
const store = useDraftStore.getState();
store.patchDraftMetadata({ draftKey, patch: { "plugin.review": true } });
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
expect(useDraftStore.getState().metadataByDraftKey[draftKey]).toEqual({
"plugin.review": true,
});
store.clearDraftMetadata({ draftKey });
expect(useDraftStore.getState().metadataByDraftKey[draftKey]).toBeUndefined();
});
});
+2
View File
@@ -27,6 +27,7 @@ import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
import type {
AgentPermissionRequest,
AgentOptionControl,
JsonValue,
ProviderOptions,
AgentProvider,
AgentMode,
@@ -101,6 +102,7 @@ export interface Agent {
archivedAt?: Date | null;
parentAgentId: string | null;
labels: Record<string, string>;
metadata?: Record<string, JsonValue>;
projectPlacement?: ProjectPlacementPayload | null;
}
@@ -82,6 +82,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
...(agent.lastError ? { lastError: agent.lastError } : {}),
title: agent.title,
labels: agent.labels,
...(agent.metadata ? { metadata: agent.metadata } : {}),
requiresAttention: agent.requiresAttention ?? false,
attentionReason: agent.attentionReason ?? null,
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
@@ -137,5 +138,6 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
archivedAt,
parentAgentId,
labels: snapshot.labels,
metadata: snapshot.metadata,
};
}
@@ -116,3 +116,8 @@ export const Maximize2 = StubIcon;
export const Workflow = StubIcon;
export const ZoomIn = StubIcon;
export const ZoomOut = StubIcon;
export const ListTodo = StubIcon;
export const Shield = StubIcon;
export const ShieldEllipsis = StubIcon;
export const ShieldPlus = StubIcon;
export const Zap = StubIcon;
@@ -30,8 +30,10 @@ const testTheme = {
borderAccent: "#a1a1aa",
palette: {
amber: { 500: "#f59e0b" },
blue: { 300: "#93c5fd" },
green: { 500: "#22c55e" },
blue: { 300: "#93c5fd", 400: "#60a5fa" },
green: { 400: "#4ade80", 500: "#22c55e" },
yellow: { 400: "#facc15" },
zinc: { 600: "#52525b" },
red: { 300: "#fca5a5" },
white: "#ffffff",
},
+8
View File
@@ -236,6 +236,14 @@ export default defineConfig({
find: /^react-native\/Libraries\/Renderer\/shims\/ReactFabric$/,
replacement: path.resolve(__dirname, "test-stubs/react-native-fabric-shim.ts"),
},
// The CJS entry requires the real react-native, whose Flow sources do not parse.
{
find: /^@floating-ui\/react-native$/,
replacement: path.resolve(
rootNodeModules,
"@floating-ui/react-native/dist/floating-ui.react-native.esm.js",
),
},
// Point to the ESM build so Vite can transform its imports and apply the
// react alias below (the CJS build uses require('react') which bypasses
// Vite alias resolution).
+82
View File
@@ -213,6 +213,88 @@ describe("plugin panel SDK boundary", () => {
});
});
describe("plugin composer SDK boundary", () => {
it("lets a composer item read and change the composer through typed commands and controls", () => {
expect(
typeErrors(`
import { useComposer, type PluginClientContext } from "@getpaseo/plugin/client";
import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui";
function Item() {
const composer = useComposer();
const context: "agent" | "draft" | "new-workspace" = composer.context;
const provider: string | null = composer.agent.provider;
const flag: boolean = composer.agent.metadata.flag === true;
// @ts-expect-error reads are not writable
composer.agent.model = "other";
composer.setProvider("codex").then(() => composer.setModel("gpt"));
composer.setOptions({ effort: null });
composer.setMetadata({ flag: true, stale: null });
composer.insertText("text");
composer.addAttachment({ sourceId: "issues", item: { id: "1", identifier: "1", title: "t", url: "https://e.com", text: "t", resourceType: "issue" } });
// @ts-expect-error commands take JSON values
composer.setMetadata({ fn: () => {} });
return (
<>
<Toggle title="Flag" icon="Flag" value={flag} onValueChange={({ value }: { value: boolean }) => composer.setMetadata({ flag: value })} />
<Select title="Size" icon="Gauge" value="a" options={[{ value: "a", label: "A" }, { value: "b", label: "B" }]} onValueChange={({ value }: { value: "a" | "b" }) => {}} />
<Action title="Insert" icon="Plus" onPress={() => composer.setPrompt("")} />
</>
);
}
export default function contribute(client: PluginClientContext) {
client.addComposerItem({ id: "item", Component: Item });
return () => {};
}
`),
).toEqual([]);
});
});
describe("plugin New workspace SDK boundary", () => {
it("lets a New workspace item read the setup and change it through typed commands and a Combobox", () => {
expect(
typeErrors(`
import { useNewWorkspace, type PluginClientContext } from "@getpaseo/plugin/client";
import { Combobox } from "@getpaseo/plugin/client/ui";
interface Issue { id: string; label: string; branch: string }
function Item() {
const newWorkspace = useNewWorkspace();
const isolation: "local" | "worktree" = newWorkspace.isolation;
const branch: string | null = newWorkspace.branch;
const project: string | undefined = newWorkspace.project?.name;
const host: string = newWorkspace.host.label;
const base = newWorkspace.baseRef;
if (base?.type === "branch") { const name: string = base.name; }
// @ts-expect-error project is read-only
newWorkspace.project = null;
// @ts-expect-error host is read-only
newWorkspace.setHost("other");
// @ts-expect-error metadata reads are not writable
newWorkspace.metadata.key = "x";
newWorkspace.setIsolation("worktree").then(() => newWorkspace.setBaseRef({ type: "branch", name: "main" }));
newWorkspace.setBranch(null);
newWorkspace.setMetadata({ issue: "1", stale: null });
// @ts-expect-error commands take JSON values
newWorkspace.setMetadata({ fn: () => {} });
return (
<Combobox<Issue>
title="Issue"
icon="CircleDot"
value={{ id: "1", label: "One" }}
search={async ({ query }: { query: string }) => [{ id: query, label: query, branch: "b" }]}
onSelect={({ item }) => newWorkspace.setBranch(item.branch)}
/>
);
}
export default function contribute(client: PluginClientContext) {
client.addNewWorkspaceItem({ id: "item", Component: Item });
return () => {};
}
`),
).toEqual([]);
});
});
describe("plugin agent tools SDK boundary", () => {
it("lets a session hook attach typed tools without exposing their transport", () => {
expect(
+78
View File
@@ -0,0 +1,78 @@
import { createContext, useContext, type ComponentType, type ReactNode } from "react";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginAttachmentItem } from "../attachments.js";
import type { PluginHostProps } from "./contracts.js";
/** One item the user attached from a source this plugin registered with `addAttachmentSource`. */
export interface PluginComposerAttachment {
readonly sourceId: string;
readonly item: PluginAttachmentItem;
}
/**
* The composer the item sits in: the prompt, the attachments and the agent being configured.
* The commands behave the same in every composer. `context` says where the composer is; it does
* not change what a command does, except that a running agent keeps its provider.
*/
export interface PluginComposer {
/** `"agent"`: a running agent. `"draft"`: a new agent in a workspace. `"new-workspace"`: the New workspace screen. */
readonly context: "agent" | "draft" | "new-workspace";
readonly prompt: string;
/** This plugin's attachments only. */
readonly attachments: readonly PluginComposerAttachment[];
readonly agent: {
readonly provider: string | null;
readonly model: string | null;
readonly mode: string | null;
readonly thinking: string | null;
/** The options the agent runs with, including provider defaults. */
readonly options: Readonly<Record<string, JsonValue>>;
readonly metadata: Readonly<Record<string, JsonValue>>;
};
setPrompt(text: string): void;
/** Inserts at the cursor, replacing the selection. */
insertText(text: string): void;
addAttachment(input: { sourceId: string; item: PluginAttachmentItem }): void;
removeAttachment(input: { sourceId: string; itemId: string }): void;
/** Resets the model like the built-in control. Rejects on a running agent, which keeps its provider. */
setProvider(provider: string): Promise<void>;
setModel(modelId: string): Promise<void>;
setMode(modeId: string): Promise<void>;
setThinking(thinkingOptionId: string): Promise<void>;
/** Top-level keys replace; `null` removes the agent's own value. */
setOptions(patch: Record<string, JsonValue>): Promise<void>;
/** Top-level keys replace; `null` removes the key. */
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
}
export interface PluginComposerItemProps extends PluginHostProps {}
export interface PluginComposerItemContribution {
id: string;
Component: ComponentType<PluginComposerItemProps>;
}
const PluginComposerContext = createContext<PluginComposer | null>(null);
export function PluginComposerProvider({
composer,
children,
}: {
composer: PluginComposer;
children: ReactNode;
}) {
return (
<PluginComposerContext.Provider value={composer}>{children}</PluginComposerContext.Provider>
);
}
export function usePluginComposerValue(): PluginComposer | null {
return useContext(PluginComposerContext);
}
/** The composer this item sits in. Call it from a component registered with `addComposerItem`. */
export function useComposer(): PluginComposer {
const composer = usePluginComposerValue();
if (!composer) throw new Error("useComposer must run inside a composer item");
return composer;
}
+4
View File
@@ -4,6 +4,8 @@ import type { PaseoApi } from "@getpaseo/client";
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginFile } from "./file.js";
import type { PluginComposerItemContribution } from "./composer.js";
import type { PluginNewWorkspaceItemContribution } from "./new-workspace.js";
import type { PluginRpcContract } from "../rpc.js";
import type {
PluginButtonRegistration,
@@ -194,6 +196,8 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
contribution: PluginComposerPillContribution<PluginText>,
): PluginButtonRegistration<PluginText>;
addAttachmentSource(contribution: PluginAttachmentSourceContribution<PluginText>): PluginCleanup;
addComposerItem(contribution: PluginComposerItemContribution): PluginCleanup;
addNewWorkspaceItem(contribution: PluginNewWorkspaceItemContribution): PluginCleanup;
addTheme(contribution: PluginThemeContribution<PluginText>): PluginCleanup;
addFilePanel(contribution: PluginFilePanelContribution<PluginText>): PluginCleanup;
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
+2
View File
@@ -1,6 +1,8 @@
import type { PluginText } from "../text.js";
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
export { PluginComposerProvider } from "./composer.js";
export { PluginNewWorkspaceProvider } from "./new-workspace.js";
export {
usePluginRuntimeContextBridge,
type PluginRuntimeContextBridge,
+13
View File
@@ -71,6 +71,19 @@ export {
} from "./file.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export { useComposer } from "./composer.js";
export type {
PluginComposer,
PluginComposerAttachment,
PluginComposerItemContribution,
PluginComposerItemProps,
} from "./composer.js";
export { useNewWorkspace } from "./new-workspace.js";
export type {
PluginNewWorkspace,
PluginNewWorkspaceItemContribution,
PluginNewWorkspaceItemProps,
} from "./new-workspace.js";
export { useRpc } from "./rpc-context.js";
import type { SettingsDefinition } from "../settings.js";
import type { SettingsState } from "./contracts.js";
@@ -0,0 +1,74 @@
import { createContext, useContext, type ComponentType, type ReactNode } from "react";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginHostProps } from "./contracts.js";
/**
* The New workspace setup the item sits in. `host` and `project` are the user's choices and are
* read-only. The commands change what the created workspace is built from, under the same rules as
* the screen's own pickers.
*/
export interface PluginNewWorkspace {
readonly host: { readonly id: string; readonly label: string };
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. 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. It names the new branch of any
* worktree that cuts one, from the default base or a chosen branch, and does not apply to a
* worktree that checks out an existing branch or change request.
*/
readonly branch: string | null;
/** The workspace's metadata, not the first agent's. */
readonly metadata: Readonly<Record<string, JsonValue>>;
/** Rejects `"worktree"` for a project that cannot make worktrees. */
setIsolation(isolation: "local" | "worktree"): Promise<void>;
/**
* Starts the workspace from a branch. `name` is any ref the Base picker offers, such as
* `refs/remotes/origin/main`, or a bare branch name, which Paseo resolves the way it does for
* a branch name it is given anywhere else. Rejects while no project is chosen.
*/
setBaseRef(ref: { type: "branch"; name: string }): Promise<void>;
setBranch(name: string | null): Promise<void>;
/** Top-level keys replace; `null` removes the key. Rejects on a host without metadata support. */
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
}
export interface PluginNewWorkspaceItemProps extends PluginHostProps {}
export interface PluginNewWorkspaceItemContribution {
id: string;
Component: ComponentType<PluginNewWorkspaceItemProps>;
}
const PluginNewWorkspaceContext = createContext<PluginNewWorkspace | null>(null);
export function PluginNewWorkspaceProvider({
newWorkspace,
children,
}: {
newWorkspace: PluginNewWorkspace;
children: ReactNode;
}) {
return (
<PluginNewWorkspaceContext.Provider value={newWorkspace}>
{children}
</PluginNewWorkspaceContext.Provider>
);
}
/** The New workspace setup this item sits in. Call it from a component registered with `addNewWorkspaceItem`. */
export function useNewWorkspace(): PluginNewWorkspace {
const newWorkspace = useContext(PluginNewWorkspaceContext);
if (!newWorkspace) throw new Error("useNewWorkspace must run inside a New workspace item");
return newWorkspace;
}
@@ -1,5 +1,6 @@
import { PluginTranslationProvider, usePluginTranslationRuntime } from "./translations.js";
import { useCallback, type ReactNode } from "react";
import { PluginComposerProvider, usePluginComposerValue } from "./composer.js";
import { PluginClientStateProvider, usePluginClientStateSource } from "./client-state.js";
import { PaseoApiProvider, usePaseoContextValue } from "./paseo-context.js";
import { PluginRpcProvider, usePluginRpcContextValue } from "./rpc-context.js";
@@ -12,6 +13,7 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
const paseo = usePaseoContextValue();
const rpc = usePluginRpcContextValue();
const state = usePluginClientStateSource();
const composer = usePluginComposerValue();
if (!paseo || !rpc) {
throw new Error("Plugin UI must run inside a contributed plugin surface");
@@ -19,11 +21,16 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
return useCallback(
(children: ReactNode) => {
const content = state ? (
const withState = state ? (
<PluginClientStateProvider source={state}>{children}</PluginClientStateProvider>
) : (
children
);
const content = composer ? (
<PluginComposerProvider composer={composer}>{withState}</PluginComposerProvider>
) : (
withState
);
return (
<PaseoApiProvider paseo={paseo}>
<PluginRpcProvider invoke={rpc.invoke}>
@@ -32,6 +39,6 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
</PaseoApiProvider>
);
},
[paseo, rpc, state, translations],
[composer, paseo, rpc, state, translations],
);
}
+57
View File
@@ -185,3 +185,60 @@ export interface MarkdownProps {
/** Renders Markdown using Paseo’s theme, selection, and link behavior. */
export declare const Markdown: ComponentType<MarkdownProps>;
export interface ComposerControlProps {
/** The control's label and tooltip. */
title: string;
/** A Lucide icon name, inline SVG, or a component. */
icon: PluginIcon;
disabled?: boolean;
}
/**
* What a control's callback receives. A returned promise marks the control pending until it
* settles, and Paseo reports a rejection to the user.
*/
export interface ToggleProps extends ComposerControlProps {
value: boolean;
onValueChange(event: { value: boolean }): void | Promise<void>;
}
export interface SelectProps<Value extends string = string> extends ComposerControlProps {
value: Value;
options: readonly { value: Value; label: string; description?: string }[];
onValueChange(event: { value: Value }): void | Promise<void>;
}
export interface ActionPressEvent {}
export interface ActionProps extends ComposerControlProps {
onPress(event: ActionPressEvent): void | Promise<void>;
}
/** One result of a `Combobox` search. Extra fields ride along to `onSelect`. */
export interface ComboboxItem {
id: string;
label: string;
description?: string;
}
export interface ComboboxSearchEvent {
/** Empty when the list opens. */
query: string;
}
export interface ComboboxProps<
Item extends ComboboxItem = ComboboxItem,
> extends ComposerControlProps {
/** What the control shows. It need not be among the search results. */
value: ComboboxItem | null;
/**
* Called when the list opens and again, debounced, as the user types. Paseo shows the loading
* and error states, keeps the previous results while a search runs, and ignores a search that a
* newer one replaced.
*/
search(event: ComboboxSearchEvent): Promise<readonly Item[]>;
onSelect(event: { item: Item }): void | Promise<void>;
}
/**
* Paseo's controls. Render them from a composer item's or a New workspace item's `Component`.
* Paseo draws them where the item sits: inline when the composer has room and as rows in its
* overflow sheet when it does not, and as a field in the New workspace setup.
*/
export declare const Toggle: ComponentType<ToggleProps>;
export declare function Select<Value extends string>(props: SelectProps<Value>): ReactNode;
export declare const Action: ComponentType<ActionProps>;
export declare function Combobox<Item extends ComboboxItem>(props: ComboboxProps<Item>): ReactNode;
+2
View File
@@ -52,6 +52,8 @@ export interface PluginAgentSnapshot {
readonly attentionReason: "finished" | "error" | "permission" | null;
readonly parentAgentId: string | null;
readonly labels: Readonly<Record<string, string>>;
/** Structured data plugins store on the agent. */
readonly metadata: Readonly<Record<string, JsonValue>>;
}
export interface PluginThemeColors {
@@ -0,0 +1,18 @@
# Composer controls
Install this directory as a plugin and open the agent, draft, or New workspace composer. Four
controls appear beside the model and thinking controls.
| Control | Component | What it does |
| -------------------- | --------- | ---------------------------------------------------------------------------------------- |
| **Context length** | `Select` | Saves `composer-controls.contextLength` in the agent's metadata. |
| **Needs review** | `Toggle` | Sets or clears `composer-controls.needsReview` in the agent's metadata. |
| **Plan first** | `Toggle` | Sets the provider option `plan_mode` with `composer.setOptions`; enabled for Codex only. |
| **Insert checklist** | `Action` | Inserts a task list at the cursor through `composer.insertText`. |
Each component calls `useComposer()` and uses the same commands in every composer. Paseo draws the
controls inline when the composer has room and as rows in the composer's overflow sheet when it
does not, including on a phone.
[client/controls.tsx](client/controls.tsx) holds the four components; [index.client.tsx](index.client.tsx)
registers them with `addComposerItem`.
@@ -0,0 +1,77 @@
import { useCallback } from "react";
import { useComposer } from "@getpaseo/plugin/client";
import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui";
type ContextLength = "short" | "long";
const CONTEXT_OPTIONS = [
{ value: "short", label: "Short", description: "Answer from the files you name." },
{ value: "long", label: "Long", description: "Read the surrounding code first." },
] as const;
const CONTEXT_KEY = "composer-controls.contextLength";
const REVIEW_KEY = "composer-controls.needsReview";
/** Reads and writes agent metadata, so the choice is saved with the agent. */
export function ContextLength() {
const composer = useComposer();
const current: ContextLength = composer.agent.metadata[CONTEXT_KEY] === "long" ? "long" : "short";
const change = useCallback(
({ value }: { value: ContextLength }) => composer.setMetadata({ [CONTEXT_KEY]: value }),
[composer],
);
return (
<Select
title="Context length"
icon="Gauge"
value={current}
options={CONTEXT_OPTIONS}
onValueChange={change}
/>
);
}
export function ReviewFlag() {
const composer = useComposer();
const change = useCallback(
({ value }: { value: boolean }) => composer.setMetadata({ [REVIEW_KEY]: value ? true : null }),
[composer],
);
return (
<Toggle
title="Needs review"
icon="Flag"
value={composer.agent.metadata[REVIEW_KEY] === true}
onValueChange={change}
/>
);
}
/**
* Changes a provider option. Only Codex reports `plan_mode`, so the control is disabled for
* other providers. The promise it returns keeps the control pending, and a rejection reaches the
* user as a toast.
*/
export function PlanFirst() {
const composer = useComposer();
const change = useCallback(
({ value }: { value: boolean }) => composer.setOptions({ plan_mode: value }),
[composer],
);
return (
<Toggle
title="Plan first"
icon="ListTodo"
disabled={composer.agent.provider !== "codex"}
value={composer.agent.options.plan_mode === true}
onValueChange={change}
/>
);
}
/** Works the same in the agent, draft and New workspace composers. */
export function Checklist() {
const composer = useComposer();
const insert = useCallback(() => composer.insertText("- [ ] Tests\n- [ ] Docs\n"), [composer]);
return <Action title="Insert checklist" icon="ListChecks" onPress={insert} />;
}
@@ -0,0 +1,10 @@
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { Checklist, ContextLength, PlanFirst, ReviewFlag } from "./client/controls";
export default function contribute(client: PluginClientContext) {
client.addComposerItem({ id: "context-length", Component: ContextLength });
client.addComposerItem({ id: "review-flag", Component: ReviewFlag });
client.addComposerItem({ id: "plan-first", Component: PlanFirst });
client.addComposerItem({ id: "checklist", Component: Checklist });
return () => {};
}
@@ -0,0 +1,4 @@
{
"id": "composer-controls-example",
"requirements": { "paseo": ">=0.8.0" }
}
@@ -0,0 +1,17 @@
# New workspace issues
Install this directory as a plugin and open New workspace. An **Issue** field appears beside the
project, host, and branch pickers: a field on a phone, a badge on desktop.
Choosing an issue:
- Calls `newWorkspace.setBranch(issue.branch)`, so a worktree is created on that branch name.
- Calls `newWorkspace.setMetadata({ "new-workspace-issues.issue": issue.id })`, so the created
workspace carries the issue id.
`Combobox` runs `search` when the list opens and again as the user types. Paseo shows the loading
and error states and drops results that a newer search replaced. The server handler in
[server/issues.ts](server/issues.ts) filters a fixed list; a real plugin would call its tracker.
[client/issue-field.tsx](client/issue-field.tsx) is the field; [index.client.tsx](index.client.tsx)
registers it with `addNewWorkspaceItem`.
@@ -0,0 +1,31 @@
import { useCallback, useMemo } from "react";
import { useNewWorkspace, useRpc } from "@getpaseo/plugin/client";
import { Combobox } from "@getpaseo/plugin/client/ui";
import { searchIssuesRpc, type Issue } from "../shared/issues";
const ISSUE_KEY = "new-workspace-issues.issue";
/** Starts the workspace on the issue's branch and records which issue it is for. */
export function IssueField() {
const newWorkspace = useNewWorkspace();
const searchIssues = useRpc(searchIssuesRpc);
const issueId = newWorkspace.metadata[ISSUE_KEY];
const value = useMemo(
() => (typeof issueId === "string" ? { id: issueId, label: issueId } : null),
[issueId],
);
const search = useCallback(
async ({ query }: { query: string }) => (await searchIssues({ query })).issues,
[searchIssues],
);
const select = useCallback(
async ({ item }: { item: Issue }) => {
await newWorkspace.setBranch(item.branch);
await newWorkspace.setMetadata({ [ISSUE_KEY]: item.id });
},
[newWorkspace],
);
return (
<Combobox title="Issue" icon="CircleDot" value={value} search={search} onSelect={select} />
);
}
@@ -0,0 +1,7 @@
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { IssueField } from "./client/issue-field";
export default function contribute(client: PluginClientContext) {
client.addNewWorkspaceItem({ id: "issue", Component: IssueField });
return () => {};
}
@@ -0,0 +1,8 @@
import type { PluginServerContext } from "@getpaseo/plugin/server";
import { searchIssues } from "./server/issues";
import { searchIssuesRpc } from "./shared/issues";
export default function contribute(server: PluginServerContext) {
server.handle(searchIssuesRpc, searchIssues);
return () => {};
}
@@ -0,0 +1,4 @@
{
"id": "new-workspace-issues-example",
"requirements": { "paseo": ">=0.8.0" }
}
@@ -0,0 +1,26 @@
import type { RpcInput } from "@getpaseo/plugin";
import { searchIssuesRpc, type Issue } from "../shared/issues";
// A real plugin would ask its tracker. A fixed list keeps the example self-contained.
const ISSUES: Issue[] = [
{
id: "PAS-101",
label: "PAS-101 Slow sidebar",
description: "Lists lag at 200 rows",
branch: "pas-101-slow-sidebar",
},
{
id: "PAS-102",
label: "PAS-102 Crash on resume",
description: "Agent resume throws",
branch: "pas-102-crash-on-resume",
},
{ id: "PAS-103", label: "PAS-103 Dark theme contrast", branch: "pas-103-dark-theme-contrast" },
];
export async function searchIssues({ query }: RpcInput<typeof searchIssuesRpc>) {
const needle = query.trim().toLowerCase();
return {
issues: ISSUES.filter((issue) => issue.label.toLowerCase().includes(needle)),
};
}
@@ -0,0 +1,18 @@
import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
export const IssueSchema = z.object({
id: z.string(),
label: z.string(),
description: z.string().optional(),
/** The branch the issue's work goes on. */
branch: z.string(),
});
export type Issue = z.infer<typeof IssueSchema>;
export const searchIssuesRpc = defineRpc({
name: "issues.search",
input: z.object({ query: z.string() }),
output: z.object({ issues: z.array(IssueSchema) }),
});

Some files were not shown because too many files have changed in this diff Show More