diff --git a/packages/app/e2e/browser/plugin-composer-items.spec.ts b/packages/app/e2e/browser/plugin-composer-items.spec.ts
new file mode 100644
index 0000000000..bccdaed4b5
--- /dev/null
+++ b/packages/app/e2e/browser/plugin-composer-items.spec.ts
@@ -0,0 +1,45 @@
+import { expect, test } from "../support/fixtures";
+import { gotoAppShell } from "../support/helpers/app";
+import { buildAgentRoute } from "../support/helpers/mock-agent";
+import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
+import { copyPluginExample } from "../support/helpers/plugin-fixture";
+import { seedWorkspace } from "../support/helpers/seed-client";
+
+const NARROW = { width: 500, height: 800 };
+
+test("a narrow composer's overflow sheet shows a plugin item's row", async ({ page }) => {
+ test.setTimeout(120_000);
+ 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 page.setViewportSize(NARROW);
+ await gotoAppShell(page);
+ await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
+
+ await page.getByRole("button", { name: "Open agent features", exact: true }).click();
+
+ await expect(page.getByRole("button", { name: "Needs review", exact: true })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Insert checklist", exact: true })).toBeVisible();
+ } finally {
+ await client.removePlugin("composer-controls-example");
+ await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
+ await client.close();
+ await example.cleanup();
+ await workspace.cleanup();
+ }
+});
diff --git a/packages/app/src/agent-controls/draft-options-model.ts b/packages/app/src/agent-controls/draft-options-model.ts
index aed92ded91..3e21119959 100644
--- a/packages/app/src/agent-controls/draft-options-model.ts
+++ b/packages/app/src/agent-controls/draft-options-model.ts
@@ -47,6 +47,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 +69,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 +86,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 +96,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 })),
};
}
@@ -166,3 +176,17 @@ export function viewDraftOptions(
: { ...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;
+}
diff --git a/packages/app/src/composer/agent-controls/sheet-bridge.test.tsx b/packages/app/src/composer/agent-controls/sheet-bridge.test.tsx
deleted file mode 100644
index 481763bce5..0000000000
--- a/packages/app/src/composer/agent-controls/sheet-bridge.test.tsx
+++ /dev/null
@@ -1,197 +0,0 @@
-/**
- * @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 (
-
-
-
- );
-}
-
-/** 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
{node as React.ReactNode}
;
-}
-
-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(
-
-
-
-
-
-
-
-
-
- ,
- );
- });
-
- 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");
- });
-});
diff --git a/packages/app/src/composer/draft/composer-agent.test.ts b/packages/app/src/composer/draft/composer-agent.test.ts
new file mode 100644
index 0000000000..86c7f3f031
--- /dev/null
+++ b/packages/app/src/composer/draft/composer-agent.test.ts
@@ -0,0 +1,146 @@
+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,
+ 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.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("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");
+ });
+});
diff --git a/packages/app/src/composer/draft/composer-agent.test.tsx b/packages/app/src/composer/draft/composer-agent.test.tsx
deleted file mode 100644
index 842489ade2..0000000000
--- a/packages/app/src/composer/draft/composer-agent.test.tsx
+++ /dev/null
@@ -1,198 +0,0 @@
-/** @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(),
- 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 {children};
-}
-
-/** 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");
- });
-});
diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts
index e7cf7e211f..01134b37eb 100644
--- a/packages/app/src/hooks/use-agent-form-state.ts
+++ b/packages/app/src/hooks/use-agent-form-state.ts
@@ -34,7 +34,7 @@ import {
type FormState,
type ProviderModelsByProvider,
} from "@/provider-selection/resolve-agent-form";
-import { openAgentForm } from "@/provider-selection/agent-form-store";
+import { openAgentForm, type AgentFormCatalog } from "@/provider-selection/agent-form-store";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { MaterializedAgentProfile } from "@/agent-profiles";
@@ -84,6 +84,12 @@ export interface UseAgentFormStateResult {
persistFormPreferences: () => Promise;
}
+const EMPTY_CATALOG: AgentFormCatalog = {
+ selectableProviders: new Map(),
+ models: new Map(),
+ providerPreferences: () => undefined,
+};
+
function resolveSelectedProviderModes(input: {
selectedEntry: ProviderSnapshotEntry | null;
provider: AgentProvider | null;
@@ -175,7 +181,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
[updatePreferences],
);
- const [formStore] = useState(openAgentForm);
+ // The store reads what the host offers when a command runs; each render publishes the latest.
+ const catalogRef = useRef(EMPTY_CATALOG);
+ const [formStore] = useState(() => openAgentForm(() => catalogRef.current));
const { form: formState, resolution } = useSyncExternalStore(
formStore.subscribe,
formStore.getState,
@@ -251,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;
@@ -287,23 +301,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
const setProviderAndModelFromUser = useCallback(
(provider: AgentProvider, modelId: string): boolean => {
- if (!selectableProviderDefinitionMap.has(provider)) {
- return false;
- }
- const providerDef = selectableProviderDefinitionMap.get(provider);
+ 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,
@@ -315,7 +316,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
);
return true;
},
- [allProviderModels, dispatch, selectableProviderDefinitionMap, updateCurrentPreferences],
+ [allProviderModels, formStore, updateCurrentPreferences],
);
const clearProviderSelectionFromUser = useCallback(() => {
@@ -395,33 +396,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
[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): boolean => {
+ if (!formStore.setModel(modelId)) return false;
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: models,
- providerPrefs,
- });
if (provider) {
- const normalizedModelId = normalizeSelectedModelId(modelId);
- const nextModelId = normalizedModelId || resolveDefaultModelId(models);
+ const listed = allProviderModels.get(provider);
+ const nextModelId =
+ normalizeSelectedModelId(modelId) ||
+ resolveDefaultModelId(listed?.length ? listed : null);
void updateCurrentPreferences((current) =>
mergeSelectedComposerPreferences({
preferences: current,
@@ -434,7 +417,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
}
return true;
},
- [dispatch, formStore, snapshotEntries, updateCurrentPreferences],
+ [allProviderModels, formStore, updateCurrentPreferences],
);
const setThinkingOptionFromUser = useCallback(
diff --git a/packages/app/src/hooks/use-draft-provider-options.ts b/packages/app/src/hooks/use-draft-provider-options.ts
index 6876c57241..52309773c2 100644
--- a/packages/app/src/hooks/use-draft-provider-options.ts
+++ b/packages/app/src/hooks/use-draft-provider-options.ts
@@ -5,6 +5,7 @@ import {
draftControlsRequest,
openDraftOptions,
ownDraftOptions,
+ shownDraftOptions,
viewDraftOptions,
} from "@/agent-controls/draft-options-model";
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
@@ -12,8 +13,6 @@ 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.
@@ -40,7 +39,9 @@ export function useDraftProviderOptions(input: {
thinkingOptionId,
initialOptions,
} = input;
- const [model] = useState(() => openDraftOptions({ provider, seed: initialOptions }));
+ 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 ?? "");
@@ -93,9 +94,8 @@ export function useDraftProviderOptions(input: {
// 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);
+ const current = getProvider();
if (!current) return;
void updatePreferences((stored) =>
mergeProviderPreferences({
@@ -122,7 +122,7 @@ export function useDraftProviderOptions(input: {
);
// What the draft will create with, the plugin's own edits included, before the daemon answers.
- const shownOptions = viewOptions ?? ownOptions ?? EMPTY_OPTIONS;
+ const shownOptions = shownDraftOptions(state, { provider, requestKey });
return {
optionControls,
diff --git a/packages/app/src/provider-selection/agent-form-store.ts b/packages/app/src/provider-selection/agent-form-store.ts
index cfc2342130..cbfb25a284 100644
--- a/packages/app/src/provider-selection/agent-form-store.ts
+++ b/packages/app/src/provider-selection/agent-form-store.ts
@@ -1,36 +1,90 @@
+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,
+ normalizeSelectedModelId,
resolveAgentForm,
+ resolveEffectiveModel,
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;
+ /** Each provider's selectable models; empty when the host has listed none. */
+ models: ReadonlyMap;
+ providerPreferences: (
+ provider: AgentProvider,
+ ) => NonNullable[AgentProvider] | undefined;
+}
+
/**
* 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.
+ * `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(): AgentFormStore {
+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: (action) => {
- const next = resolveAgentForm(state, action);
- if (next === state) return;
- state = next;
- for (const listener of listeners) listener();
+ 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 (models && normalizeSelectedModelId(modelId) && !resolveEffectiveModel(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);