diff --git a/docs/plugins.md b/docs/plugins.md index e3c3406cfd..79220ef80a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -663,6 +663,16 @@ density and overflow. Plugin items count in the density presence in `layout.ts`, condenses to make room for them. A custom component renders wherever its item is placed, and an item that renders nothing still counts for density; there is no registration for either. +A command runs against the owner's current state, not the render that built the callback. The +draft's form (`openAgentForm`) and options (`openDraftOptions`) are plain stores for that reason, +and `draftComposerAgent` rejects a provider the host does not offer or a model its loaded catalog +does not list. `ComposerControl` owns the pending state and the error toast for callbacks that +return a promise. + +The overflow sheet's content portals to the app root, away from the composer. Pass +`useComposerSheetBridge()` as the sheet's `contextBridge`, or `useComposer()` returns nothing and +the sheet draws no plugin rows. + ## Contribute timeline items Timeline transformers and renderers are client contributions. The daemon's canonical rows and diff --git a/packages/app/src/composer/agent-controls/composer-control.test.tsx b/packages/app/src/composer/agent-controls/composer-control.test.tsx index cfd5aacf78..84cc5659c9 100644 --- a/packages/app/src/composer/agent-controls/composer-control.test.tsx +++ b/packages/app/src/composer/agent-controls/composer-control.test.tsx @@ -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 }; -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 ( + + + + ); +} + +function renderControls(items: ComposerControlItem[], surface: "toolbar" | "sheet" = "toolbar") { + const toast = createToast(); + render(); + 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((_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; + 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 ; + } + 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(); + 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(); - }); }); diff --git a/packages/app/src/composer/agent-controls/composer-control.tsx b/packages/app/src/composer/agent-controls/composer-control.tsx index 362fb057ae..8af7d4114f 100644 --- a/packages/app/src/composer/agent-controls/composer-control.tsx +++ b/packages/app/src/composer/agent-controls/composer-control.tsx @@ -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; }) | (ComposerControlBase & { type: "select"; value: string | null; choices: readonly ComposerControlChoice[]; - onChange: (value: string) => void; + onChange: (value: string) => void | Promise; /** 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; }); 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(() => ({ 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) => { + 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} diff --git a/packages/app/src/composer/agent-controls/control-items.tsx b/packages/app/src/composer/agent-controls/control-items.tsx index 7291bfc3f2..a098c51071 100644 --- a/packages/app/src/composer/agent-controls/control-items.tsx +++ b/packages/app/src/composer/agent-controls/control-items.tsx @@ -30,7 +30,7 @@ function getFeatureHighlight( return color === "default" ? undefined : color; } -export function toFeatureItem( +function toFeatureItem( control: AgentOptionControl, optionControls: ComposerOptionControls, ): ComposerControlItem { diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index ac1438c20a..7724c2c544 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -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; 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" > ( - () => ({ - 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, diff --git a/packages/app/src/composer/agent-controls/mode.test.ts b/packages/app/src/composer/agent-controls/mode.test.ts index ca160ec9c7..8eca9b9251 100644 --- a/packages/app/src/composer/agent-controls/mode.test.ts +++ b/packages/app/src/composer/agent-controls/mode.test.ts @@ -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, diff --git a/packages/app/src/composer/agent-controls/model-sheet.tsx b/packages/app/src/composer/agent-controls/model-sheet.tsx index 6929b447ea..7c56ff0349 100644 --- a/packages/app/src/composer/agent-controls/model-sheet.tsx +++ b/packages/app/src/composer/agent-controls/model-sheet.tsx @@ -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" > { + 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/agent-controls/sheet-bridge.tsx b/packages/app/src/composer/agent-controls/sheet-bridge.tsx new file mode 100644 index 0000000000..294312e55c --- /dev/null +++ b/packages/app/src/composer/agent-controls/sheet-bridge.tsx @@ -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( + (content) => ( + + + + + {input ? ( + {content} + ) : ( + content + )} + + + + + ), + [agent, input, layout, queryClient, toast], + ); +} diff --git a/packages/app/src/composer/draft/composer-agent.test.tsx b/packages/app/src/composer/draft/composer-agent.test.tsx new file mode 100644 index 0000000000..842489ade2 --- /dev/null +++ b/packages/app/src/composer/draft/composer-agent.test.tsx @@ -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(), + 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/composer/draft/composer-agent.ts b/packages/app/src/composer/draft/composer-agent.ts new file mode 100644 index 0000000000..5aa0dab0c6 --- /dev/null +++ b/packages/app/src/composer/draft/composer-agent.ts @@ -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), + }; +} diff --git a/packages/app/src/composer/draft/input-draft-core.ts b/packages/app/src/composer/draft/input-draft-core.ts index b58d6782a1..6458126e7f 100644 --- a/packages/app/src/composer/draft/input-draft-core.ts +++ b/packages/app/src/composer/draft/input-draft-core.ts @@ -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; @@ -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, diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 8081fc2e5a..9b93228827 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -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, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 084590347a..411cba6f34 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -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, diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index d088dfb161..e7cf7e211f 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -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, diff --git a/packages/app/src/hooks/use-draft-provider-options.ts b/packages/app/src/hooks/use-draft-provider-options.ts index 0eb11507d7..6876c57241 100644 --- a/packages/app/src/hooks/use-draft-provider-options.ts +++ b/packages/app/src/hooks/use-draft-provider-options.ts @@ -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, + }; } diff --git a/packages/app/src/plugins/composer-items/model.test.ts b/packages/app/src/plugins/composer-items/model.test.ts index 301c9a7c4b..e3c7546476 100644 --- a/packages/app/src/plugins/composer-items/model.test.ts +++ b/packages/app/src/plugins/composer-items/model.test.ts @@ -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(); diff --git a/packages/app/src/plugins/react-native/composer-controls.tsx b/packages/app/src/plugins/react-native/composer-controls.tsx index 8427d68b95..450972e727 100644 --- a/packages/app/src/plugins/react-native/composer-controls.tsx +++ b/packages/app/src/plugins/react-native/composer-controls.tsx @@ -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({ 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({ export function Action({ title, icon, disabled, onPress }: ActionProps) { const Icon = usePluginControlIcon(icon); const item = useMemo( - () => ({ id: "plugin-action", type: "action", title, icon: Icon, onPress }), + () => ({ id: "plugin-action", type: "action", title, icon: Icon, onPress: () => onPress({}) }), [Icon, onPress, title], ); return ; diff --git a/packages/app/src/provider-selection/agent-form-store.ts b/packages/app/src/provider-selection/agent-form-store.ts new file mode 100644 index 0000000000..cfc2342130 --- /dev/null +++ b/packages/app/src/provider-selection/agent-form-store.ts @@ -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); + }; + }, + }; +} diff --git a/packages/app/test-stubs/react-native-unistyles.ts b/packages/app/test-stubs/react-native-unistyles.ts index bce7fdcba8..aec8cc34cd 100644 --- a/packages/app/test-stubs/react-native-unistyles.ts +++ b/packages/app/test-stubs/react-native-unistyles.ts @@ -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", }, diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index 0d8da368bf..168acbaf27 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -235,8 +235,8 @@ describe("plugin composer SDK boundary", () => { composer.setMetadata({ fn: () => {} }); return ( <> - composer.setMetadata({ flag: next })} /> - {}} /> composer.setPrompt("")} /> ); diff --git a/packages/plugin/src/client/ui.ts b/packages/plugin/src/client/ui.ts index 8d6cf94606..5130141332 100644 --- a/packages/plugin/src/client/ui.ts +++ b/packages/plugin/src/client/ui.ts @@ -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; } export interface SelectProps extends ComposerControlProps { value: Value; options: readonly { value: Value; label: string; description?: string }[]; - onValueChange(value: Value): void; + onValueChange(event: { value: Value }): void | Promise; } +export interface ActionPressEvent {} export interface ActionProps extends ComposerControlProps { - onPress(): void; + onPress(event: ActionPressEvent): void | Promise; } /** * Composer controls. Render them from a composer item's `Component`. Paseo draws them inline when diff --git a/plugin-examples/composer-controls/README.md b/plugin-examples/composer-controls/README.md index b686c851d3..813f037865 100644 --- a/plugin-examples/composer-controls/README.md +++ b/plugin-examples/composer-controls/README.md @@ -1,17 +1,18 @@ # Composer controls -Install this directory as a plugin and open the agent, draft, or New workspace composer. Three +Install this directory as a plugin and open the agent, draft, or New workspace composer. Four controls appear beside the model and thinking controls. -| Control | Component | What it does | -| -------------------- | --------- | ----------------------------------------------------------------------- | -| **Context length** | `Select` | Saves `composer-controls.contextLength` in the agent's metadata. | -| **Needs review** | `Toggle` | Sets or clears `composer-controls.needsReview` in the agent's metadata. | -| **Insert checklist** | `Action` | Inserts a task list at the cursor through `composer.insertText`. | +| Control | Component | What it does | +| -------------------- | --------- | ---------------------------------------------------------------------------------------- | +| **Context length** | `Select` | Saves `composer-controls.contextLength` in the agent's metadata. | +| **Needs review** | `Toggle` | Sets or clears `composer-controls.needsReview` in the agent's metadata. | +| **Plan first** | `Toggle` | Sets the provider option `plan_mode` with `composer.setOptions`; enabled for Codex only. | +| **Insert checklist** | `Action` | Inserts a task list at the cursor through `composer.insertText`. | Each component calls `useComposer()` and uses the same commands in every composer. Paseo draws the controls inline when the composer has room and as rows in the composer's overflow sheet when it does not, including on a phone. -[client/controls.tsx](client/controls.tsx) holds the three components; [index.client.tsx](index.client.tsx) +[client/controls.tsx](client/controls.tsx) holds the four components; [index.client.tsx](index.client.tsx) registers them with `addComposerItem`. diff --git a/plugin-examples/composer-controls/client/controls.tsx b/plugin-examples/composer-controls/client/controls.tsx index 0fad067767..98a1befe37 100644 --- a/plugin-examples/composer-controls/client/controls.tsx +++ b/plugin-examples/composer-controls/client/controls.tsx @@ -15,16 +15,16 @@ const REVIEW_KEY = "composer-controls.needsReview"; /** Reads and writes agent metadata, so the choice is saved with the agent. */ export function ContextLength() { const composer = useComposer(); - const value: ContextLength = composer.agent.metadata[CONTEXT_KEY] === "long" ? "long" : "short"; + const current: ContextLength = composer.agent.metadata[CONTEXT_KEY] === "long" ? "long" : "short"; const change = useCallback( - (next: ContextLength) => composer.setMetadata({ [CONTEXT_KEY]: next }), + ({ value }: { value: ContextLength }) => composer.setMetadata({ [CONTEXT_KEY]: value }), [composer], ); return (