From c1f64d45e14210a9b9af1bbf1cc1da32302ed57b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 06:56:13 +0100 Subject: [PATCH] fix(plugins): draft metadata survives a remount, plugin commands follow the composer lock Draft metadata was component state while the prompt lived in the draft store, so a composer that remounted across the compact breakpoint kept its text and lost the plugin's choices. It now lives in the draft store beside the prompt, is cleared when the draft is sent, and is not cleared when the prompt is emptied. It is not persisted across app restarts. While the New workspace composer is creating the agent, the built-in controls are disabled but plugin controls and commands were not. They read the composer's own lock: Paseo controls render disabled, and setPrompt, insertText and the agent commands refuse. The options and metadata a plugin reads are frozen copies, through the same helper the timeline projection uses. The composer control test mounted components in JSDOM, which the testing rules ban; it now runs in the browser project. --- ....tsx => composer-control.browser.test.tsx} | 5 +- .../app/src/composer/agent-controls/index.tsx | 3 ++ .../app/src/composer/draft/composer-agent.ts | 2 + .../app/src/composer/draft/input-draft.ts | 11 +++-- packages/app/src/composer/index.tsx | 10 +++- packages/app/src/composer/session.tsx | 11 +++++ .../src/plugins/composer-items/model.test.ts | 46 ++++++++++++++++++- .../app/src/plugins/composer-items/model.ts | 39 ++++++++++++---- packages/app/src/plugins/freeze.ts | 12 +++++ .../react-native/composer-controls.tsx | 4 +- .../app/src/plugins/timeline/projection.ts | 13 +----- packages/app/src/stores/draft-store/index.ts | 30 ++++++++++++ .../src/stores/draft-store/metadata.test.ts | 35 ++++++++++++++ .../app/test-stubs/lucide-react-native.ts | 5 ++ public-docs/plugins/reference.md | 5 ++ public-docs/plugins/reference.zh-CN.md | 2 + 16 files changed, 198 insertions(+), 35 deletions(-) rename packages/app/src/composer/agent-controls/{composer-control.test.tsx => composer-control.browser.test.tsx} (98%) create mode 100644 packages/app/src/plugins/freeze.ts create mode 100644 packages/app/src/stores/draft-store/metadata.test.ts diff --git a/packages/app/src/composer/agent-controls/composer-control.test.tsx b/packages/app/src/composer/agent-controls/composer-control.browser.test.tsx similarity index 98% rename from packages/app/src/composer/agent-controls/composer-control.test.tsx rename to packages/app/src/composer/agent-controls/composer-control.browser.test.tsx index 84cc5659c9..8a4905b235 100644 --- a/packages/app/src/composer/agent-controls/composer-control.test.tsx +++ b/packages/app/src/composer/agent-controls/composer-control.browser.test.tsx @@ -1,13 +1,10 @@ -/** - * @vitest-environment jsdom - */ import React from "react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { Scan } from "lucide-react-native"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AgentOptionControl } from "@getpaseo/protocol/agent-types"; import type { ToastApi } from "@/components/toast-host"; -import { ToastApiProvider } from "@/contexts/toast-context"; +import { ToastApiProvider } from "@/contexts/toast-api-context"; import { ComposerControls, type ComposerControlItem } from "./composer-control"; import { useAgentControlItems } from "./control-items"; import { resolveComposerControlPresentation } from "./layout"; diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 877493cac1..fb23fa1eb5 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -1139,6 +1139,7 @@ export const AgentControls = memo(function AgentControls({ if (!agent || !client) return null; return { context: "agent", + locked: false, provider: agent.provider, model: activeModelId, mode: modeControl?.selectedModeId ?? null, @@ -1359,6 +1360,7 @@ export function DraftAgentControls({ () => draftComposerAgent({ context, + disabled, selectedProvider, selectedModel, selectedMode, @@ -1374,6 +1376,7 @@ export function DraftAgentControls({ }), [ context, + disabled, effectiveSelectedThinkingOption, metadata, onSelectMode, diff --git a/packages/app/src/composer/draft/composer-agent.ts b/packages/app/src/composer/draft/composer-agent.ts index 5aa0dab0c6..a7e4f7dca3 100644 --- a/packages/app/src/composer/draft/composer-agent.ts +++ b/packages/app/src/composer/draft/composer-agent.ts @@ -9,6 +9,7 @@ export function draftComposerAgent( props: Pick< DraftAgentControlsProps, | "context" + | "disabled" | "selectedProvider" | "selectedModel" | "selectedMode" @@ -24,6 +25,7 @@ export function draftComposerAgent( ): ComposerAgent { return { context: props.context ?? "draft", + locked: props.disabled ?? false, provider: props.selectedProvider, model: props.selectedModel || null, mode: props.selectedMode || null, diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 9b93228827..d39f5af16d 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -28,7 +28,8 @@ import { AfterPaintPublication } from "@/composer/after-paint-publication"; import { useShallow } from "zustand/shallow"; import type { ComposerTextSource } from "@/composer/text-source"; import { isWeb } from "@/constants/platform"; -import { applyMetadataPatch } from "@/composer/draft/metadata"; + +const NO_METADATA: Record = {}; type AttachmentUpdater = | UserComposerAttachment[] @@ -197,18 +198,18 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr [saveDraft], ); - const [metadata, setMetadataState] = useState>({}); + const metadata = useDraftStore((state) => state.metadataByDraftKey[draftKey] ?? NO_METADATA); const setMetadata = useCallback( (patch: Record) => - setMetadataState((current) => applyMetadataPatch(current, patch)), - [], + useDraftStore.getState().patchDraftMetadata({ draftKey, patch }), + [draftKey], ); const clear = useCallback( (lifecycle: "sent" | "abandoned") => { textPublication.cancel(); useDraftStore.getState().clearDraftInput({ draftKey, lifecycle }); - if (lifecycle === "sent") setMetadataState({}); + if (lifecycle === "sent") useDraftStore.getState().clearDraftMetadata({ draftKey }); }, [draftKey, textPublication], ); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index f277a82ea3..a41716a13f 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1435,13 +1435,21 @@ function ComposerContentImpl({ ); const composerInput = useMemo( () => ({ + locked: isComposerLocked, textSource, setText: replaceUserInput, insertText: insertUserInput, attachments, changeAttachments: setSelectedAttachments, }), - [attachments, insertUserInput, replaceUserInput, setSelectedAttachments, textSource], + [ + attachments, + insertUserInput, + isComposerLocked, + replaceUserInput, + setSelectedAttachments, + textSource, + ], ); const runClientSlashCommand = useCallback( diff --git a/packages/app/src/composer/session.tsx b/packages/app/src/composer/session.tsx index 7f79f02b5f..73892f0d44 100644 --- a/packages/app/src/composer/session.tsx +++ b/packages/app/src/composer/session.tsx @@ -8,6 +8,8 @@ import type { ComposerTextSource } from "@/composer/text-source"; * control reaches the prompt without knowing which screen mounted the composer. */ export interface ComposerInput { + /** The prompt is held while the composer submits; the built-in input is read-only meanwhile. */ + locked: boolean; textSource: ComposerTextSource; setText: (text: string) => void; /** Inserts at the cursor, replacing the selection. */ @@ -25,6 +27,8 @@ export interface ComposerInput { */ export interface ComposerAgent { context: "agent" | "draft" | "new-workspace"; + /** The agent is being created; the built-in controls are disabled meanwhile. */ + locked: boolean; provider: string | null; model: string | null; mode: string | null; @@ -72,3 +76,10 @@ export function useComposerInput(): ComposerInput | null { export function useComposerAgent(): ComposerAgent | null { return useContext(ComposerAgentContext); } + +/** True while the composer holds its prompt or its agent's settings for a submit in flight. */ +export function useComposerLocked(): boolean { + const input = useComposerInput(); + const agent = useComposerAgent(); + return Boolean(input?.locked || agent?.locked); +} diff --git a/packages/app/src/plugins/composer-items/model.test.ts b/packages/app/src/plugins/composer-items/model.test.ts index e3c7546476..aa5549e03c 100644 --- a/packages/app/src/plugins/composer-items/model.test.ts +++ b/packages/app/src/plugins/composer-items/model.test.ts @@ -59,10 +59,11 @@ const issue = { resourceType: "issue", }; -function fakeAgent(context: ComposerAgent["context"]) { +function fakeAgent(context: ComposerAgent["context"], locked = false) { const calls: string[] = []; const agent: ComposerAgent = { context, + locked, provider: "claude", model: "sonnet", mode: "default", @@ -84,9 +85,10 @@ function fakeAgent(context: ComposerAgent["context"]) { return { agent, calls }; } -function fakeInput(initial: UserComposerAttachment[] = []) { +function fakeInput(initial: UserComposerAttachment[] = [], locked = false) { const state = { text: "", attachments: initial }; const input: ComposerInput = { + locked, textSource: { getSnapshot: () => state.text, subscribe: () => () => {} }, setText: (text) => { state.text = text; @@ -189,6 +191,46 @@ describe("toPluginComposer", () => { expect(state.text).toBe("ab"); }); + it("refuses every prompt and agent command while the composer is locked", async () => { + const { agent, calls } = fakeAgent("new-workspace", true); + const { input, state } = fakeInput([], true); + const composer = toPluginComposer({ + plugin: installed(), + agent, + composerInput: input, + prompt: "", + }); + + expect(() => composer.setPrompt("a")).toThrow("The composer is locked"); + expect(() => composer.insertText("b")).toThrow("The composer is locked"); + await expect(composer.setProvider("codex")).rejects.toThrow("The composer is locked"); + await expect(composer.setModel("gpt")).rejects.toThrow("The composer is locked"); + await expect(composer.setMode("plan")).rejects.toThrow("The composer is locked"); + await expect(composer.setThinking("high")).rejects.toThrow("The composer is locked"); + await expect(composer.setOptions({ effort: "low" })).rejects.toThrow("The composer is locked"); + await expect(composer.setMetadata({ flag: null })).rejects.toThrow("The composer is locked"); + expect(state.text).toBe(""); + expect(calls).toEqual([]); + }); + + it("hands out options and metadata a plugin cannot change", () => { + const { agent } = fakeAgent("draft"); + agent.metadata = { nested: { list: [1] } }; + const { input } = fakeInput(); + const composer = toPluginComposer({ + plugin: installed(), + agent, + composerInput: input, + prompt: "", + }); + + expect(Object.isFrozen(composer.agent.options)).toBe(true); + expect(Object.isFrozen(composer.agent.metadata)).toBe(true); + expect(Object.isFrozen((composer.agent.metadata as { nested: object }).nested)).toBe(true); + expect(agent.metadata).toEqual({ nested: { list: [1] } }); + expect(Object.isFrozen(agent.metadata)).toBe(false); + }); + it("adds and removes the plugin's own attachments only", () => { const { agent } = fakeAgent("agent"); const other: UserComposerAttachment = { diff --git a/packages/app/src/plugins/composer-items/model.ts b/packages/app/src/plugins/composer-items/model.ts index 8a43effebf..8b7b73f27e 100644 --- a/packages/app/src/plugins/composer-items/model.ts +++ b/packages/app/src/plugins/composer-items/model.ts @@ -4,6 +4,7 @@ import type { UserComposerAttachment } from "@/attachments/types"; import type { ComposerAgent, ComposerInput } from "@/composer/session"; import type { InstalledPlugin } from "../types"; import { createPluginResourceAttachment } from "../attachments/model"; +import { cloneAndFreeze } from "../freeze"; function isPluginAttachment( attachment: UserComposerAttachment, @@ -19,6 +20,17 @@ function isPluginAttachment( ); } +const LOCKED_MESSAGE = "The composer is locked while the agent is being created."; + +function assertUnlocked(locked: boolean): void { + if (locked) throw new Error(LOCKED_MESSAGE); +} + +async function whenUnlocked(agent: ComposerAgent, command: () => Promise): Promise { + assertUnlocked(agent.locked); + await command(); +} + /** * What a plugin sees of the composer it sits in. `agent` and `input` are the composer's own * halves, so the plugin's commands reach the same models the built-in controls use. @@ -44,11 +56,17 @@ export function toPluginComposer(input: { model: agent.model, mode: agent.mode, thinking: agent.thinking, - options: agent.options as Record, - metadata: agent.metadata, + options: cloneAndFreeze(agent.options as Record), + metadata: cloneAndFreeze(agent.metadata), + }, + setPrompt: (text) => { + assertUnlocked(composerInput.locked); + composerInput.setText(text); + }, + insertText: (text) => { + assertUnlocked(composerInput.locked); + composerInput.insertText(text); }, - setPrompt: composerInput.setText, - insertText: composerInput.insertText, addAttachment({ sourceId, item }) { const source = plugin.attachmentSources.find((candidate) => candidate.id === sourceId); if (!source) throw new Error(`Unknown attachment source: ${sourceId}`); @@ -72,11 +90,12 @@ export function toPluginComposer(input: { current.filter((candidate) => !isPluginAttachment(candidate, plugin.id, sourceId, itemId)), ); }, - setProvider: agent.setProvider, - setModel: agent.setModel, - setMode: agent.setMode, - setThinking: agent.setThinking, - setOptions: agent.setOptions, - setMetadata: agent.setMetadata, + setProvider: (provider) => whenUnlocked(agent, () => agent.setProvider(provider)), + setModel: (modelId) => whenUnlocked(agent, () => agent.setModel(modelId)), + setMode: (modeId) => whenUnlocked(agent, () => agent.setMode(modeId)), + setThinking: (thinkingOptionId) => + whenUnlocked(agent, () => agent.setThinking(thinkingOptionId)), + setOptions: (patch) => whenUnlocked(agent, () => agent.setOptions(patch)), + setMetadata: (patch) => whenUnlocked(agent, () => agent.setMetadata(patch)), }; } diff --git a/packages/app/src/plugins/freeze.ts b/packages/app/src/plugins/freeze.ts new file mode 100644 index 0000000000..9735d872d8 --- /dev/null +++ b/packages/app/src/plugins/freeze.ts @@ -0,0 +1,12 @@ +/** A deep copy no plugin can change. Plugins read data through it, never the host's own objects. */ +export function cloneAndFreeze(value: T): T { + if (Array.isArray(value)) { + return Object.freeze(value.map((entry) => cloneAndFreeze(entry))) as T; + } + if (value !== null && typeof value === "object") { + return Object.freeze( + Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneAndFreeze(entry)])), + ) as T; + } + return value; +} diff --git a/packages/app/src/plugins/react-native/composer-controls.tsx b/packages/app/src/plugins/react-native/composer-controls.tsx index 450972e727..eb4d05a09b 100644 --- a/packages/app/src/plugins/react-native/composer-controls.tsx +++ b/packages/app/src/plugins/react-native/composer-controls.tsx @@ -5,6 +5,7 @@ import { useComposerControlSurface, type ComposerControlItem, } from "@/composer/agent-controls/composer-control"; +import { useComposerLocked } from "@/composer/session"; import { parsePluginIcon, pluginIconComponent } from "../icons"; /** Icons resolve at render, so an invalid one throws to the item's boundary. */ @@ -21,12 +22,13 @@ function PluginComposerControl({ disabled: boolean | undefined; }) { const surface = useComposerControlSurface(); + const locked = useComposerLocked(); const [open, setOpen] = useState(false); return ( diff --git a/packages/app/src/plugins/timeline/projection.ts b/packages/app/src/plugins/timeline/projection.ts index 0750bdb66a..afc26d07a7 100644 --- a/packages/app/src/plugins/timeline/projection.ts +++ b/packages/app/src/plugins/timeline/projection.ts @@ -1,5 +1,6 @@ import type { AgentTimelineItem, ToolCallTimelineItem } from "@getpaseo/protocol/agent-types"; import type { AgentToolCallData, PluginTimelineStreamItem, StreamItem } from "@/types/stream"; +import { cloneAndFreeze } from "../freeze"; import type { TimelineItemTransform } from "./model"; type AssistantPhase = "streaming" | "complete"; @@ -9,18 +10,6 @@ const projectionCache = new WeakMap< WeakMap>> >(); -function cloneAndFreeze(value: T): T { - if (Array.isArray(value)) { - return Object.freeze(value.map((entry) => cloneAndFreeze(entry))) as T; - } - if (value !== null && typeof value === "object") { - return Object.freeze( - Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneAndFreeze(entry)])), - ) as T; - } - return value; -} - function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem { const { callId, name, status, error, detail, metadata } = data; const base = { diff --git a/packages/app/src/stores/draft-store/index.ts b/packages/app/src/stores/draft-store/index.ts index 9836d3dfec..755b7a25fe 100644 --- a/packages/app/src/stores/draft-store/index.ts +++ b/packages/app/src/stores/draft-store/index.ts @@ -36,6 +36,8 @@ import { } from "./migration"; import { createDraftPersistStorage } from "./persistence"; import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import { applyMetadataPatch } from "@/composer/draft/metadata"; export type { DraftInput, DraftLifecycleState } from "./state"; @@ -49,6 +51,9 @@ interface DraftStoreActions { draftKey: string; lifecycle?: Exclude; }) => void; + /** Plugin metadata the draft will create its agent with; see `composer/draft/metadata`. */ + patchDraftMetadata: (input: { draftKey: string; patch: Record }) => void; + clearDraftMetadata: (input: { draftKey: string }) => void; attachWorkspaceFile: (input: { draftKey: string; attachment: WorkspaceFileComposerAttachment; @@ -60,6 +65,11 @@ interface DraftStoreActions { interface DraftStoreRuntimeState { attachmentFocusRequestByDraftKey: Record; + /** + * Kept beside the prompt so a composer that remounts finds it. Not persisted, and not cleared + * with an emptied prompt: a plugin's choice outlives the text until the draft is sent. + */ + metadataByDraftKey: Record>; } type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions; @@ -256,6 +266,7 @@ export const useDraftStore = create()( drafts: {}, createModalDraft: null, attachmentFocusRequestByDraftKey: {}, + metadataByDraftKey: {}, getDraftInput: (draftKey) => { const record = get().drafts[draftKey]; @@ -374,6 +385,25 @@ export const useDraftStore = create()( scheduleAttachmentGc(); }, + patchDraftMetadata: ({ draftKey, patch }) => { + set((state) => { + const next = applyMetadataPatch(state.metadataByDraftKey[draftKey] ?? {}, patch); + const { [draftKey]: _previous, ...others } = state.metadataByDraftKey; + return { + metadataByDraftKey: + Object.keys(next).length > 0 ? { ...others, [draftKey]: next } : others, + }; + }); + }, + + clearDraftMetadata: ({ draftKey }) => { + set((state) => { + if (!(draftKey in state.metadataByDraftKey)) return state; + const { [draftKey]: _removed, ...others } = state.metadataByDraftKey; + return { metadataByDraftKey: others }; + }); + }, + attachWorkspaceFile: async ({ draftKey, attachment }) => { await get().hydrateDraftInput({ draftKey }); set((state) => { diff --git a/packages/app/src/stores/draft-store/metadata.test.ts b/packages/app/src/stores/draft-store/metadata.test.ts new file mode 100644 index 0000000000..7f2f7a52d6 --- /dev/null +++ b/packages/app/src/stores/draft-store/metadata.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useDraftStore } from "./index"; + +const draftKey = "draft:metadata"; + +describe("a draft's plugin metadata", () => { + beforeEach(() => { + useDraftStore.setState({ drafts: {}, metadataByDraftKey: {} }); + }); + + it("is there when the draft's composer mounts again", () => { + const store = useDraftStore.getState(); + store.editDraftText({ draftKey, text: "review this" }); + store.patchDraftMetadata({ draftKey, patch: { "plugin.review": true, "plugin.tag": "a" } }); + store.patchDraftMetadata({ draftKey, patch: { "plugin.tag": null } }); + + // A remount reads the store again; nothing from the unmounted composer is kept. + const remounted = useDraftStore.getState(); + expect(remounted.getDraftInput(draftKey)?.text).toBe("review this"); + expect(remounted.metadataByDraftKey[draftKey]).toEqual({ "plugin.review": true }); + }); + + it("is dropped with the prompt once the draft is sent, and not before", () => { + const store = useDraftStore.getState(); + store.patchDraftMetadata({ draftKey, patch: { "plugin.review": true } }); + + store.clearDraftInput({ draftKey, lifecycle: "abandoned" }); + expect(useDraftStore.getState().metadataByDraftKey[draftKey]).toEqual({ + "plugin.review": true, + }); + + store.clearDraftMetadata({ draftKey }); + expect(useDraftStore.getState().metadataByDraftKey[draftKey]).toBeUndefined(); + }); +}); diff --git a/packages/app/test-stubs/lucide-react-native.ts b/packages/app/test-stubs/lucide-react-native.ts index a85976e1bf..e03104d01b 100644 --- a/packages/app/test-stubs/lucide-react-native.ts +++ b/packages/app/test-stubs/lucide-react-native.ts @@ -116,3 +116,8 @@ export const Maximize2 = StubIcon; export const Workflow = StubIcon; export const ZoomIn = StubIcon; export const ZoomOut = StubIcon; +export const ListTodo = StubIcon; +export const Shield = StubIcon; +export const ShieldEllipsis = StubIcon; +export const ShieldPlus = StubIcon; +export const Zap = StubIcon; diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 50609e242b..453f6899d1 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1969,6 +1969,11 @@ callback, so `await composer.setProvider("codex")` followed by `await composer.s changes both. A command's promise resolves once the change has taken effect, and `composer.agent` shows it on the next render. +While the New workspace screen creates the workspace and its agent, the composer is locked. Your +controls render disabled, `setPrompt` and `insertText` throw, and the agent commands reject with +"The composer is locked while the agent is being created." `composer.agent.options` and +`composer.agent.metadata` are frozen copies; change them through `setOptions` and `setMetadata`. + A composer item counts toward how much fits before Paseo knows what it renders. An item that renders nothing can still leave an empty overflow sheet on a narrow composer, so always render a control. diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index d5b138bc6f..9ed5eb1b8a 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -1659,6 +1659,8 @@ export default function contribute(client: PluginClientContext) { 每个命令都按运行时 composer 的当前状态执行,而不是按创建回调的那次渲染,所以 `await composer.setProvider("codex")` 之后再 `await composer.setModel("gpt")` 会同时生效。命令的 Promise 在更改生效后完成,`composer.agent` 在下一次渲染时反映它。 +新建工作区界面在创建工作区及其 Agent 期间会锁定 composer。你的控件显示为禁用,`setPrompt` 和 `insertText` 会抛出错误,Agent 命令会以 "The composer is locked while the agent is being created." 拒绝。`composer.agent.options` 和 `composer.agent.metadata` 是冻结的副本;请通过 `setOptions` 和 `setMetadata` 修改它们。 + 在 Paseo 知道渲染结果之前,composer 项就已计入可容纳的数量。不渲染任何内容的项仍可能在窄 composer 上留下空的溢出面板,所以请始终渲染一个控件。 [Composer 控件示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)注册了一个 `Select`、两个 `Toggle`(其中一个写入 Provider 选项)和一个 `Action`。