Compare commits

...
Author SHA1 Message Date
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
76 changed files with 3084 additions and 871 deletions
+29
View File
@@ -644,6 +644,35 @@ 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 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,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();
}
}
@@ -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;
}
@@ -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,307 @@
import {
createContext,
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 { 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 in the composer's toolbar, drawn from data. Provider option controls, thinking and
* plugin controls all come through here, so each is an icon in the toolbar and a row in the sheet.
*/
export type ComposerControlItem =
| (ComposerControlBase & {
type: "toggle";
value: boolean;
onChange: (value: boolean) => void | Promise<void>;
})
| (ComposerControlBase & {
type: "select";
value: string | null;
choices: readonly ComposerControlChoice[];
onChange: (value: string) => void | Promise<void>;
/** The toolbar trigger shows only the icon. */
iconOnly?: boolean;
showCaret?: boolean;
searchable?: boolean;
desktopMinWidth?: number;
renderOption?: (args: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
})
| (ComposerControlBase & {
type: "action";
onPress: () => void | Promise<void>;
});
export type ComposerControlSurface = "toolbar" | "sheet";
const ComposerControlSurfaceContext = createContext<ComposerControlSurface>("toolbar");
export const ComposerControlSurfaceProvider = ComposerControlSurfaceContext.Provider;
/** Where the control being drawn sits: the toolbar, or a row in the overflow sheet. */
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.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,
};
}
export function ComposerControl({
item,
surface,
disabled = false,
open,
onOpenChange,
onActionComplete,
}: ComposerControlProps) {
const { t } = useTranslation();
const anchorRef = useRef<View>(null);
const isSheet = surface === "sheet";
const sheetHeader = useMemo<SheetHeader>(() => ({ title: item.title }), [item.title]);
const view = useMemo(() => describeControl(item, surface, t), [item, surface, t]);
// 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.
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],
);
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 trigger = (
<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={item.accessibilityLabel ?? view.tooltip}
testID={item.testID}
/>
);
return (
<>
{isSheet ? (
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={isSheet ? undefined : "top-start"}
desktopMinWidth={select?.desktopMinWidth}
searchable={select ? select.searchable : false}
renderOption={select?.renderOption}
header={isSheet ? sheetHeader : undefined}
/>
) : null}
</>
);
}
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) => ({
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,
};
}
@@ -23,6 +23,7 @@ function installation(): InstalledPlugin {
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
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,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
workspacePanels: [
{
id: "details",
@@ -0,0 +1,138 @@
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 { 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 { PluginEnvironmentProvider, type PluginEnvironment } from "../popover";
import { useInstalledPlugins } from "../registry";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
import { toPluginTheme } from "../theme";
import type { InstalledPlugin } from "../types";
import { toPluginComposer } from "./model";
const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
function renderNothing() {
return null;
}
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 toast = useToast();
const compact = useIsCompactFormFactor();
const hosts = useHosts();
const hostLabel =
hosts.find((host) => host.serverId === plugin.serverId)?.label ?? plugin.serverId;
const textSource = composerInput?.textSource;
const prompt = useSyncExternalStore(
textSource?.subscribe ?? subscribeNothing,
textSource?.getSnapshot ?? emptyText,
textSource?.getSnapshot ?? emptyText,
);
const environment = useMemo<PluginEnvironment>(
() => ({
installation: plugin,
toast,
state: createPluginClientStateSource(plugin.serverId),
}),
[plugin, toast],
);
const composer = useMemo(
() =>
agent && composerInput ? toPluginComposer({ plugin, agent, composerInput, prompt }) : null,
[agent, composerInput, plugin, prompt],
);
const props = useMemo<PluginHostProps>(
() => ({
theme,
host: { id: plugin.serverId, label: hostLabel },
layout: { compact, platform: resolvePluginPlatform() },
}),
[compact, hostLabel, plugin.serverId, theme],
);
if (!composer) return null;
return (
<PluginEnvironmentProvider environment={environment}>
<PluginComposerProvider composer={composer}>
{plugin.composerItems.map(({ id, Component }) => (
<SurfaceErrorBoundary
key={id}
installation={plugin}
Surface={Component}
renderError={renderNothing}
>
<Component {...props} />
</SurfaceErrorBoundary>
))}
</PluginComposerProvider>
</PluginEnvironmentProvider>
);
}
const ThemedPluginComposerItems = withUnistyles(PluginComposerItemsHost);
function subscribeNothing() {
return () => undefined;
}
function emptyText() {
return "";
}
@@ -0,0 +1,311 @@
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: [],
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",
+17
View File
@@ -131,6 +131,7 @@ export function runPluginClientBundle(
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -146,6 +147,7 @@ export function runPluginClientBundle(
footer: new Set(),
};
const filePanelIds = new Set<string>();
const composerItemIds = new Set<string>();
const workspacePanelIds = new Set<string>();
const commandCenterItemIds = new Set<string>();
const clientSlashCommandNames = new Set<string>();
@@ -269,6 +271,20 @@ 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),
);
},
addSidebarHeaderItem(contribution: PluginSidebarItemContribution) {
return addSidebarItem("header", contribution);
},
@@ -585,6 +601,7 @@ export function runPluginClientBundle(
sidebarItems: collector.sidebarItems,
legacySidebarItems: collector.legacySidebarItems,
filePanels: collector.filePanels,
composerItems: collector.composerItems,
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,7 @@ function installation(invoked: string[]): InstalledPlugin {
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -0,0 +1,87 @@
import type { ActionProps, SelectProps, ToggleProps } from "@getpaseo/plugin/client/ui";
import { useMemo, useState } from "react";
import {
ComposerControl,
useComposerControlSurface,
type ComposerControlItem,
} from "@/composer/agent-controls/composer-control";
import { useComposerLocked } from "@/composer/session";
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 composer item sits: inline, or as a row in the sheet. */
function PluginComposerControl({
item,
disabled,
}: {
item: ComposerControlItem;
disabled: boolean | undefined;
}) {
const surface = useComposerControlSurface();
const locked = useComposerLocked();
const [open, setOpen] = useState(false);
return (
<ComposerControl
item={item}
surface={surface}
disabled={disabled || locked}
open={open}
onOpenChange={setOpen}
/>
);
}
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} />;
}
@@ -20,6 +20,7 @@ export { FilePanelLoading as Loading } from "@/file-panels/loading";
export { SettingsGroup } from "@/components/settings";
export { Icon } from "../icons";
export { Action, Select, Toggle } from "./composer-controls";
export { Modal } from "./modal";
export { ScrollView, FlatList } from "./scroll-view";
export { TextInput } from "./text-input";
+1
View File
@@ -106,6 +106,7 @@ export class PluginRegistry {
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -34,6 +34,7 @@ function installed(
},
legacySidebarItems,
filePanels: [],
composerItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -36,6 +36,7 @@ function installation(
icon: "Blocks",
})),
filePanels: [],
composerItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
+1
View File
@@ -61,6 +61,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
@@ -24,6 +24,7 @@ function plugin(input: {
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
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 = {
+3
View File
@@ -11,6 +11,7 @@ import type {
PluginFilePanelContribution as SDKPluginFilePanelContribution,
PluginCommandCenterItemContribution as SDKPluginCommandCenterItemContribution,
PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution,
PluginComposerItemContribution,
PluginComposerPillContribution as SDKPluginComposerPillContribution,
PluginSidebarContribution as SDKPluginSidebarContribution,
PluginSidebarItemContribution as SDKPluginSidebarItemContribution,
@@ -53,6 +54,7 @@ export interface EvaluatedPlugin {
/** `addSidebarItem` registrations, so `/plugin/<id>/sidebar/<item>` routes keep resolving. */
legacySidebarItems: PluginSidebarContribution[];
filePanels: PluginFilePanelContribution[];
composerItems: PluginComposerItemContribution[];
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
commandCenterItems: PluginCommandCenterItemContribution[];
clientSlashCommands: PluginClientSlashCommandContribution[];
@@ -82,6 +84,7 @@ export type {
PluginAttachmentSourceContribution,
PluginCommandCenterItemContribution,
PluginClientSlashCommandContribution,
PluginComposerItemContribution,
PluginComposerPillContribution,
PluginSidebarContribution,
PluginSidebarItemContribution,
@@ -21,6 +21,7 @@ function installed(): InstalledPlugin {
sidebarItems: { header: [], footer: [] },
legacySidebarItems: [],
filePanels: [],
composerItems: [],
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;
@@ -456,6 +456,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 +536,7 @@ function buildComposerConfig(input: {
initialServerId: serverId || null,
initialValues: buildComposerInitialValues({ initialSetup }),
initialProviderOptions: initialSetup?.providerOptions,
context: "new-workspace" as const,
isVisible: true,
lockedWorkingDir: workingDir,
};
@@ -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).
+37
View File
@@ -213,6 +213,43 @@ 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 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;
}
+2
View File
@@ -4,6 +4,7 @@ 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 { PluginRpcContract } from "../rpc.js";
import type {
PluginButtonRegistration,
@@ -194,6 +195,7 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
contribution: PluginComposerPillContribution<PluginText>,
): PluginButtonRegistration<PluginText>;
addAttachmentSource(contribution: PluginAttachmentSourceContribution<PluginText>): PluginCleanup;
addComposerItem(contribution: PluginComposerItemContribution): PluginCleanup;
addTheme(contribution: PluginThemeContribution<PluginText>): PluginCleanup;
addFilePanel(contribution: PluginFilePanelContribution<PluginText>): PluginCleanup;
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
+1
View File
@@ -1,6 +1,7 @@
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 {
usePluginRuntimeContextBridge,
type PluginRuntimeContextBridge,
+7
View File
@@ -71,6 +71,13 @@ 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 { useRpc } from "./rpc-context.js";
import type { SettingsDefinition } from "../settings.js";
import type { SettingsState } from "./contracts.js";
@@ -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],
);
}
+32
View File
@@ -185,3 +185,35 @@ 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>;
}
/**
* Composer controls. Render them from a composer item's `Component`. Paseo draws them inline when
* the composer has room and as rows in its overflow sheet when it does not.
*/
export declare const Toggle: ComponentType<ToggleProps>;
export declare function Select<Value extends string>(props: SelectProps<Value>): ReactNode;
export declare const Action: ComponentType<ActionProps>;
+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" }
}
+1
View File
@@ -14,6 +14,7 @@ A plugin is a TypeScript project installed into one Paseo daemon. It can add
[file panels](/docs/plugins/reference#file-panels),
[Command Center items](/docs/plugins/reference#command-center-items),
[slash commands](/docs/plugins/reference#slash-commands),
[composer items](/docs/plugins/reference#composer-items),
[composer pills](/docs/plugins/reference#composer-pills),
[timeline items](/docs/plugins/reference#timeline-items),
[themes](/docs/plugins/reference#contribute-a-theme),
+1 -1
View File
@@ -6,7 +6,7 @@ nav: 快速上手
# 插件快速上手 {#plugin-quickstart}
插件是安装到某个 Paseo daemon 中的 TypeScript 项目。它可以添加[界面和侧边栏项](/docs/plugins/reference#screens-and-sidebar-items)、[工作区面板](/docs/plugins/reference#workspace-panels)、[文件面板](/docs/plugins/reference#file-panels)、[Command Center 项](/docs/plugins/reference#command-center-items)、[斜杠命令](/docs/plugins/reference#slash-commands)、[Composer 标签](/docs/plugins/reference#composer-pills)、[时间线条目](/docs/plugins/reference#timeline-items)、[主题](/docs/plugins/reference#contribute-a-theme)、[附件来源](/docs/plugins/reference#add-a-composer-attachment-source)和 [daemon 端 RPC](/docs/plugins/reference#add-plugin-specific-backend-behavior)。它还可以[把编程 Agent 接入为 Provider](/docs/plugins/providers)。客户端贡献会在连接到该 daemon 的每个 Paseo 客户端上运行,包括移动端。
插件是安装到某个 Paseo daemon 中的 TypeScript 项目。它可以添加[界面和侧边栏项](/docs/plugins/reference#screens-and-sidebar-items)、[工作区面板](/docs/plugins/reference#workspace-panels)、[文件面板](/docs/plugins/reference#file-panels)、[Command Center 项](/docs/plugins/reference#command-center-items)、[斜杠命令](/docs/plugins/reference#slash-commands)、[Composer 项](/docs/plugins/reference#composer-items)、[Composer 标签](/docs/plugins/reference#composer-pills)、[时间线条目](/docs/plugins/reference#timeline-items)、[主题](/docs/plugins/reference#contribute-a-theme)、[附件来源](/docs/plugins/reference#add-a-composer-attachment-source)和 [daemon 端 RPC](/docs/plugins/reference#add-plugin-specific-backend-behavior)。它还可以[把编程 Agent 接入为 Provider](/docs/plugins/providers)。客户端贡献会在连接到该 daemon 的每个 Paseo 客户端上运行,包括移动端。
浏览[已发布的插件](https://paseo.sh/plugins),然后在你的 daemon 上安装一个:
+1
View File
@@ -80,6 +80,7 @@ Use this table as the complete registration checklist.
| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` |
| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function |
| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function |
| New composer item contribution | `client.addComposerItem(item)` in `index.client.tsx`; see [composer items](reference#composer-items) |
| New header contribution | `client.addHeaderButton({ id, workspaceId, button })` |
| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` |
| New settings screen contribution | `client.addSettingsScreen(screen)` in `index.client.tsx`; see [settings screens](reference#settings-screens) |
+1
View File
@@ -73,6 +73,7 @@ my-plugin/
| 旧根入口中的 `plugin.addClientSlashCommand(command)` | `index.client.tsx` 中的 `client.addSlashCommand(command)` |
| 旧根入口中的 `plugin.addClientSide(fn)` | 删除这层包装,把 `fn` 的函数体移入默认的客户端入口函数 |
| 旧客户端回调中的 `client.addComposerPill(pill)` | `index.client.tsx` 或被导入的 `client/` 函数中的 `client.addComposerPill(pill)` |
| 新的编辑器项贡献 | `index.client.tsx` 中的 `client.addComposerItem(item)`;见[编辑器项](reference#composer-items) |
| 新的标题栏贡献 | `client.addHeaderButton({ id, workspaceId, button })` |
| 旧根入口中的 `plugin.addAttachmentSource(source)` | `index.client.tsx` 中的 `client.addAttachmentSource(source)` |
| 新的设置界面贡献 | `index.client.tsx` 中的 `client.addSettingsScreen(screen)`;见[设置界面](reference#settings-screens) |
+87
View File
@@ -1894,6 +1894,93 @@ header has no room for plugin buttons, so each one is a row of the workspace men
icon and title; its menus and popovers open as pages of that sheet. Placement and overflow are host
decisions.
## Composer items
`client.addComposerItem({ id, Component })` puts a component in the composer: the agent composer,
the draft composer for a new agent, and the composer on the New workspace screen. `useComposer()`
reads and changes what the composer configures, and `Toggle`, `Select` and `Action` from
`@getpaseo/plugin/client/ui` draw the controls.
```tsx
import { useComposer, type PluginClientContext } from "@getpaseo/plugin/client";
import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui";
function ReviewFlag() {
const composer = useComposer();
return (
<Toggle
title="Needs review"
icon="Flag"
value={composer.agent.metadata["review.needsReview"] === true}
onValueChange={({ value }) =>
composer.setMetadata({ "review.needsReview": value ? true : null })
}
/>
);
}
function Checklist() {
const composer = useComposer();
return (
<Action
title="Insert checklist"
icon="ListChecks"
onPress={() => composer.insertText("- [ ] Tests\n- [ ] Docs\n")}
/>
);
}
export default function contribute(client: PluginClientContext) {
client.addComposerItem({ id: "review-flag", Component: ReviewFlag });
client.addComposerItem({ id: "checklist", Component: Checklist });
return () => {};
}
```
`useComposer()` returns the composer the item sits in:
| Field or command | Meaning |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `context` | `"agent"` for a running agent, `"draft"` for a new agent in a workspace, `"new-workspace"` for the New workspace screen. |
| `prompt`, `setPrompt(text)`, `insertText(text)` | The prompt. `insertText` replaces the selection or inserts at the cursor. |
| `attachments`, `addAttachment`, `removeAttachment` | Items from [your own attachment sources](#add-a-composer-attachment-source). Attachments from other plugins and Paseo's own are not listed. |
| `agent.provider`, `model`, `mode`, `thinking` | What the agent runs with, or what the draft will create. |
| `agent.options` | The provider options in effect, defaults included. In a draft: the options the agent will be created with, including your changes. |
| `agent.metadata` | The agent's [metadata](/docs/plugins/hooks#metadata). In New workspace, the first agent's. |
| `setProvider`, `setModel`, `setMode`, `setThinking` | Return promises. `setProvider` resets the model like the provider picker. It rejects on `context: "agent"`, which keeps its provider, and for a provider the host does not offer. `setModel` rejects for a model the provider's loaded catalog does not list. |
| `setOptions(patch)`, `setMetadata(patch)` | Top-level keys replace; `null` removes the key. Metadata set in a draft is sent when the agent is created and cleared once the prompt is sent. |
The same call does the same thing in every composer; a plugin does not choose a composer's
implementation. `setMetadata` on a running agent needs a host that supports metadata. On an older
host the promise rejects with "Update the host to use metadata."
`Toggle` takes `value` and `onValueChange({ value })`. `Select` takes `value`, `options` (`{ value, label,
description? }`) and `onValueChange({ value })`. `Action` takes `onPress(event)`. All three take `title`,
`icon` (a [Lucide name, SVG or component](#icons)) and `disabled`. A callback may return a promise:
the control stays disabled until it settles, and Paseo shows a rejection to the user as an error
toast, the way it does for its own controls. Paseo owns where each control sits: inline in the
toolbar when the composer has room, and a row in the composer's overflow sheet when it does not,
which is always the case on a phone. A `Select` opens its list in place of the sheet row. A
`Component` that renders something other than these controls draws it where its item is placed; in
the overflow sheet that is the sheet.
Each command acts on the composer's state when it runs, not on the render that created your
callback, so `await composer.setProvider("codex")` followed by `await composer.setModel("gpt")`
changes both. A command's promise resolves once the change has taken effect, and `composer.agent`
shows it on the next render.
While the New workspace screen creates the workspace and its agent, the composer is locked. Your
controls render disabled, `setPrompt` and `insertText` throw, and the agent commands reject with
"The composer is locked while the agent is being created." `composer.agent.options` and
`composer.agent.metadata` are frozen copies; change them through `setOptions` and `setMetadata`.
A composer item counts toward how much fits before Paseo knows what it renders. An item that
renders nothing can still leave an empty overflow sheet on a narrow composer, so always render a
control.
The [composer controls example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)
registers a `Select`, two `Toggle`s (one writes a provider option) and an `Action`.
## Composer pills
`client.addComposerPill({ id, workspaceId, agentId, button })` uses the same [button descriptor](#button-descriptor)
+65
View File
@@ -1600,6 +1600,71 @@ review.remove();
贡献移入一个共享的溢出菜单。在手机和较窄的窗口中,标题栏没有空间放置插件按钮,因此每个按钮都会成为工作区
菜单(`···`)中的一行,显示它的图标和标题;它的菜单和弹出框以该底部面板中的页面打开。位置和溢出由宿主决定。
## Composer 项 {#composer-items}
`client.addComposerItem({ id, Component })` 把一个组件放进 composer:Agent composer、为新 Agent 准备的草稿 composer,以及新建工作区界面上的 composer。`useComposer()` 读取并修改 composer 所配置的内容,`@getpaseo/plugin/client/ui` 中的 `Toggle`、`Select` 和 `Action` 负责绘制控件。
```tsx
import { useComposer, type PluginClientContext } from "@getpaseo/plugin/client";
import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui";
function ReviewFlag() {
const composer = useComposer();
return (
<Toggle
title="Needs review"
icon="Flag"
value={composer.agent.metadata["review.needsReview"] === true}
onValueChange={({ value }) =>
composer.setMetadata({ "review.needsReview": value ? true : null })
}
/>
);
}
function Checklist() {
const composer = useComposer();
return (
<Action
title="Insert checklist"
icon="ListChecks"
onPress={() => composer.insertText("- [ ] Tests\n- [ ] Docs\n")}
/>
);
}
export default function contribute(client: PluginClientContext) {
client.addComposerItem({ id: "review-flag", Component: ReviewFlag });
client.addComposerItem({ id: "checklist", Component: Checklist });
return () => {};
}
```
`useComposer()` 返回该项所在的 composer:
| 字段或命令 | 含义 |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `context` | `"agent"` 表示运行中的 Agent,`"draft"` 表示工作区中的新 Agent,`"new-workspace"` 表示新建工作区界面。 |
| `prompt`、`setPrompt(text)`、`insertText(text)` | 提示词。`insertText` 会替换选区,或在光标处插入。 |
| `attachments`、`addAttachment`、`removeAttachment` | 来自[你自己的附件来源](#add-a-composer-attachment-source)的项目。其他插件和 Paseo 自身的附件不会列出。 |
| `agent.provider`、`model`、`mode`、`thinking` | Agent 当前使用的配置,或草稿将要创建的配置。 |
| `agent.options` | 当前生效的 Provider 选项,包含默认值。在草稿中:创建 Agent 时将使用的选项,包含你所做的修改。 |
| `agent.metadata` | Agent 的[元数据](/docs/plugins/hooks#metadata)。在新建工作区中是第一个 Agent 的元数据。 |
| `setProvider`、`setModel`、`setMode`、`setThinking` | 返回 Promise。`setProvider` 会像 Provider 选择器一样重置模型。在 `context: "agent"` 下(运行中的 Agent 保持其 Provider)以及主机不提供该 Provider 时会被拒绝。Provider 的模型目录已加载但未列出该模型时,`setModel` 会被拒绝。 |
| `setOptions(patch)`、`setMetadata(patch)` | 顶层键替换,`null` 删除该键。草稿中设置的元数据在创建 Agent 时发送,发送提示词后清空。 |
同一个调用在每个 composer 中行为一致,插件不需要选择 composer 的实现。在运行中的 Agent 上调用 `setMetadata` 需要支持元数据的主机;主机过旧时,Promise 会以 “Update the host to use metadata.” 拒绝。
`Toggle` 接收 `value` 和 `onValueChange({ value })`。`Select` 接收 `value`、`options`(`{ value, label, description? }`)和 `onValueChange({ value })`。`Action` 接收 `onPress(event)`。三者都接收 `title`、`icon`([Lucide 名称、SVG 或组件](#icons))和 `disabled`。回调可以返回 Promise:在它完成之前控件保持禁用,被拒绝时 Paseo 会像对待自身控件一样,以错误提示向用户显示。控件的位置由 Paseo 决定:composer 空间足够时显示在工具栏中,不够时(手机上始终如此)成为 composer 溢出面板中的一行。`Select` 会就地打开列表,而不是替换面板中的行。`Component` 如果渲染这些控件以外的内容,会在其所在位置绘制;在溢出面板中,该位置就是面板。
每个命令都按运行时 composer 的当前状态执行,而不是按创建回调的那次渲染,所以 `await composer.setProvider("codex")` 之后再 `await composer.setModel("gpt")` 会同时生效。命令的 Promise 在更改生效后完成,`composer.agent` 在下一次渲染时反映它。
新建工作区界面在创建工作区及其 Agent 期间会锁定 composer。你的控件显示为禁用,`setPrompt` 和 `insertText` 会抛出错误,Agent 命令会以 "The composer is locked while the agent is being created." 拒绝。`composer.agent.options` 和 `composer.agent.metadata` 是冻结的副本;请通过 `setOptions` 和 `setMetadata` 修改它们。
在 Paseo 知道渲染结果之前,composer 项就已计入可容纳的数量。不渲染任何内容的项仍可能在窄 composer 上留下空的溢出面板,所以请始终渲染一个控件。
[Composer 控件示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)注册了一个 `Select`、两个 `Toggle`(其中一个写入 Provider 选项)和一个 `Action`。
## Composer 胶囊 {#composer-pills}
`client.addComposerPill({ id, workspaceId, agentId, button })` 使用同样的[按钮描述符](#button-descriptor),