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.
This commit is contained in:
Mohamed Boudra
2026-11-02 05:59:32 +01:00
parent abd4d2c1ea
commit d92b16afbf
27 changed files with 1005 additions and 208 deletions
+8 -7
View File
@@ -1,17 +1,18 @@
# Composer controls
Install this directory as a plugin and open the agent, draft, or New workspace composer. Three
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. |
| **Insert checklist** | `Action` | Inserts a task list at the cursor through `composer.insertText`. |
| 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 three components; [index.client.tsx](index.client.tsx)
[client/controls.tsx](client/controls.tsx) holds the four components; [index.client.tsx](index.client.tsx)
registers them with `addComposerItem`.
@@ -15,16 +15,16 @@ 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 value: ContextLength = composer.agent.metadata[CONTEXT_KEY] === "long" ? "long" : "short";
const current: ContextLength = composer.agent.metadata[CONTEXT_KEY] === "long" ? "long" : "short";
const change = useCallback(
(next: ContextLength) => composer.setMetadata({ [CONTEXT_KEY]: next }),
({ value }: { value: ContextLength }) => composer.setMetadata({ [CONTEXT_KEY]: value }),
[composer],
);
return (
<Select
title="Context length"
icon="Gauge"
value={value}
value={current}
options={CONTEXT_OPTIONS}
onValueChange={change}
/>
@@ -34,7 +34,7 @@ export function ContextLength() {
export function ReviewFlag() {
const composer = useComposer();
const change = useCallback(
(next: boolean) => composer.setMetadata({ [REVIEW_KEY]: next ? true : null }),
({ value }: { value: boolean }) => composer.setMetadata({ [REVIEW_KEY]: value ? true : null }),
[composer],
);
return (
@@ -47,6 +47,28 @@ export function ReviewFlag() {
);
}
/**
* 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();
@@ -1,9 +1,10 @@
import type { PluginClientContext } from "@getpaseo/plugin/client";
import { Checklist, ContextLength, ReviewFlag } from "./client/controls";
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 () => {};
}