fix(plugins): composer commands run on current state, report failures, and reach the overflow sheet

Draft commands read the state captured at render, so a plugin's
`await setProvider(); await setModel()` acted on a stale provider. The draft
form and options are now plain stores that commands read live, and
`setProvider`/`setModel` reject an unavailable provider or a model the loaded
catalog does not list. Draft `options` include the plugin's pending edits.

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

The overflow sheet's content portals to the app root and lost the composer's
agent, input, toast and query contexts, so plugin items drew nothing in it.
`useComposerSheetBridge` carries them across.
This commit is contained in:
Mohamed Boudra
2026-11-02 05:59:32 +01:00
parent abd4d2c1ea
commit d92b16afbf
27 changed files with 1005 additions and 208 deletions
@@ -1,101 +1,178 @@
import type { TFunction } from "i18next";
/**
* @vitest-environment jsdom
*/
import React from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Scan } from "lucide-react-native";
import { describe, expect, it } from "vitest";
import { describeControl, type ComposerControlItem } from "./composer-control";
import { toFeatureItem } from "./control-items";
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-context";
import { ComposerControls, type ComposerControlItem } from "./composer-control";
import { useAgentControlItems } from "./control-items";
import { resolveComposerControlPresentation } from "./layout";
const t = ((key: string) => key) as unknown as TFunction;
beforeEach(() => vi.stubGlobal("React", React));
afterEach(cleanup);
const base = { id: "x", title: "Context", icon: Scan };
const select: ComposerControlItem = {
...base,
type: "select",
value: "large",
choices: [
{ value: "small", label: "Small" },
{ value: "large", label: "Large", description: "More room" },
],
onChange: () => {},
};
type RecordingToast = ToastApi & { error: ReturnType<typeof vi.fn> };
describe("describeControl", () => {
it("shows the chosen label and opens a list for a select", () => {
expect(describeControl(select, "toolbar", t)).toMatchObject({
value: "Large",
selectedId: "large",
iconOnly: false,
tooltip: "Context",
opensList: true,
options: [
{ id: "small", label: "Small" },
{ id: "large", label: "Large", description: "More room" },
],
});
});
function createToast(): RecordingToast {
return { error: vi.fn() } as unknown as RecordingToast;
}
it("names the choice in the tooltip of an icon-only select", () => {
const item: ComposerControlItem = { ...select, iconOnly: true } as ComposerControlItem;
expect(describeControl(item, "toolbar", t)).toMatchObject({
iconOnly: true,
tooltip: "Context: Large",
});
});
function ignoreOpenChange() {}
it("toggles on press in the toolbar and lists on/off in the sheet", () => {
const toggle: ComposerControlItem = {
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: "toggle",
value: true,
onChange: () => {},
type: "select",
value: "large",
choices: [
{ value: "small", label: "Small" },
{ value: "large", label: "Large" },
],
onChange: () => undefined,
};
expect(describeControl(toggle, "toolbar", t).opensList).toBe(false);
const inSheet = describeControl(toggle, "sheet", t);
expect(inSheet).toMatchObject({ opensList: true, selectedId: "true" });
expect(inSheet.value).toBe("agentControls.features.on");
renderControls([select]);
expect(screen.getByRole("button", { name: "Context" }).textContent).toContain("Large");
});
it("acts on press for an action in both places", () => {
const action: ComposerControlItem = { ...base, type: "action", onPress: () => {} };
expect(describeControl(action, "toolbar", t).opensList).toBe(false);
expect(describeControl(action, "sheet", t).opensList).toBe(false);
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");
});
});
describe("toFeatureItem", () => {
it("reads a toggle from the agent's options and writes its key", () => {
const writes: Array<[string, unknown]> = [];
const item = toFeatureItem(
{ type: "toggle", key: "fast_mode", label: "Fast", defaultValue: false },
{ controls: [], options: { fast_mode: true }, onSetOption: (k, v) => writes.push([k, v]) },
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],
);
expect(item).toMatchObject({
id: "feature-fast_mode",
type: "toggle",
value: true,
highlight: "yellow",
testID: "agent-feature-fast_mode",
const items = useAgentControlItems({
surface: "toolbar",
presentation: resolveComposerControlPresentation("full"),
thinking: null,
optionControls,
});
if (item.type === "toggle") item.onChange(false);
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]]);
});
it("falls back to the default choice and shows only the icon when asked", () => {
const item = toFeatureItem(
{
type: "select",
key: "speed",
label: "Speed",
desktopTrigger: "icon",
defaultValue: "standard",
choices: [
{ value: "standard", label: "Standard" },
{ value: "fast", label: "Fast" },
],
},
{ controls: [], options: {}, onSetOption: () => {} },
);
expect(item).toMatchObject({ type: "select", value: "standard", iconOnly: true });
expect(item.highlight).toBeUndefined();
});
});
@@ -1,4 +1,12 @@
import { createContext, useCallback, useContext, useMemo, useRef, type ReactElement } from "react";
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";
@@ -8,6 +16,8 @@ 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-context";
import { toErrorMessage } from "@/utils/error-messages";
interface ComposerControlBase {
id: string;
@@ -36,13 +46,13 @@ export type ComposerControlItem =
| (ComposerControlBase & {
type: "toggle";
value: boolean;
onChange: (value: boolean) => void;
onChange: (value: boolean) => void | Promise<void>;
})
| (ComposerControlBase & {
type: "select";
value: string | null;
choices: readonly ComposerControlChoice[];
onChange: (value: string) => void;
onChange: (value: string) => void | Promise<void>;
/** The toolbar trigger shows only the icon. */
iconOnly?: boolean;
showCaret?: boolean;
@@ -57,7 +67,7 @@ export type ComposerControlItem =
})
| (ComposerControlBase & {
type: "action";
onPress: () => void;
onPress: () => void | Promise<void>;
});
export type ComposerControlSurface = "toolbar" | "sheet";
@@ -92,7 +102,7 @@ interface ControlView {
opensList: boolean;
}
export function describeControl(
function describeControl(
item: ComposerControlItem,
surface: ComposerControlSurface,
t: TFunction,
@@ -153,18 +163,37 @@ export function ComposerControl({
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") item.onChange(!item.value);
else if (item.type === "action") item.onPress();
if (item.type === "toggle") run(() => item.onChange(!item.value));
else if (item.type === "action") run(item.onPress);
onActionComplete?.();
}, [item, onActionComplete]);
}, [item, onActionComplete, run]);
const handleOpenPress = useCallback(() => onOpenChange(!open), [onOpenChange, open]);
const handleSelect = useCallback(
(value: string) => {
if (item.type === "select") item.onChange(value);
else if (item.type === "toggle") item.onChange(value === "true");
if (item.type === "select") run(() => item.onChange(value));
else if (item.type === "toggle") run(() => item.onChange(value === "true"));
},
[item],
[item, run],
);
const select = item.type === "select" ? item : null;
@@ -179,7 +208,7 @@ export function ComposerControl({
showToolbarLabel={!view.iconOnly}
showCaret={select?.showCaret === true && !isSheet}
open={view.opensList && open}
disabled={disabled}
disabled={disabled || pending}
onPress={view.opensList ? handleOpenPress : handlePress}
accessibilityLabel={item.accessibilityLabel ?? view.tooltip}
testID={item.testID}
@@ -30,7 +30,7 @@ function getFeatureHighlight(
return color === "default" ? undefined : color;
}
export function toFeatureItem(
function toFeatureItem(
control: AgentOptionControl,
optionControls: ComposerOptionControls,
): ComposerControlItem {
@@ -71,6 +71,8 @@ import {
} from "@/composer/agent-controls/composer-control";
import { useAgentControlItems } from "@/composer/agent-controls/control-items";
import { CompactModelSheet } from "@/composer/agent-controls/model-sheet";
import { useComposerSheetBridge } from "@/composer/agent-controls/sheet-bridge";
import { draftComposerAgent } from "@/composer/draft/composer-agent";
import { ComposerAgentProvider, type ComposerAgent } from "@/composer/session";
import { PluginComposerItems, usePluginComposerItemCount } from "@/plugins/composer-items";
import {
@@ -128,16 +130,22 @@ export interface DraftAgentControlsProps {
onSelectMode: (modeId: string) => void;
models: AgentModelDefinition[];
selectedModel: string;
onSelectModel: (modelId: string) => void;
/** `false` when the provider's loaded catalog does not list the model. */
onSelectModel: (modelId: string) => boolean;
isModelLoading: boolean;
modelSelectorProviders: ProviderSelectorProvider[];
isAllModelsLoading: boolean;
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
/** `false` when the provider is unknown or not ready. */
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => boolean;
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void;
onApplyAgentProfile: DraftAgentProfileControls["applyProfile"];
/** The controls the daemon described for the draft; absent until it answers. */
optionControls?: ComposerOptionControls;
/** What the draft will create with, edits included, whether or not the daemon has answered. */
options: ProviderOptions;
onSetOption: (key: string, value: unknown) => void;
onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
onRetryModelProvider?: (provider: AgentProvider) => void;
@@ -711,6 +719,7 @@ interface DesktopAgentControlsContentProps extends AgentControlsContentProps {
}
function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
const sheetBridge = useComposerSheetBridge();
const { theme } = useUnistyles();
const { t } = useTranslation();
const {
@@ -817,6 +826,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
header={featuresSheetHeader}
visible={activeSheet === "features"}
onClose={handleCloseSheet}
contextBridge={sheetBridge}
testID="agent-features-sheet"
>
<ComposerControls
@@ -853,6 +863,7 @@ interface SheetAgentControlsContentProps extends AgentControlsContentProps {
}
function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
const sheetBridge = useComposerSheetBridge();
const {
provider,
selectedModelId,
@@ -916,6 +927,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
onEditProfiles={onEditAgentProfiles}
onCreateProfile={onCreateAgentProfile}
onEditProfile={onEditAgentProfile}
contextBridge={sheetBridge}
isLoading={isModelLoading}
disabled={disabled}
onOpen={onModelSelectorOpen}
@@ -1269,6 +1281,8 @@ export function DraftAgentControls({
onSelectThinkingOption,
onApplyAgentProfile,
optionControls,
options,
onSetOption,
onDropdownClose,
onModelSelectorOpen,
onRetryModelProvider,
@@ -1338,26 +1352,23 @@ export function DraftAgentControls({
[selectedProvider, providerDefinitions, modeOptions, selectedMode, onSelectMode, disabled],
);
const composerAgent = useMemo<ComposerAgent>(
() => ({
context,
provider: selectedProvider,
model: selectedModel || null,
mode: selectedMode || null,
thinking: effectiveSelectedThinkingOption ?? null,
options: optionControls?.options ?? EMPTY_OPTIONS,
metadata,
// An empty model id picks the provider's default model.
setProvider: async (nextProvider) => onSelectProviderAndModel(nextProvider, ""),
setModel: async (modelId) => onSelectModel(modelId),
setMode: async (modeId) => onSelectMode(modeId),
setThinking: async (thinkingOptionId) => onSelectThinkingOption(thinkingOptionId),
setOptions: async (patch) => {
if (!optionControls) throw new Error("This provider has no options.");
for (const [key, value] of Object.entries(patch)) optionControls.onSetOption(key, value);
},
setMetadata: async (patch) => onSetMetadata(patch),
}),
const composerAgent = useMemo(
() =>
draftComposerAgent({
context,
selectedProvider,
selectedModel,
selectedMode,
thinking: effectiveSelectedThinkingOption ?? null,
options,
metadata,
onSelectProviderAndModel,
onSelectModel,
onSelectMode,
onSelectThinkingOption,
onSetOption,
onSetMetadata,
}),
[
context,
effectiveSelectedThinkingOption,
@@ -1367,7 +1378,8 @@ export function DraftAgentControls({
onSelectProviderAndModel,
onSelectThinkingOption,
onSetMetadata,
optionControls,
onSetOption,
options,
selectedMode,
selectedModel,
selectedProvider,
@@ -25,11 +25,13 @@ 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,
@@ -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,197 @@
/**
* @vitest-environment jsdom
*/
import React, { act, useSyncExternalStore } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ToastApi } from "@/components/toast-host";
/**
* A faithful stand-in for `@gorhom/portal`: it renders nothing where it sits and hands its
* element to a host elsewhere in the tree, so the sheet's content loses the composer's context
* exactly as it does on a phone and on a narrow window.
*/
const portalStore = vi.hoisted(() => {
const listeners = new Set<() => void>();
let node: unknown = null;
return {
put(next: unknown) {
node = next;
for (const listener of listeners) listener();
},
read: () => node,
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
reset() {
node = null;
listeners.clear();
},
};
});
vi.mock("@gorhom/portal", () => ({
Portal: ({ children }: { children?: React.ReactNode }) => {
React.useEffect(() => {
portalStore.put(children);
}, [children]);
return null;
},
PortalHost: () => React.createElement("div", { "data-host": true }),
}));
vi.mock("@gorhom/bottom-sheet", async () => {
const { Portal } = await import("@gorhom/portal");
return {
BottomSheetHandle: () => null,
useBottomSheetGestureHandlers: () => ({}),
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", null, children),
BottomSheetModal: React.forwardRef(({ children }: { children?: React.ReactNode }, _ref) =>
React.createElement(Portal, null, children),
),
};
});
vi.mock("@/plugins/client-runtime", () => ({
createPluginClientRuntime: () => ({
paseo: { dispose: async () => {} },
rpc: async () => undefined,
openSurface: () => undefined,
openPanel: () => undefined,
addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }),
}),
}));
import appPackage from "../../../package.json";
import { IsolatedBottomSheetModal } from "@/components/ui/isolated-bottom-sheet-modal";
import { ToastApiProvider } from "@/contexts/toast-context";
import { PluginComposerItems } from "@/plugins/composer-items";
import { pluginRegistry } from "@/plugins/registry";
import {
ComposerAgentProvider,
ComposerInputProvider,
type ComposerAgent,
type ComposerInput,
} from "@/composer/session";
import { useComposerSheetBridge } from "./sheet-bridge";
// One toggle that reads the composer's metadata, as a plugin author writes it.
const PLUGIN_BUNDLE = `(function(require) {
const React = require("react");
const { useComposer } = require("@getpaseo/plugin/client");
const { Toggle } = require("@getpaseo/plugin/client/ui");
const module = { exports: {} };
function NeedsReview() {
const { agent, setMetadata } = useComposer();
return React.createElement(Toggle, {
title: "Needs review",
icon: "Scan",
value: agent.metadata.needsReview === true,
onValueChange: ({ value }) => setMetadata({ needsReview: value }),
});
}
module.exports.default = function(plugin) {
plugin.addComposerItem({ id: "needs-review", Component: NeedsReview });
return function() {};
};
return module.exports;
})`;
const agent: ComposerAgent = {
context: "draft",
provider: "claude",
model: "sonnet",
mode: null,
thinking: null,
options: {},
metadata: { needsReview: true },
setProvider: async () => {},
setModel: async () => {},
setMode: async () => {},
setThinking: async () => {},
setOptions: async () => {},
setMetadata: async () => {},
};
const input: ComposerInput = {
textSource: { getSnapshot: () => "", subscribe: () => () => {} },
setText: () => {},
insertText: () => {},
attachments: [],
changeAttachments: () => {},
};
const toast = { error: () => {} } as unknown as ToastApi;
/** The overflow sheet: the plugin's items inside a sheet, opened from the composer. */
function OverflowSheet() {
const sheetBridge = useComposerSheetBridge();
return (
<IsolatedBottomSheetModal contextBridge={sheetBridge}>
<PluginComposerItems serverId="host-a" surface="sheet" />
</IsolatedBottomSheetModal>
);
}
/** Renders whatever the portal holds from the app root, outside every composer provider. */
function PortalHostProbe() {
const node = useSyncExternalStore(portalStore.subscribe, portalStore.read, portalStore.read);
return <div data-portal-host>{node as React.ReactNode}</div>;
}
describe("the composer's overflow sheet", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.stubGlobal("React", React);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
pluginRegistry.installCatalog(
"host-a",
[
{
id: "review",
clientBundle: PLUGIN_BUNDLE,
requirements: { paseo: `>=${appPackage.version}` },
},
],
{ client: {} as DaemonClient, audio: { play: async () => 0 } },
);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
portalStore.reset();
pluginRegistry.removeHost("host-a");
});
it("draws a plugin's composer item as a row that reads the composer", () => {
act(() => {
root.render(
<QueryClientProvider client={new QueryClient()}>
<ToastApiProvider api={toast}>
<ComposerAgentProvider value={agent}>
<ComposerInputProvider value={input}>
<OverflowSheet />
</ComposerInputProvider>
</ComposerAgentProvider>
</ToastApiProvider>
<PortalHostProbe />
</QueryClientProvider>,
);
});
const sheet = container.querySelector("[data-portal-host]");
expect(sheet?.textContent).toContain("Needs review");
// The toggle reads `agent.metadata.needsReview`, so the row shows the composer's value.
expect(sheet?.textContent).toContain("On");
});
});
@@ -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-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,198 @@
/** @vitest-environment jsdom */
import React, { type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
const { asyncStorage, runtime } = vi.hoisted(() => ({
asyncStorage: new Map<string, string>(),
runtime: {
controlRequests: [] as unknown[],
client: null as unknown,
},
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: async (key: string) => asyncStorage.get(key) ?? null,
setItem: async (key: string, value: string) => void asyncStorage.set(key, value),
removeItem: async (key: string) => void asyncStorage.delete(key),
},
}));
const entries: ProviderSnapshotEntry[] = [
{
provider: "claude",
status: "ready",
enabled: true,
modes: [{ id: "default", label: "Default" }],
models: [{ provider: "claude", id: "sonnet", label: "Sonnet", isDefault: true }],
},
{
provider: "codex",
status: "ready",
enabled: true,
modes: [{ id: "auto", label: "Auto" }],
models: [
{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true },
{ provider: "codex", id: "gpt-5.5", label: "GPT-5.5" },
],
},
// Ready, but it lists no models: the host cannot say which ids exist.
{
provider: "opencode",
status: "ready",
enabled: true,
modes: [{ id: "build", label: "Build" }],
},
];
vi.mock("@/hooks/use-providers-snapshot", () => ({
useProvidersSnapshot: () => ({
entries,
isLoading: false,
isFetching: false,
isRefreshing: false,
error: null,
supportsSnapshot: true,
refresh: async () => undefined,
refetchIfStale: () => undefined,
}),
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => runtime.client,
useHostRuntimeIsConnected: () => true,
}));
const { useAgentFormState } = await import("@/hooks/use-agent-form-state");
const { useDraftProviderOptions } = await import("@/hooks/use-draft-provider-options");
const { draftComposerAgent } = await import("./composer-agent");
function wrapper({ children }: { children: ReactNode }) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
/** The draft composer's hooks, joined the way the screens join them. */
const initialValues = { provider: "claude" } as const;
function useDraftComposer(context: "draft" | "new-workspace") {
const form = useAgentFormState({
serverId: "host-1",
workingDir: "/repo",
isVisible: true,
initialValues,
});
const options = useDraftProviderOptions({
serverId: "host-1",
provider: form.selectedProvider,
getProvider: form.getSelectedProvider,
cwd: "/repo",
modeId: form.selectedMode,
modelId: form.selectedModel,
thinkingOptionId: form.selectedThinkingOptionId,
});
const agent = draftComposerAgent({
context,
selectedProvider: form.selectedProvider,
selectedModel: form.selectedModel,
selectedMode: form.selectedMode,
thinking: form.selectedThinkingOptionId || null,
options: options.shownOptions,
metadata: {},
onSelectProviderAndModel: form.setProviderAndModelFromUser,
onSelectModel: form.setModelFromUser,
onSelectMode: form.setModeFromUser,
onSelectThinkingOption: form.setThinkingOptionFromUser,
onSetOption: options.setOption,
onSetMetadata: () => undefined,
});
return { form, agent };
}
async function mount(context: "draft" | "new-workspace") {
const view = renderHook(() => useDraftComposer(context), { wrapper });
await waitFor(() => expect(view.result.current.form.selectedProvider).not.toBeNull());
return view;
}
describe.each(["draft", "new-workspace"] as const)("the %s composer's agent", (context) => {
beforeEach(() => {
asyncStorage.clear();
runtime.controlRequests = [];
runtime.client = {
listProviderControls: async (request: unknown) => {
runtime.controlRequests.push(request);
return { controls: [], options: {} };
},
};
});
it("applies each awaited command before the next one runs", async () => {
const view = await mount(context);
// A plugin keeps the object it was handed across its awaits.
const retained = view.result.current.agent;
const other = retained.provider === "codex" ? "claude" : "codex";
const model = other === "codex" ? "gpt-5.5" : "sonnet";
await act(async () => {
await retained.setProvider(other);
await retained.setModel(model);
await retained.setOptions({ context: "1m" });
});
const { form, agent } = view.result.current;
expect(agent.context).toBe(context);
expect(form.selectedProvider).toBe(other);
expect(form.selectedModel).toBe(model);
expect(agent.options).toEqual({ context: "1m" });
await waitFor(() =>
expect(runtime.controlRequests.at(-1)).toMatchObject({
provider: other,
model,
providerOptions: { context: "1m" },
}),
);
});
it("keeps the options of the provider they were set for", async () => {
const view = await mount(context);
const retained = view.result.current.agent;
await act(async () => {
await retained.setOptions({ context: "1m" });
await retained.setProvider(retained.provider === "codex" ? "claude" : "codex");
});
// Choosing a provider by hand starts the options over.
expect(view.result.current.agent.options).toEqual({});
});
it("rejects a provider the host cannot run and a model the catalog does not list", async () => {
const view = await mount(context);
const retained = view.result.current.agent;
await act(async () => {
await expect(retained.setProvider("nope")).rejects.toThrow(
"Unknown or unavailable provider: nope",
);
await retained.setProvider("codex");
await expect(retained.setModel("missing")).rejects.toThrow("Unknown model: missing");
});
expect(view.result.current.form.selectedProvider).toBe("codex");
expect(view.result.current.form.selectedModel).not.toBe("missing");
});
it("keeps a model id as given when the provider lists no catalog", async () => {
const view = await mount(context);
const retained = view.result.current.agent;
await act(async () => {
await retained.setProvider("opencode");
await retained.setModel("anthropic/claude-x");
});
expect(view.result.current.form.selectedModel).toBe("anthropic/claude-x");
});
});
@@ -0,0 +1,49 @@
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"
| "selectedProvider"
| "selectedModel"
| "selectedMode"
| "options"
| "metadata"
| "onSelectProviderAndModel"
| "onSelectModel"
| "onSelectMode"
| "onSelectThinkingOption"
| "onSetOption"
| "onSetMetadata"
> & { thinking: string | null },
): ComposerAgent {
return {
context: props.context ?? "draft",
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),
};
}
@@ -22,6 +22,8 @@ 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"]>;
@@ -31,6 +33,8 @@ export function buildDraftAgentControls(input: {
const {
formState,
optionControls,
options,
onSetOption,
onApplyAgentProfile,
onDropdownClose,
context,
@@ -55,6 +59,8 @@ export function buildDraftAgentControls(input: {
onSelectThinkingOption: formState.setThinkingOptionFromUser,
onApplyAgentProfile,
optionControls,
options,
onSetOption,
onDropdownClose,
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
onRetryModelProvider: formState.refreshProviderModels,
@@ -290,10 +290,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,
@@ -337,6 +340,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
agentControls: buildDraftAgentControls({
formState,
optionControls: draftOptionControls,
options: draftShownOptions,
onSetOption: setDraftOption,
onApplyAgentProfile: applyDraftAgentProfile,
context: composerOptions.context ?? "draft",
metadata,
@@ -352,6 +357,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveThinkingOptionId,
draftOptionControls,
draftOptions,
draftShownOptions,
setDraftOption,
applyDraftAgentProfile,
formState,
metadata,
@@ -513,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,
+52 -28
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 } 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,7 +76,8 @@ 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;
@@ -172,11 +175,13 @@ 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,
});
const [formStore] = useState(openAgentForm);
const { form: formState, resolution } = useSyncExternalStore(
formStore.subscribe,
formStore.getState,
formStore.getState,
);
const dispatch = formStore.dispatch;
const {
entries: snapshotEntries,
@@ -277,12 +282,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
preferences,
snapshotProviderModelsByProvider,
snapshotResolvableProviderDefinitionMap,
dispatch,
]);
const setProviderAndModelFromUser = useCallback(
(provider: AgentProvider, modelId: string) => {
(provider: AgentProvider, modelId: string): boolean => {
if (!selectableProviderDefinitionMap.has(provider)) {
return;
return false;
}
const providerDef = selectableProviderDefinitionMap.get(provider);
const providerModels = allProviderModels.get(provider) ?? null;
@@ -307,13 +313,14 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
},
}),
);
return true;
},
[allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences],
[allProviderModels, dispatch, selectableProviderDefinitionMap, updateCurrentPreferences],
);
const clearProviderSelectionFromUser = useCallback(() => {
dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" });
}, []);
}, [dispatch]);
const applyProfileFromUser = useCallback(
(profile: MaterializedAgentProfile) => {
@@ -322,7 +329,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 +344,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 +368,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 +392,36 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
);
}
},
[formState.provider, updateCurrentPreferences],
[dispatch, formStore, updateCurrentPreferences],
);
// Reads the form as it is now: a command that follows `setProviderAndModelFromUser` sees the new
// provider and its catalog. A catalog that has not arrived keeps the id as given; one that has
// arrived refuses an id it does not list.
const setModelFromUser = useCallback(
(modelId: string) => {
const provider = formState.provider;
(modelId: string): boolean => {
const { provider } = formStore.getState().form;
const listed = provider
? filterSelectableModels(
(snapshotEntries ?? []).find((entry) => entry.provider === provider)?.models ?? null,
)
: null;
const models = listed?.length ? listed : null;
if (models && normalizeSelectedModelId(modelId) && !resolveEffectiveModel(models, modelId)) {
return false;
}
const providerPrefs = provider
? preferenceOverlayRef.current.current().providerPreferences?.[provider]
: undefined;
dispatch({
type: "SET_MODEL_FROM_USER",
modelId,
availableModels,
availableModels: models,
providerPrefs,
});
if (provider) {
const normalizedModelId = normalizeSelectedModelId(modelId);
const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels);
const nextModelId = normalizedModelId || resolveDefaultModelId(models);
void updateCurrentPreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
@@ -413,14 +432,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
}),
);
}
return true;
},
[availableModels, formState.provider, updateCurrentPreferences],
[dispatch, formStore, snapshotEntries, 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 +455,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 +501,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
() => ({
selectedServerId: serverId,
selectedProvider: formState.provider,
getSelectedProvider,
selectedMode: formState.modeId,
setModeFromUser,
selectedModel: formState.model,
@@ -509,6 +532,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
}),
[
serverId,
getSelectedProvider,
formState.provider,
formState.modeId,
formState.model,
@@ -12,6 +12,8 @@ import { useFetchQuery } from "@/data/query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
const EMPTY_OPTIONS: ProviderOptions = {};
/**
* Adapter between a draft's options model and the screen: it sends the model's complete options
* to the daemon, hands the answer back, and persists the user's toggles as last-used values.
@@ -19,6 +21,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,7 +30,16 @@ export function useDraftProviderOptions(input: {
initialOptions?: ProviderOptions;
}) {
const { t } = useTranslation();
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialOptions } = input;
const {
serverId,
provider,
getProvider,
cwd,
modeId,
modelId,
thinkingOptionId,
initialOptions,
} = input;
const [model] = useState(() => openDraftOptions({ provider, seed: initialOptions }));
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
const client = useHostRuntimeClient(serverId ?? "");
@@ -77,21 +90,24 @@ 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) => {
const current = getProvider();
model.observeProvider(current);
model.setOption(key, value);
if (!provider) return;
void updatePreferences((current) =>
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 = viewOptions ?? ownOptions ?? EMPTY_OPTIONS;
return {
optionControls,
options: ownOptions,
shownOptions,
setOption,
applyProfile: model.applyProfile,
};
}
@@ -69,7 +69,11 @@ function fakeAgent(context: ComposerAgent["context"]) {
thinking: null,
options: { effort: "high" },
metadata: { flag: true },
setProvider: async (provider) => void calls.push(`provider:${provider}`),
// 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}`),
@@ -133,14 +137,12 @@ describe("toPluginComposer", () => {
composerInput: input,
prompt: "",
});
await composer.setProvider("codex");
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([
"provider:codex",
"model:gpt",
"mode:plan",
"thinking:high",
@@ -150,6 +152,29 @@ describe("toPluginComposer", () => {
},
);
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();
@@ -43,7 +43,7 @@ export function Toggle({ title, icon, disabled, value, onValueChange }: TogglePr
icon: Icon,
highlight: value ? "blue" : undefined,
value,
onChange: onValueChange,
onChange: (next) => onValueChange({ value: next }),
}),
[Icon, onValueChange, title, value],
);
@@ -67,7 +67,7 @@ export function Select<Value extends string>({
icon: Icon,
value,
choices: options,
onChange: (next) => onValueChange(next as Value),
onChange: (next) => onValueChange({ value: next as Value }),
showCaret: true,
}),
[Icon, onValueChange, options, title, value],
@@ -78,7 +78,7 @@ export function Select<Value extends string>({
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 }),
() => ({ id: "plugin-action", type: "action", title, icon: Icon, onPress: () => onPress({}) }),
[Icon, onPress, title],
);
return <PluginComposerControl item={item} disabled={disabled} />;
@@ -0,0 +1,42 @@
import {
INITIAL_AGENT_FORM_RESOLUTION,
INITIAL_USER_MODIFIED,
resolveAgentForm,
type AgentFormAction,
type AgentFormReducerState,
} from "@/provider-selection/resolve-agent-form";
/**
* The draft form as a plain model: `resolveAgentForm` applied synchronously. A command reads
* `getState()` 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;
}
export function openAgentForm(): AgentFormStore {
let state: AgentFormReducerState = {
form: { provider: null, modeId: "", model: "", thinkingOptionId: "" },
userModified: INITIAL_USER_MODIFIED,
resolution: INITIAL_AGENT_FORM_RESOLUTION,
};
const listeners = new Set<() => void>();
return {
getState: () => state,
dispatch: (action) => {
const next = resolveAgentForm(state, action);
if (next === state) return;
state = next;
for (const listener of listeners) listener();
},
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
@@ -33,6 +33,7 @@ const testTheme = {
blue: { 300: "#93c5fd", 400: "#60a5fa" },
green: { 400: "#4ade80", 500: "#22c55e" },
yellow: { 400: "#facc15" },
zinc: { 600: "#52525b" },
red: { 300: "#fca5a5" },
white: "#ffffff",
},
+2 -2
View File
@@ -235,8 +235,8 @@ describe("plugin composer SDK boundary", () => {
composer.setMetadata({ fn: () => {} });
return (
<>
<Toggle title="Flag" icon="Flag" value={flag} onValueChange={(next: boolean) => composer.setMetadata({ flag: next })} />
<Select title="Size" icon="Gauge" value="a" options={[{ value: "a", label: "A" }, { value: "b", label: "B" }]} onValueChange={(next: "a" | "b") => {}} />
<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("")} />
</>
);
+8 -3
View File
@@ -193,17 +193,22 @@ export interface ComposerControlProps {
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(value: boolean): void;
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(value: Value): void;
onValueChange(event: { value: Value }): void | Promise<void>;
}
export interface ActionPressEvent {}
export interface ActionProps extends ComposerControlProps {
onPress(): void;
onPress(event: ActionPressEvent): void | Promise<void>;
}
/**
* Composer controls. Render them from a composer item's `Component`. Paseo draws them inline when