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);