From abd4d2c1ea1fab14741bd955cbd9f1c15ce8a506 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 05:36:04 +0100 Subject: [PATCH] feat(plugins): composer items and controls in all three composers A plugin registers a component with addComposerItem and reads and changes the composer through useComposer(): provider, model, mode, thinking, options, prompt, attachments and agent metadata. The agent, draft and New workspace composers provide the same ComposerInput and ComposerAgent halves, so a command reaches the right model without the plugin knowing which composer it sits in. Toggle, Select and Action are drawn by one ComposerControl renderer that the thinking select and provider option controls now use too. Controls sit in the toolbar when there is room and become rows in the existing overflow sheet otherwise, so on a phone they appear in the overflow. Plugin items count in the density presence so the composer condenses to make room. Model and mode stay specialized but share the trigger, density and overflow. Draft metadata is kept locally, sent at creation and reset when the prompt is sent. PluginAgentSnapshot.metadata, deferred from the metadata phase, lands here. The unused provider badge is removed. vitest resolves @floating-ui/react-native to its ESM build: the CJS entry requires the real react-native, whose Flow sources do not parse. --- docs/plugins.md | 19 + .../agent-controls/composer-control.test.tsx | 101 ++ .../agent-controls/composer-control.tsx | 277 +++++ .../composer/agent-controls/control-items.tsx | 165 +++ .../app/src/composer/agent-controls/index.tsx | 976 ++++-------------- .../composer/agent-controls/layout.test.ts | 33 + .../app/src/composer/agent-controls/layout.ts | 12 +- .../src/composer/agent-controls/mode.test.ts | 2 + .../src/composer/draft/input-draft-core.ts | 17 +- .../app/src/composer/draft/input-draft.ts | 20 + .../app/src/composer/draft/metadata.test.ts | 19 + packages/app/src/composer/draft/metadata.ts | 14 + .../app/src/composer/draft/workspace-tab.tsx | 3 + packages/app/src/composer/index.tsx | 58 +- packages/app/src/composer/insert-text.test.ts | 18 + packages/app/src/composer/insert-text.ts | 15 + packages/app/src/composer/session.tsx | 74 ++ .../app/src/plugins/buttons/model.test.ts | 1 + .../plugins/client-state/snapshots.test.ts | 2 + .../app/src/plugins/client-state/snapshots.ts | 1 + .../command-center/contributions.test.ts | 2 + .../app/src/plugins/composer-items/index.tsx | 138 +++ .../src/plugins/composer-items/model.test.ts | 214 ++++ .../app/src/plugins/composer-items/model.ts | 82 ++ packages/app/src/plugins/evaluate.test.ts | 32 + packages/app/src/plugins/evaluate.ts | 17 + .../plugins/installation-provider.test.tsx | 1 + .../react-native/composer-controls.tsx | 85 ++ packages/app/src/plugins/react-native/ui.ts | 1 + packages/app/src/plugins/registry.ts | 1 + .../app/src/plugins/sidebar-groups.test.ts | 1 + .../src/plugins/surface-contribution.test.ts | 1 + packages/app/src/plugins/theme.test.ts | 1 + .../app/src/plugins/timeline/model.test.ts | 1 + packages/app/src/plugins/types.ts | 3 + .../workspace-panels/resolution.test.ts | 1 + .../app/src/screens/new-workspace-screen.tsx | 2 + packages/app/src/stores/session-store.ts | 2 + packages/app/src/utils/agent-snapshots.ts | 2 + .../app/test-stubs/react-native-unistyles.ts | 5 +- packages/app/vitest.config.ts | 8 + packages/plugin/src/boundaries.test.ts | 37 + packages/plugin/src/client/composer.tsx | 78 ++ packages/plugin/src/client/contracts.ts | 2 + packages/plugin/src/client/host.ts | 1 + packages/plugin/src/client/index.ts | 7 + .../src/client/runtime-context-bridge.tsx | 11 +- packages/plugin/src/client/ui.ts | 27 + packages/plugin/src/contracts.ts | 2 + plugin-examples/composer-controls/README.md | 17 + .../composer-controls/client/controls.tsx | 55 + .../composer-controls/index.client.tsx | 9 + .../composer-controls/paseo-plugin.json | 4 + public-docs/plugins/index.md | 1 + public-docs/plugins/index.zh-CN.md | 2 +- public-docs/plugins/reference.md | 73 ++ public-docs/plugins/reference.zh-CN.md | 59 ++ 57 files changed, 2029 insertions(+), 783 deletions(-) create mode 100644 packages/app/src/composer/agent-controls/composer-control.test.tsx create mode 100644 packages/app/src/composer/agent-controls/composer-control.tsx create mode 100644 packages/app/src/composer/agent-controls/control-items.tsx create mode 100644 packages/app/src/composer/draft/metadata.test.ts create mode 100644 packages/app/src/composer/draft/metadata.ts create mode 100644 packages/app/src/composer/insert-text.test.ts create mode 100644 packages/app/src/composer/insert-text.ts create mode 100644 packages/app/src/composer/session.tsx create mode 100644 packages/app/src/plugins/composer-items/index.tsx create mode 100644 packages/app/src/plugins/composer-items/model.test.ts create mode 100644 packages/app/src/plugins/composer-items/model.ts create mode 100644 packages/app/src/plugins/react-native/composer-controls.tsx create mode 100644 packages/plugin/src/client/composer.tsx create mode 100644 plugin-examples/composer-controls/README.md create mode 100644 plugin-examples/composer-controls/client/controls.tsx create mode 100644 plugin-examples/composer-controls/index.client.tsx create mode 100644 plugin-examples/composer-controls/paseo-plugin.json diff --git a/docs/plugins.md b/docs/plugins.md index 7cdc057332..e3c3406cfd 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -644,6 +644,25 @@ Mounted surfaces and command invocations have shorter API lifetimes. Keep the client entry synchronous: return its cleanup function immediately and start asynchronous work inside it. See the maintained [composer pill example](../plugin-examples/local-plugin/client/main.tsx). +## Contribute composer items + +`addComposerItem` mounts a plugin component in the agent, draft and New workspace composers. The +public contract is the [composer items reference](../public-docs/plugins/reference.md#composer-items). + +The composer is two contexts, provided by two owners (`packages/app/src/composer/session.tsx`). +`Composer` provides `ComposerInput` (prompt and attachments). `AgentControls` and +`DraftAgentControls` provide `ComposerAgent` (provider, model, mode, thinking, options, metadata and +their commands). `toPluginComposer` (`packages/app/src/plugins/composer-items/`) joins them into the +`PluginComposer` an item reads, so a plugin cannot tell which composer a command reaches. Add a +capability to `ComposerAgent` and both implementations; never branch on `context` in the model. + +Plugin `Toggle`, `Select` and `Action`, the thinking select and provider option controls all +render through `ComposerControl` (`packages/app/src/composer/agent-controls/composer-control.tsx`), +a toolbar icon or a row in the overflow sheet. Model and mode stay specialized but share its trigger, +density and overflow. Plugin items count in the density presence in `layout.ts`, so a composer +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. + ## 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 new file mode 100644 index 0000000000..cfd5aacf78 --- /dev/null +++ b/packages/app/src/composer/agent-controls/composer-control.test.tsx @@ -0,0 +1,101 @@ +import type { TFunction } from "i18next"; +import { Scan } from "lucide-react-native"; +import { describe, expect, it } from "vitest"; +import { describeControl, type ComposerControlItem } from "./composer-control"; +import { toFeatureItem } from "./control-items"; + +const t = ((key: string) => key) as unknown as TFunction; + +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: () => {}, +}; + +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" }, + ], + }); + }); + + 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", + }); + }); + + it("toggles on press in the toolbar and lists on/off in the sheet", () => { + const toggle: ComposerControlItem = { + ...base, + type: "toggle", + value: true, + onChange: () => {}, + }; + 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"); + }); + + 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); + }); +}); + +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]) }, + ); + expect(item).toMatchObject({ + id: "feature-fast_mode", + type: "toggle", + value: true, + highlight: "yellow", + testID: "agent-feature-fast_mode", + }); + if (item.type === "toggle") item.onChange(false); + 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 new file mode 100644 index 0000000000..362fb057ae --- /dev/null +++ b/packages/app/src/composer/agent-controls/composer-control.tsx @@ -0,0 +1,277 @@ +import { createContext, useCallback, useContext, useMemo, useRef, type ReactElement } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import type { AgentControlIcon } from "@/agent-controls/icons"; +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"; + +interface ComposerControlBase { + id: string; + title: string; + icon: AgentControlIcon; + /** Tints the icon; muted when absent. */ + highlight?: "blue" | "green" | "yellow"; + /** Defaults to the title, or to "title: choice" on an icon-only select. */ + tooltip?: string; + /** Defaults to the tooltip. */ + accessibilityLabel?: string; + testID?: string; +} + +export interface ComposerControlChoice { + value: string; + label: string; + description?: string; +} + +/** + * One control in the composer's toolbar, drawn from data. Provider option controls, thinking and + * plugin controls all come through here, so each is an icon in the toolbar and a row in the sheet. + */ +export type ComposerControlItem = + | (ComposerControlBase & { + type: "toggle"; + value: boolean; + onChange: (value: boolean) => void; + }) + | (ComposerControlBase & { + type: "select"; + value: string | null; + choices: readonly ComposerControlChoice[]; + onChange: (value: string) => void; + /** The toolbar trigger shows only the icon. */ + iconOnly?: boolean; + showCaret?: boolean; + searchable?: boolean; + desktopMinWidth?: number; + renderOption?: (args: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; + }) => ReactElement; + }) + | (ComposerControlBase & { + type: "action"; + onPress: () => void; + }); + +export type ComposerControlSurface = "toolbar" | "sheet"; + +const ComposerControlSurfaceContext = createContext("toolbar"); +export const ComposerControlSurfaceProvider = ComposerControlSurfaceContext.Provider; + +/** Where the control being drawn sits: the toolbar, or a row in the overflow sheet. */ +export function useComposerControlSurface(): ComposerControlSurface { + return useContext(ComposerControlSurfaceContext); +} + +interface ComposerControlProps { + item: ComposerControlItem; + surface: ComposerControlSurface; + disabled?: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + /** Runs after a toolbar toggle or action, so the composer can take focus back. */ + onActionComplete?: () => void; +} + +interface ControlView { + options: ComboboxOption[]; + /** The list's selected id. */ + selectedId: string; + /** What the trigger shows beside the icon. */ + value: string; + iconOnly: boolean; + tooltip: string; + /** Whether pressing the trigger opens a list instead of acting. */ + opensList: boolean; +} + +export function describeControl( + item: ComposerControlItem, + surface: ComposerControlSurface, + t: TFunction, +): ControlView { + const isSheet = surface === "sheet"; + if (item.type === "action") { + return { + options: [], + selectedId: "", + value: "", + iconOnly: true, + tooltip: item.tooltip ?? item.title, + opensList: false, + }; + } + if (item.type === "toggle") { + const label = item.value ? t("agentControls.features.on") : t("agentControls.features.off"); + return { + options: [ + { id: "true", label: t("agentControls.features.on") }, + { id: "false", label: t("agentControls.features.off") }, + ], + selectedId: String(item.value), + value: label, + iconOnly: true, + tooltip: item.tooltip ?? item.title, + opensList: isSheet, + }; + } + const choiceLabel = + item.choices.find((choice) => choice.value === item.value)?.label ?? item.title; + const iconOnly = item.iconOnly === true; + return { + options: item.choices.map((choice) => ({ + id: choice.value, + label: choice.label, + description: choice.description, + })), + selectedId: String(item.value ?? ""), + value: choiceLabel, + iconOnly, + tooltip: item.tooltip ?? (iconOnly ? `${item.title}: ${choiceLabel}` : item.title), + opensList: true, + }; +} + +export function ComposerControl({ + item, + surface, + disabled = false, + open, + onOpenChange, + onActionComplete, +}: ComposerControlProps) { + const { t } = useTranslation(); + const anchorRef = useRef(null); + const isSheet = surface === "sheet"; + const sheetHeader = useMemo(() => ({ title: item.title }), [item.title]); + const view = useMemo(() => describeControl(item, surface, t), [item, surface, t]); + + const handlePress = useCallback(() => { + if (item.type === "toggle") item.onChange(!item.value); + else if (item.type === "action") item.onPress(); + onActionComplete?.(); + }, [item, onActionComplete]); + 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"); + }, + [item], + ); + + const select = item.type === "select" ? item : null; + const trigger = ( + + ); + + return ( + <> + {isSheet ? ( + trigger + ) : ( + + + {trigger} + + + {view.tooltip} + + + )} + {view.opensList ? ( + + ) : null} + + ); +} + +interface ComposerControlsProps { + items: readonly ComposerControlItem[]; + surface: ComposerControlSurface; + disabled?: boolean; + /** The id of the item whose list is open. */ + openId: string | null; + onOpenIdChange: (id: string | null) => void; + onActionComplete?: () => void; +} + +/** A row of Paseo-owned controls that share one open list at a time. */ +export function ComposerControls({ + items, + onOpenIdChange, + openId, + ...rest +}: ComposerControlsProps) { + return ( + <> + {items.map((item) => ( + + ))} + + ); +} + +function ComposerControlSlot({ + item, + open, + onOpenIdChange, + ...rest +}: Omit & { item: ComposerControlItem; open: boolean }) { + const handleOpenChange = useCallback( + (next: boolean) => onOpenIdChange(next ? item.id : null), + [item.id, onOpenIdChange], + ); + return ; +} + +const styles = StyleSheet.create((theme) => ({ + blue: { color: theme.colors.palette.blue[400] }, + green: { color: theme.colors.palette.green[400] }, + yellow: { color: theme.colors.palette.yellow[400] }, + tooltipText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + lineHeight: theme.fontSize.base * 1.4, + }, +})); diff --git a/packages/app/src/composer/agent-controls/control-items.tsx b/packages/app/src/composer/agent-controls/control-items.tsx new file mode 100644 index 0000000000..7291bfc3f2 --- /dev/null +++ b/packages/app/src/composer/agent-controls/control-items.tsx @@ -0,0 +1,165 @@ +import { useMemo } from "react"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types"; +import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons"; +import { + getControlTooltip, + isControlActive, + readSelectOption, + readToggleOption, + type ComposerOptionControls, +} from "@/agent-controls/option-controls"; +import { ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; +import { + type ComposerControlItem, + type ComposerControlSurface, +} from "@/composer/agent-controls/composer-control"; +import type { ComposerControlPresentation } from "@/composer/agent-controls/layout"; +import { getAgentControlHintKey, getFeatureHighlightColor } from "@/composer/agent-controls/utils"; + +const SEARCH_THRESHOLD = 6; + +function getFeatureHighlight( + control: AgentOptionControl, + options: ProviderOptions, +): ComposerControlItem["highlight"] { + if (!isControlActive(control, options)) return undefined; + const color = getFeatureHighlightColor(control.key); + return color === "default" ? undefined : color; +} + +export function toFeatureItem( + control: AgentOptionControl, + optionControls: ComposerOptionControls, +): ComposerControlItem { + const { options, onSetOption } = optionControls; + const base = { + id: `feature-${control.key}`, + title: control.label, + icon: getAgentFeatureIcon(control.icon), + highlight: getFeatureHighlight(control, options), + testID: `agent-feature-${control.key}`, + }; + if (control.type === "toggle") { + return { + ...base, + type: "toggle", + tooltip: getControlTooltip(control), + value: readToggleOption(control, options), + onChange: (value) => onSetOption(control.key, value), + }; + } + const iconOnly = control.desktopTrigger === "icon"; + return { + ...base, + type: "select", + tooltip: iconOnly ? undefined : getControlTooltip(control), + value: readSelectOption(control, options), + choices: control.choices, + iconOnly, + onChange: (value) => onSetOption(control.key, value), + }; +} + +interface ThinkingControl { + options: readonly { id: string; label: string }[]; + selectedId: string | undefined; + onSelect: (id: string) => void; + /** The select's displayed label, falling back like the compact model sheet. */ + displayLabel: string; +} + +/** The thinking select and the option controls, as the data `ComposerControl` draws. */ +export function useAgentControlItems({ + surface, + presentation, + thinking, + optionControls, +}: { + surface: ComposerControlSurface; + presentation: ComposerControlPresentation; + thinking: ThinkingControl | null; + optionControls: ComposerOptionControls | undefined; +}): { thinking: ComposerControlItem[]; features: ComposerControlItem[] } { + const { t } = useTranslation(); + + const thinkingItem = useMemo( + () => (thinking ? [toThinkingItem({ thinking, surface, presentation, t })] : []), + [presentation, surface, t, thinking], + ); + const features = useMemo( + () => + optionControls + ? optionControls.controls.map((control) => toFeatureItem(control, optionControls)) + : [], + [optionControls], + ); + return { thinking: thinkingItem, features }; +} + +function toThinkingItem({ + thinking, + surface, + presentation, + t, +}: { + thinking: ThinkingControl; + surface: ComposerControlSurface; + presentation: ComposerControlPresentation; + t: TFunction; +}): ComposerControlItem { + const isSheet = surface === "sheet"; + return { + id: "thinking", + type: "select", + title: t("agentControls.thinking.title"), + icon: ThinkingIcon, + tooltip: t(getAgentControlHintKey("thinking")), + accessibilityLabel: t("agentControls.thinking.selectWithValue", { + value: thinking.displayLabel, + }), + testID: isSheet ? "agent-controls-thinking" : "agent-thinking-selector", + value: thinking.options.some((option) => option.id === thinking.selectedId) + ? (thinking.selectedId ?? null) + : (thinking.options[0]?.id ?? null), + choices: thinking.options.map((option) => ({ value: option.id, label: option.label })), + onChange: thinking.onSelect, + iconOnly: !presentation.showThinkingLabel, + showCaret: presentation.showCarets, + searchable: !isSheet && thinking.options.length > SEARCH_THRESHOLD, + desktopMinWidth: 200, + renderOption: (args) => , + }; +} + +function ThinkingOption({ + option, + selected, + active, + onPress, +}: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; +}) { + const leadingSlot = useMemo( + () => , + [], + ); + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + thinkingIcon: { color: theme.colors.foreground }, +})); diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 69c3567313..ac1438c20a 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -1,13 +1,4 @@ -import { - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ReactElement, - type RefObject, -} from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { router } from "expo-router"; import { @@ -17,23 +8,12 @@ import { Keyboard, useWindowDimensions, type LayoutChangeEvent, - type PressableStateCallbackType, - type StyleProp, - type ViewStyle, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { Settings2 } from "lucide-react-native"; -import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons"; -import { - getControlTooltip, - isControlActive, - readSelectOption, - readToggleOption, - type ComposerOptionControls, -} from "@/agent-controls/option-controls"; +import { readSelectOption, type ComposerOptionControls } from "@/agent-controls/option-controls"; import { formatThinkingOptionLabel } from "@/agent-controls/labels"; -import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { CombinedModelSelector } from "@/components/combined-model-selector"; import { buildProviderSelectorProviders, @@ -45,7 +25,6 @@ import { useSessionStore } from "@/stores/session-store"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { resolveProviderDefinition } from "@/utils/provider-definitions"; import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences"; -import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { AgentModeControl, useLiveAgentModeControl, @@ -55,6 +34,7 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { AgentOptionControl, + JsonValue, ProviderOptions, AgentMode, AgentModelDefinition, @@ -62,7 +42,6 @@ import type { } from "@getpaseo/protocol/agent-types"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { - getFeatureHighlightColor, getAgentControlHintKey, resolveAgentModelSelection, } from "@/composer/agent-controls/utils"; @@ -86,8 +65,14 @@ import { } from "@/composer/agent-controls/layout"; import { ComposerControlLayoutProvider } from "@/composer/agent-controls/layout-context"; import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; -import { AgentControlTrigger } from "@/composer/agent-controls/control"; +import { + ComposerControls, + type ComposerControlItem, +} from "@/composer/agent-controls/composer-control"; +import { useAgentControlItems } from "@/composer/agent-controls/control-items"; import { CompactModelSheet } from "@/composer/agent-controls/model-sheet"; +import { ComposerAgentProvider, type ComposerAgent } from "@/composer/session"; +import { PluginComposerItems, usePluginComposerItemCount } from "@/plugins/composer-items"; import { useAgentProfileEditor, useAgentProfilePicker, @@ -104,16 +89,12 @@ interface AgentControlOption { label: string; } -type AgentControlSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`; - const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = []; const EMPTY_OPTIONS: ProviderOptions = {}; +const EMPTY_METADATA: Record = {}; interface ControlledAgentControlsProps { provider: string; - providerOptions?: AgentControlOption[]; - selectedProviderId?: string; - onSelectProvider?: (providerId: string) => void; modelOptions?: AgentControlOption[]; selectedModelId?: string; onSelectModel?: (modelId: string) => void; @@ -164,6 +145,11 @@ export interface DraftAgentControlsProps { disabled?: boolean; modelSelectorServerId?: string | null; isCompactLayout?: boolean; + /** Which composer a plugin's `useComposer()` reports. */ + context?: "draft" | "new-workspace"; + /** What plugins set on the agent this draft creates. */ + metadata: Record; + onSetMetadata: (patch: Record) => void; } interface AgentControlsProps { @@ -216,60 +202,30 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) { return modeControl?.providerDefinitions ?? EMPTY_AGENT_PROVIDER_DEFINITIONS; } -function getFeatureIconColor( - control: AgentOptionControl, - options: ProviderOptions, - palette: { - blue: { 400: string }; - green: { 400: string }; - yellow: { 400: string }; - }, - foregroundMuted: string, -): string { - if (!isControlActive(control, options)) { - return foregroundMuted; - } - - switch (getFeatureHighlightColor(control.key)) { - case "blue": - return palette.blue[400]; - case "green": - return palette.green[400]; - case "yellow": - return palette.yellow[400]; - default: - return foregroundMuted; - } -} - -type ActiveSheet = "thinking" | "features" | null; +type ActiveSheet = "features" | null; function resolveHasAnyControl({ - providerOptions, canSelectModel, thinkingOptions, controls, hasMode, + pluginItemCount, }: { - providerOptions: AgentControlOption[] | undefined; canSelectModel: boolean; thinkingOptions: AgentControlOption[] | undefined; controls: readonly AgentOptionControl[] | undefined; hasMode: boolean; + pluginItemCount: number; }) { return ( - Boolean(providerOptions?.length) || canSelectModel || Boolean(thinkingOptions?.length) || Boolean(controls?.length) || - hasMode + hasMode || + pluginItemCount > 0 ); } -function toComboboxOptions(options: AgentControlOption[] | undefined): ComboboxOption[] { - return (options ?? []).map((o) => ({ id: o.id, label: o.label })); -} - function toThinkingControlOptions(options: AgentControlOption[] | undefined): AgentControlOption[] { return (options ?? []).map((option) => ({ id: option.id, @@ -333,42 +289,21 @@ function buildFallbackModelSelectorProviders( ]; } -function makeBadgePressableStyle( - baseStyle: StyleProp, - disabledStyle: StyleProp, - disabled: boolean, - isOpen: boolean, -) { - return ({ pressed, hovered }: PressableStateCallbackType) => [ - baseStyle, - hovered && styles.modeBadgeHovered, - (pressed || isOpen) && styles.modeBadgePressed, - disabled && disabledStyle, - ]; -} - function pickSheetModel({ nextProviderId, modelId, - currentProvider, onSelectProviderAndModel, - onSelectProvider, onSelectModel, }: { nextProviderId: string; modelId: string; - currentProvider: string; onSelectProviderAndModel?: (provider: string, modelId: string) => void; - onSelectProvider?: (providerId: string) => void; onSelectModel?: (modelId: string) => void; }) { if (onSelectProviderAndModel) { onSelectProviderAndModel(nextProviderId, modelId); return; } - if (nextProviderId !== currentProvider) { - onSelectProvider?.(nextProviderId); - } onSelectModel?.(modelId); } @@ -404,6 +339,7 @@ type AgentControlsSlice = { runtimeThinkingOptionId: string | null; thinkingOptionId: string | null | undefined; lastUsage: unknown; + metadata: Record | undefined; } | null; function selectAgentControlsSlice( @@ -425,6 +361,7 @@ function selectAgentControlsSlice( runtimeThinkingOptionId: currentAgent.runtimeInfo?.thinkingOptionId ?? null, thinkingOptionId: currentAgent.thinkingOptionId, lastUsage: currentAgent.lastUsage, + metadata: currentAgent.metadata, }; } @@ -468,24 +405,8 @@ function buildAgentProviderModels( return map; } -function buildOpenChangeHandler( - selector: AgentControlSelector, - setOpenSelector: (next: AgentControlSelector | null) => void, - onDropdownClose?: () => void, -) { - return (nextOpen: boolean) => { - setOpenSelector(nextOpen ? selector : null); - if (!nextOpen) { - onDropdownClose?.(); - } - }; -} - function ControlledAgentControls({ provider, - providerOptions, - selectedProviderId, - onSelectProvider, modelOptions, selectedModelId, onSelectModel, @@ -510,35 +431,23 @@ function ControlledAgentControls({ modelSelectorServerId = null, isCompactLayout, }: ControlledAgentControlsProps) { - const { theme } = useUnistyles(); const { t } = useTranslation(); const isCompactFormFactor = useIsCompactFormFactor(); const isCompact = isCompactLayout ?? isCompactFormFactor; const { fontScale } = useWindowDimensions(); const [activeSheet, setActiveSheet] = useState(null); - const [openSelector, setOpenSelector] = useState(null); + const [openSelector, setOpenSelector] = useState(null); const initialDensity: ComposerControlDensity = isCompact ? "tight" : "full"; const [density, setDensity] = useState(initialDensity); const densityRef = useRef(initialDensity); const availableWidthRef = useRef(0); - const providerAnchorRef = useRef(null); - const _modelAnchorRef = useRef(null); - const thinkingAnchorRef = useRef(null); - - const canSelectProvider = Boolean( - onSelectProvider && providerOptions && providerOptions.length > 0, - ); const canSelectModel = Boolean(onSelectModel); const canSelectThinking = Boolean( onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0, ); + const pluginItemCount = usePluginComposerItemCount(modelSelectorServerId); - const displayProvider = findOptionLabel( - providerOptions, - selectedProviderId, - t("agentControls.provider.fallback"), - ); const formattedThinkingOptions = useMemo( () => toThinkingControlOptions(thinkingOptions), [thinkingOptions], @@ -550,11 +459,11 @@ function ControlledAgentControls({ ); const hasAnyControl = resolveHasAnyControl({ - providerOptions, canSelectModel, thinkingOptions, controls: optionControls?.controls, hasMode: modeControl !== null && modeControl !== undefined, + pluginItemCount, }); const featureControls = useMemo( () => @@ -575,9 +484,10 @@ function ControlledAgentControls({ hasThinking: canSelectThinking, hasMode: modeControl !== null && modeControl !== undefined, features: featureControls, + pluginItems: pluginItemCount, fontScale, }), - [canSelectModel, canSelectThinking, featureControls, fontScale, modeControl], + [canSelectModel, canSelectThinking, featureControls, fontScale, modeControl, pluginItemCount], ); const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]); const layoutContextValue = useMemo( @@ -618,65 +528,43 @@ function ControlledAgentControls({ } }, [updateDensityForWidth]); - const modelDisabled = disabled; - - const comboboxProviderOptions = useMemo( - () => toComboboxOptions(providerOptions), - [providerOptions], - ); const fallbackModelSelectorProviders = useMemo( () => buildFallbackModelSelectorProviders(provider, modelOptions), [modelOptions, provider], ); const effectiveModelSelectorProviders = modelSelectorProviders ?? fallbackModelSelectorProviders; - const comboboxThinkingOptions = useMemo( - () => toComboboxOptions(formattedThinkingOptions), - [formattedThinkingOptions], - ); - const renderThinkingOption = useCallback( - (args: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => ( - - ), - [theme.colors.foreground], + const thinking = useMemo( + () => + canSelectThinking && onSelectThinkingOption + ? { + options: formattedThinkingOptions, + selectedId: selectedThinkingOptionId, + onSelect: onSelectThinkingOption, + displayLabel: displayThinking, + } + : null, + [ + canSelectThinking, + displayThinking, + formattedThinkingOptions, + onSelectThinkingOption, + selectedThinkingOptionId, + ], ); + const items = useAgentControlItems({ + surface: isCompact ? "sheet" : "toolbar", + presentation, + thinking, + optionControls, + }); - const handleOpenChange = useCallback( - (selector: AgentControlSelector) => - buildOpenChangeHandler(selector, setOpenSelector, onDropdownClose), - [onDropdownClose], - ); - const handleSheetOpenChange = useCallback( - (selector: AgentControlSelector) => (nextOpen: boolean) => { - setOpenSelector(nextOpen ? selector : null); + const handleToolbarOpenChange = useCallback( + (id: string | null) => { + setOpenSelector(id); + if (id === null) onDropdownClose?.(); }, - [], - ); - - const handleProviderPress = useCallback(() => { - handleOpenChange("provider")(openSelector !== "provider"); - }, [handleOpenChange, openSelector]); - - const handleThinkingPress = useCallback(() => { - handleOpenChange("thinking")(openSelector !== "thinking"); - }, [handleOpenChange, openSelector]); - - const handleProviderOpenChange = useMemo(() => handleOpenChange("provider"), [handleOpenChange]); - const handleThinkingOpenChange = useMemo(() => handleOpenChange("thinking"), [handleOpenChange]); - - const handleProviderSelect = useCallback( - (id: string) => onSelectProvider?.(id), - [onSelectProvider], - ); - const handleThinkingSelect = useCallback( - (id: string) => onSelectThinkingOption?.(id), - [onSelectThinkingOption], + [onDropdownClose], ); const handleDesktopModelSelect = useCallback( @@ -692,17 +580,6 @@ function ControlledAgentControls({ [onSelectModel, onSelectProviderAndModel, provider], ); - const providerPressableStyle = useMemo( - () => - makeBadgePressableStyle( - styles.modeBadge, - styles.disabledBadge, - disabled || !canSelectProvider, - openSelector === "provider", - ), - [canSelectProvider, disabled, openSelector], - ); - const handleOpenSheet = useCallback((sheet: Exclude) => { Keyboard.dismiss(); setActiveSheet(sheet); @@ -713,26 +590,16 @@ function ControlledAgentControls({ if (!isCompact) onDropdownClose?.(); }, [isCompact, onDropdownClose]); - const handleSelectThinkingAndClose = useCallback( - (thinkingOptionId: string) => { - onSelectThinkingOption?.(thinkingOptionId); - setActiveSheet(null); - }, - [onSelectThinkingOption], - ); - const handleSheetModelSelect = useCallback( (nextProviderId: string, modelId: string) => { pickSheetModel({ nextProviderId, modelId, - currentProvider: provider, onSelectProviderAndModel, - onSelectProvider, onSelectModel, }); }, - [onSelectModel, onSelectProvider, onSelectProviderAndModel, provider], + [onSelectModel, onSelectProviderAndModel], ); if (!hasAnyControl) { @@ -745,13 +612,9 @@ function ControlledAgentControls({ {!isCompact ? ( void; onEditAgentProfiles?: () => void; onCreateAgentProfile?: (seed: AgentProfileSeed) => void; @@ -856,56 +691,33 @@ interface DesktopAgentControlsContentProps { agentProfiles: AgentProfilePicker | null; disabled: boolean; isModelLoading: boolean; - canSelectProvider: boolean; canSelectModel: boolean; - canSelectThinking: boolean; modelSelectorProviders: ProviderSelectorProvider[]; - modelDisabled: boolean; - comboboxProviderOptions: ComboboxOption[]; - comboboxThinkingOptions: ComboboxOption[]; - displayProvider: string; - displayThinking: string; - openSelector: AgentControlSelector | null; - providerAnchorRef: RefObject; - thinkingAnchorRef: RefObject; - providerPressableStyle: (state: PressableStateCallbackType) => StyleProp; - handleProviderPress: () => void; - handleThinkingPress: () => void; - handleProviderSelect: (id: string) => void; - handleThinkingSelect: (id: string) => void; - handleDesktopModelSelect: (providerId: string, modelId: string) => void; - handleProviderOpenChange: (open: boolean) => void; - handleThinkingOpenChange: (open: boolean) => void; - handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; - handleNestedOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; - renderThinkingOption: (args: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; - }) => ReactElement; + openSelector: string | null; + handleSheetOpenChange: (id: string | null) => void; modeControl?: AgentModeControlValue | null; - presentation: ComposerControlPresentation; glyphSize: number; - activeSheet: ActiveSheet; - handleOpenSheet: (sheet: Exclude) => void; - handleCloseSheet: () => void; modelSelectorServerId: string | null; } -const DESKTOP_SEARCH_THRESHOLD = 6; +interface DesktopAgentControlsContentProps extends AgentControlsContentProps { + handleDesktopModelSelect: (providerId: string, modelId: string) => void; + handleToolbarOpenChange: (id: string | null) => void; + presentation: ComposerControlPresentation; + pluginItemCount: number; + activeSheet: ActiveSheet; + handleOpenSheet: (sheet: Exclude) => void; + handleCloseSheet: () => void; +} function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const { provider, - providerOptions, - selectedProviderId, selectedModelId, - thinkingOptions, - selectedThinkingOptionId, - optionControls, + thinkingItems, + featureItems, onApplyAgentProfile, onEditAgentProfiles, onCreateAgentProfile, @@ -917,31 +729,15 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { agentProfiles, disabled, isModelLoading, - canSelectProvider, canSelectModel, - canSelectThinking, modelSelectorProviders, - modelDisabled, - comboboxProviderOptions, - comboboxThinkingOptions, - displayProvider, - displayThinking, openSelector, - providerAnchorRef, - thinkingAnchorRef, - providerPressableStyle, - handleProviderPress, - handleThinkingPress, - handleProviderSelect, - handleThinkingSelect, handleDesktopModelSelect, - handleProviderOpenChange, - handleThinkingOpenChange, - handleOpenChange, - handleNestedOpenChange, - renderThinkingOption, + handleToolbarOpenChange, + handleSheetOpenChange, modeControl, presentation, + pluginItemCount, glyphSize, activeSheet, handleOpenSheet, @@ -957,35 +753,9 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { [t], ); const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]); + const hasFeatures = featureItems.length > 0 || pluginItemCount > 0; return ( <> - {providerOptions && providerOptions.length > 0 ? ( - <> - - {displayProvider} - - DESKTOP_SEARCH_THRESHOLD} - open={openSelector === "provider"} - onOpenChange={handleProviderOpenChange} - anchorRef={providerAnchorRef} - desktopPlacement="top-start" - /> - - ) : null} - {canSelectModel ? ( @@ -1001,7 +771,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { onCreateProfile={onCreateAgentProfile} onEditProfile={onEditAgentProfile} isLoading={isModelLoading} - disabled={modelDisabled} + disabled={disabled} onOpen={onModelSelectorOpen} onClose={onDropdownClose} onRetryProvider={onRetryModelProvider} @@ -1019,49 +789,17 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { ) : null} - {thinkingOptions && thinkingOptions.length > 0 ? ( - <> - - - - - - {t(getAgentControlHintKey("thinking"))} - - - DESKTOP_SEARCH_THRESHOLD} - open={openSelector === "thinking"} - onOpenChange={handleThinkingOpenChange} - anchorRef={thinkingAnchorRef} - desktopPlacement="top-start" - desktopMinWidth={200} - renderOption={renderThinkingOption} - /> - - ) : null} + {modeControl ? : null} - {presentation.aggregateFeatures && optionControls?.controls.length ? ( + {presentation.aggregateFeatures && hasFeatures ? ( <> - {optionControls.controls.map((control) => ( - - ))} + + ) : ( - optionControls?.controls.map((control) => ( - + - )) + + )} ); } -interface SheetAgentControlsContentProps { - provider: string; - selectedModelId?: string; - selectedThinkingOptionId?: string; - optionControls?: ComposerOptionControls; - onApplyAgentProfile?: (profileId: string) => void; - onEditAgentProfiles?: () => void; - onCreateAgentProfile?: (seed: AgentProfileSeed) => void; - onEditAgentProfile?: (profileId: string) => void; - onDropdownClose?: () => void; - onModelSelectorOpen?: () => void; - onRetryModelProvider?: (provider: AgentProvider) => void; - isRetryingModelProvider: boolean; - agentProfiles: AgentProfilePicker | null; - disabled: boolean; - isModelLoading: boolean; - canSelectModel: boolean; - canSelectThinking: boolean; - modelSelectorProviders: ProviderSelectorProvider[]; - modelDisabled: boolean; - comboboxThinkingOptions: ComboboxOption[]; - openSelector: AgentControlSelector | null; - displayThinking: string; - activeSheet: ActiveSheet; - handleOpenSheet: (sheet: Exclude) => void; - handleCloseSheet: () => void; +interface SheetAgentControlsContentProps extends AgentControlsContentProps { + displayThinking: string | null; handleSheetModelSelect: (providerId: string, modelId: string) => void; - handleSelectThinkingAndClose: (thinkingOptionId: string) => void; - handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; - renderThinkingOption: (args: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; - }) => ReactElement; - modeControl?: AgentModeControlValue | null; - glyphSize: number; - modelSelectorServerId: string | null; canSwitchProvider: boolean; } function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { - const { t } = useTranslation(); const { provider, selectedModelId, - selectedThinkingOptionId, - optionControls, + thinkingItems, + featureItems, onApplyAgentProfile, onEditAgentProfiles, onCreateAgentProfile, @@ -1172,88 +870,37 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { disabled, isModelLoading, canSelectModel, - canSelectThinking, modelSelectorProviders, - modelDisabled, - comboboxThinkingOptions, openSelector, displayThinking, - activeSheet, - handleOpenSheet, - handleCloseSheet, handleSheetModelSelect, - handleSelectThinkingAndClose, - handleOpenChange, - renderThinkingOption, + handleSheetOpenChange, modeControl, glyphSize, modelSelectorServerId, canSwitchProvider, } = props; - const thinkingAnchorRef = useRef(null); - const sheetOptions = optionControls?.options ?? EMPTY_OPTIONS; - - const hasThinking = comboboxThinkingOptions.length > 0; - - const handleOpenThinking = useCallback(() => handleOpenSheet("thinking"), [handleOpenSheet]); - const handleThinkingSheetOpenChange = useCallback( - (nextOpen: boolean) => { - if (nextOpen) { - handleOpenSheet("thinking"); - } else { - handleCloseSheet(); - } - }, - [handleCloseSheet, handleOpenSheet], - ); - const sheetControls = ( - {hasThinking ? ( - <> - - - - ) : null} + {modeControl ? : null} - {(optionControls?.controls ?? []).map((control) => ( - - ))} + + ); @@ -1262,7 +909,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { providers={modelSelectorProviders} selectedProvider={provider} selectedModel={selectedModelId ?? ""} - thinkingLabel={hasThinking ? displayThinking : null} + thinkingLabel={displayThinking} onSelect={handleSheetModelSelect} profiles={agentProfiles} onApplyProfile={onApplyAgentProfile} @@ -1270,7 +917,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { onCreateProfile={onCreateAgentProfile} onEditProfile={onEditAgentProfile} isLoading={isModelLoading} - disabled={modelDisabled} + disabled={disabled} onOpen={onModelSelectorOpen} onClose={onDropdownClose} onRetryProvider={onRetryModelProvider} @@ -1284,250 +931,6 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { ) : null; } -function DesktopFeatureItem({ - control, - options, - disabled, - openSelector, - handleOpenChange, - onSetOption, - onActionComplete, -}: { - control: AgentOptionControl; - options: ProviderOptions; - disabled: boolean; - openSelector: AgentControlSelector | null; - handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; - onSetOption?: (key: string, value: unknown) => void; - onActionComplete?: () => void; -}) { - const { theme } = useUnistyles(); - const featureSelector: AgentControlSelector = `feature-${control.key}`; - const featureAnchorRef = useRef(null); - - const handleFeatureOpenChange = useMemo( - () => handleOpenChange(featureSelector), - [handleOpenChange, featureSelector], - ); - const handleSelectPress = useCallback( - () => handleFeatureOpenChange(openSelector !== featureSelector), - [featureSelector, handleFeatureOpenChange, openSelector], - ); - - const handleTogglePress = useCallback(() => { - if (control.type === "toggle") { - onSetOption?.(control.key, !readToggleOption(control, options)); - onActionComplete?.(); - } - }, [control, onActionComplete, onSetOption, options]); - - const handleSelectOption = useCallback( - (choiceValue: string) => { - onSetOption?.(control.key, choiceValue); - }, - [control.key, onSetOption], - ); - const comboboxOptions = useMemo( - () => - control.type === "select" - ? control.choices.map((choice) => ({ id: choice.value, label: choice.label })) - : [], - [control], - ); - - const FeatureIcon = getAgentFeatureIcon(control.icon); - const iconColor = getFeatureIconColor( - control, - options, - theme.colors.palette, - theme.colors.foregroundMuted, - ); - - if (control.type === "toggle") { - return ( - - - - - - {getControlTooltip(control)} - - - ); - } - - const selectedValue = readSelectOption(control, options); - const selectedChoice = control.choices.find((choice) => choice.value === selectedValue); - const iconOnly = control.desktopTrigger === "icon"; - const tooltip = iconOnly - ? `${control.label}: ${selectedChoice?.label ?? control.label}` - : getControlTooltip(control); - return ( - <> - - - - - - {tooltip} - - - - - ); -} - -function SheetFeatureItem({ - control, - options, - disabled, - openSelector, - handleOpenChange, - onSetOption, -}: { - control: AgentOptionControl; - options: ProviderOptions; - disabled: boolean; - openSelector: AgentControlSelector | null; - handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; - onSetOption?: (key: string, value: unknown) => void; -}) { - const { theme } = useUnistyles(); - const { t } = useTranslation(); - const featureSelector: AgentControlSelector = `feature-${control.key}`; - const featureAnchorRef = useRef(null); - - const handleFeatureOpenChange = useMemo( - () => handleOpenChange(featureSelector), - [handleOpenChange, featureSelector], - ); - const handleSelectPress = useCallback( - () => handleFeatureOpenChange(openSelector !== featureSelector), - [featureSelector, handleFeatureOpenChange, openSelector], - ); - const sheetHeader = useMemo(() => ({ title: control.label }), [control.label]); - - const handleSelectOption = useCallback( - (choiceValue: string) => { - onSetOption?.(control.key, control.type === "toggle" ? choiceValue === "true" : choiceValue); - }, - [control.key, control.type, onSetOption], - ); - const comboboxOptions = useMemo(() => { - if (control.type === "select") { - return control.choices.map((choice) => ({ id: choice.value, label: choice.label })); - } - return [ - { id: "true", label: t("agentControls.features.on") }, - { id: "false", label: t("agentControls.features.off") }, - ]; - }, [control, t]); - - const FeatureIcon = getAgentFeatureIcon(control.icon); - const iconColor = getFeatureIconColor( - control, - options, - theme.colors.palette, - theme.colors.foregroundMuted, - ); - let selectedValue: string; - let displayValue: string; - if (control.type === "toggle") { - const isOn = readToggleOption(control, options); - selectedValue = String(isOn); - displayValue = isOn ? t("agentControls.features.on") : t("agentControls.features.off"); - } else { - const selected = readSelectOption(control, options); - selectedValue = String(selected); - displayValue = - control.choices.find((choice) => choice.value === selected)?.label ?? control.label; - } - return ( - <> - - - - ); -} - -function ThinkingComboboxOption({ - option, - selected, - active, - onPress, - iconColor, -}: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; - iconColor: string; -}) { - const leadingSlot = useMemo(() => , [iconColor]); - return ( - - ); -} - function useAgentOptionControls( agent: { controls?: AgentOptionControl[]; options?: ProviderOptions } | null | undefined, onSetOption: ComposerOptionControls["onSetOption"], @@ -1719,6 +1122,42 @@ export const AgentControls = memo(function AgentControls({ const agentOptionControls = useAgentOptionControls(agent, handleSetOption); + const composerAgent = useMemo(() => { + if (!agent || !client) return null; + return { + context: "agent", + provider: agent.provider, + model: activeModelId, + mode: modeControl?.selectedModeId ?? null, + thinking: modelSelection.selectedThinkingId, + options: agent.options ?? EMPTY_OPTIONS, + metadata: agent.metadata ?? EMPTY_METADATA, + setProvider: () => Promise.reject(new Error("A running agent cannot change provider.")), + setModel: async (modelId) => { + await client.setAgentModel(agentId, modelId); + }, + setMode: async (modeId) => { + await client.setAgentMode(agentId, modeId); + }, + setThinking: async (thinkingOptionId) => { + await client.setAgentThinkingOption(agentId, thinkingOptionId); + }, + setOptions: async (patch) => { + await client.setAgentOptions(agentId, patch); + }, + setMetadata: async (patch) => { + await client.setAgentMetadata(agentId, patch); + }, + }; + }, [ + activeModelId, + agent, + agentId, + client, + modeControl?.selectedModeId, + modelSelection.selectedThinkingId, + ]); + const commandCenterControls = useMemo( () => ({ serverId, @@ -1780,7 +1219,7 @@ export const AgentControls = memo(function AgentControls({ } return ( - <> + {commandCenterRegistration} {profileEditor.element} - + ); }); @@ -1837,6 +1276,9 @@ export function DraftAgentControls({ disabled = false, modelSelectorServerId = null, isCompactLayout, + context = "draft", + metadata, + onSetMetadata, }: DraftAgentControlsProps) { const mappedThinkingOptions = useMemo(() => { return toThinkingControlOptions(thinkingOptions); @@ -1896,8 +1338,44 @@ export function DraftAgentControls({ [selectedProvider, providerDefinitions, modeOptions, selectedMode, onSelectMode, disabled], ); + const composerAgent = useMemo( + () => ({ + 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), + }), + [ + context, + effectiveSelectedThinkingOption, + metadata, + onSelectMode, + onSelectModel, + onSelectProviderAndModel, + onSelectThinkingOption, + onSetMetadata, + optionControls, + selectedMode, + selectedModel, + selectedProvider, + ], + ); + return ( - <> + {profileEditor.element} - + ); } @@ -1939,24 +1417,10 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[1], overflow: "hidden", }, - modeBadge: { - height: 28, - flexDirection: "row", - alignItems: "center", - backgroundColor: "transparent", - gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], - }, modelControl: { minWidth: 0, flexShrink: 1, }, - toolbarCaret: { - width: 14, - height: 14, - flexShrink: 0, - }, modeIconBadge: { width: 28, height: 28, @@ -1967,22 +1431,6 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: "transparent", borderRadius: theme.borderRadius.full, }, - modeBadgeHovered: { - backgroundColor: theme.colors.surface2, - }, - modeBadgePressed: { - backgroundColor: theme.colors.surface0, - }, - disabledBadge: { - opacity: 0.5, - }, - modeBadgeText: { - minWidth: 0, - flexShrink: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - }, tooltipText: { color: theme.colors.foreground, fontSize: theme.fontSize.base, diff --git a/packages/app/src/composer/agent-controls/layout.test.ts b/packages/app/src/composer/agent-controls/layout.test.ts index 2e52972b1c..2c88b866e2 100644 --- a/packages/app/src/composer/agent-controls/layout.test.ts +++ b/packages/app/src/composer/agent-controls/layout.test.ts @@ -34,6 +34,7 @@ describe("composer control layout", () => { hasThinking: true, hasMode: true, features: [{ type: "toggle" as const }], + pluginItems: 0, fontScale: 1, }; @@ -95,6 +96,7 @@ describe("composer control layout", () => { hasThinking: true, hasMode: true, features: [{ type: "toggle" }], + pluginItems: 0, fontScale: 1, }, }), @@ -107,6 +109,7 @@ describe("composer control layout", () => { hasThinking: true, hasMode: true, features: [{ type: "toggle" }, { type: "select", label: "Tools" }], + pluginItems: 0, fontScale: 1, }, }), @@ -119,6 +122,7 @@ describe("composer control layout", () => { hasThinking: true, hasMode: true, features: [{ type: "toggle" }], + pluginItems: 0, fontScale: 1.25, }, }), @@ -133,6 +137,7 @@ describe("composer control layout", () => { hasModel: true, hasThinking: true, hasMode: true, + pluginItems: 0, fontScale: 1, }, }; @@ -154,6 +159,34 @@ describe("composer control layout", () => { ).toBe("condensed"); }); + it("condenses to make room for plugin items and keeps them in the overflow", () => { + const base = { + availableWidth: 430, + currentDensity: "full" as const, + controls: { + hasModel: true, + hasThinking: true, + hasMode: true, + features: [], + fontScale: 1, + }, + }; + + expect( + resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 0 } }), + ).toBe("full"); + expect( + resolveComposerControlDensity({ ...base, controls: { ...base.controls, pluginItems: 2 } }), + ).toBe("condensed"); + expect( + resolveComposerControlDensity({ + ...base, + availableWidth: 250, + controls: { ...base.controls, pluginItems: 2 }, + }), + ).toBe("tight"); + }); + it("gives every toolbar control one shell and one platform glyph envelope", () => { expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({ controlSize: 28, diff --git a/packages/app/src/composer/agent-controls/layout.ts b/packages/app/src/composer/agent-controls/layout.ts index 6f7c78cc73..32cff2aa5b 100644 --- a/packages/app/src/composer/agent-controls/layout.ts +++ b/packages/app/src/composer/agent-controls/layout.ts @@ -5,6 +5,8 @@ export interface ComposerControlPresence { hasThinking: boolean; hasMode: boolean; features: readonly ComposerFeatureControlPresence[]; + /** Plugin composer items. Each is budgeted like a select with a short label. */ + pluginItems: number; fontScale: number; } @@ -26,6 +28,7 @@ export const COMPOSER_TOOLBAR_GEOMETRY = { } as const; const DENSITY_HYSTERESIS = 12; +const PLUGIN_ITEM_LABEL = "Plugin item"; function normalizedFontScale(fontScale: number): number { return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1; @@ -62,7 +65,9 @@ function resolveCondensedFloor(controls: ComposerControlPresence): number { if (controls.hasModel) widths.push(36 + 60 * fontScale); if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize); if (controls.hasMode) widths.push(36 + 96 * fontScale); - if (controls.features.length > 0) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize); + if (controls.features.length > 0 || controls.pluginItems > 0) { + widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize); + } return sumControlWidths(widths); } @@ -75,6 +80,11 @@ function resolveFullFloor(controls: ComposerControlPresence): number { for (const feature of controls.features) { widths.push(resolveFeatureControlWidth(feature, fontScale)); } + for (let index = 0; index < controls.pluginItems; index++) { + widths.push( + resolveFeatureControlWidth({ type: "select", label: PLUGIN_ITEM_LABEL }, fontScale), + ); + } return sumControlWidths(widths); } diff --git a/packages/app/src/composer/agent-controls/mode.test.ts b/packages/app/src/composer/agent-controls/mode.test.ts index 0905e3cd4c..ca160ec9c7 100644 --- a/packages/app/src/composer/agent-controls/mode.test.ts +++ b/packages/app/src/composer/agent-controls/mode.test.ts @@ -34,6 +34,8 @@ describe("resolveAgentControlsMode", () => { selectedThinkingOptionId: "", onSelectThinkingOption: () => undefined, onApplyAgentProfile: () => undefined, + metadata: {}, + onSetMetadata: () => undefined, }), ).toBe("draft"); }); diff --git a/packages/app/src/composer/draft/input-draft-core.ts b/packages/app/src/composer/draft/input-draft-core.ts index 00bb581888..b58d6782a1 100644 --- a/packages/app/src/composer/draft/input-draft-core.ts +++ b/packages/app/src/composer/draft/input-draft-core.ts @@ -1,3 +1,4 @@ +import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { UserComposerAttachment } from "@/attachments/types"; import type { DraftAgentControlsProps } from "@/composer/agent-controls"; import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state"; @@ -23,8 +24,19 @@ export function buildDraftAgentControls(input: { optionControls?: DraftAgentControlsProps["optionControls"]; onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"]; onDropdownClose?: DraftAgentControlsProps["onDropdownClose"]; + context: NonNullable; + metadata: Record; + onSetMetadata: (patch: Record) => void; }): DraftAgentControlsProps { - const { formState, optionControls, onApplyAgentProfile, onDropdownClose } = input; + const { + formState, + optionControls, + onApplyAgentProfile, + onDropdownClose, + context, + metadata, + onSetMetadata, + } = input; return { providerDefinitions: formState.providerDefinitions, selectedProvider: formState.selectedProvider, @@ -48,6 +60,9 @@ export function buildDraftAgentControls(input: { onRetryModelProvider: formState.refreshProviderModels, isRetryingModelProvider: formState.isProviderModelsRefreshing, modelSelectorServerId: formState.selectedServerId, + context, + metadata, + onSetMetadata, }; } diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index dac68abcf9..8081fc2e5a 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { UserComposerAttachment } from "@/attachments/types"; import type { TextReplacement } from "@/composer/types"; import type { DraftAgentControlsProps } from "@/composer/agent-controls"; @@ -27,6 +28,7 @@ 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"; type AttachmentUpdater = | UserComposerAttachment[] @@ -36,6 +38,8 @@ interface AgentInputDraftComposerOptions { initialServerId: string | null; initialValues?: CreateAgentInitialValues; initialProviderOptions?: Record; + /** Which composer a plugin's `useComposer()` reports. */ + context?: "draft" | "new-workspace"; isVisible?: boolean; lockedWorkingDir?: string; } @@ -51,6 +55,8 @@ type DraftComposerState = UseAgentFormStateResult & { effectiveModelId: string; effectiveThinkingOptionId: string; providerOptions: Record | undefined; + /** What plugins set on the agent this draft creates. */ + metadata: Record; agentControls: DraftAgentControlsProps; commandDraft: DraftCommandTarget; }; @@ -191,10 +197,18 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr [saveDraft], ); + const [metadata, setMetadataState] = useState>({}); + const setMetadata = useCallback( + (patch: Record) => + setMetadataState((current) => applyMetadataPatch(current, patch)), + [], + ); + const clear = useCallback( (lifecycle: "sent" | "abandoned") => { textPublication.cancel(); useDraftStore.getState().clearDraftInput({ draftKey, lifecycle }); + if (lifecycle === "sent") setMetadataState({}); }, [draftKey, textPublication], ); @@ -319,10 +333,14 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr effectiveModelId, effectiveThinkingOptionId, providerOptions: draftOptions, + metadata, agentControls: buildDraftAgentControls({ formState, optionControls: draftOptionControls, onApplyAgentProfile: applyDraftAgentProfile, + context: composerOptions.context ?? "draft", + metadata, + onSetMetadata: setMetadata, }), commandDraft, }; @@ -336,6 +354,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr draftOptions, applyDraftAgentProfile, formState, + metadata, + setMetadata, workingDir, ]); diff --git a/packages/app/src/composer/draft/metadata.test.ts b/packages/app/src/composer/draft/metadata.test.ts new file mode 100644 index 0000000000..c1a0e28213 --- /dev/null +++ b/packages/app/src/composer/draft/metadata.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { applyMetadataPatch } from "./metadata"; + +describe("applyMetadataPatch", () => { + it("replaces top-level keys and removes keys patched to null", () => { + expect( + applyMetadataPatch( + { "linear.issue": { id: "ENG-1" }, review: true }, + { "linear.issue": { id: "ENG-2" }, review: null, extra: [1] }, + ), + ).toEqual({ "linear.issue": { id: "ENG-2" }, extra: [1] }); + }); + + it("leaves the current record untouched", () => { + const current = { review: true }; + applyMetadataPatch(current, { review: null }); + expect(current).toEqual({ review: true }); + }); +}); diff --git a/packages/app/src/composer/draft/metadata.ts b/packages/app/src/composer/draft/metadata.ts new file mode 100644 index 0000000000..481abd24c2 --- /dev/null +++ b/packages/app/src/composer/draft/metadata.ts @@ -0,0 +1,14 @@ +import type { JsonValue } from "@getpaseo/protocol/agent-types"; + +/** Top-level keys replace; `null` removes the key. The daemon applies the same rule to an agent. */ +export function applyMetadataPatch( + current: Readonly>, + patch: Readonly>, +): Record { + const next = { ...current }; + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete next[key]; + else next[key] = value; + } + return next; +} diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index b1e6ce9ddc..084590347a 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -1,5 +1,6 @@ import type { ComposerOptionControls } from "@/agent-controls/option-controls"; import { useCallback, useEffect, useMemo, useRef } from "react"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; @@ -133,6 +134,7 @@ async function submitDraftCreateRequest(input: { effectiveModelId: string | null; effectiveThinkingOptionId: string | null; providerOptions: Record | undefined; + metadata: Record; }; hostDisconnectedMessage: string; selectModelMessage: string; @@ -182,6 +184,7 @@ async function submitDraftCreateRequest(input: { workspaceId, initialPrompt: text, clientMessageId: attempt.clientMessageId, + ...(Object.keys(composerState.metadata).length > 0 ? { metadata: composerState.metadata } : {}), ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), ...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}), }; diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index d6b4bae63f..f277a82ea3 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -139,6 +139,8 @@ import type { } from "@/attachments/types"; import type { SelectedFile } from "@/attachments/selected-file"; import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit"; +import { ComposerInputProvider, type ComposerInput } from "@/composer/session"; +import { insertAtSelection } from "@/composer/insert-text"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store"; import { droppedItemsToSelectedFiles } from "@/composer/attachments/drop"; @@ -1419,6 +1421,29 @@ function ComposerContentImpl({ [onChangeText], ); + const insertUserInput = useCallback( + (text: string) => { + const input = messageInputRef.current; + if (!input) { + onChangeText(textSource.getSnapshot() + text); + return; + } + const inserted = insertAtSelection(input.getInputSnapshot(), text); + input.replaceText(inserted.text, inserted.selection); + }, + [onChangeText, textSource], + ); + const composerInput = useMemo( + () => ({ + textSource, + setText: replaceUserInput, + insertText: insertUserInput, + attachments, + changeAttachments: setSelectedAttachments, + }), + [attachments, insertUserInput, replaceUserInput, setSelectedAttachments, textSource], + ); + const runClientSlashCommand = useCallback( (command: ClientSlashCommand): boolean => { if (command.execution !== "immediate" || !onClientSlashCommand) { @@ -2223,18 +2248,27 @@ function ComposerContentImpl({ [attachments, setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen], ); - const leftContent = useMemo( - () => - renderLeftContent({ - agentControls, - agentId, - serverId, - focusInput, - isCompactLayout, - showAgentControls: mode.showAgentControls, - }), - [agentControls, agentId, focusInput, isCompactLayout, mode.showAgentControls, serverId], - ); + const leftContent = useMemo(() => { + const controls = renderLeftContent({ + agentControls, + agentId, + serverId, + focusInput, + isCompactLayout, + showAgentControls: mode.showAgentControls, + }); + return controls ? ( + {controls} + ) : null; + }, [ + agentControls, + agentId, + composerInput, + focusInput, + isCompactLayout, + mode.showAgentControls, + serverId, + ]); const handleAttachButtonRef = useCallback((node: View | null) => { attachButtonRef.current = node; diff --git a/packages/app/src/composer/insert-text.test.ts b/packages/app/src/composer/insert-text.test.ts new file mode 100644 index 0000000000..b48c1a1248 --- /dev/null +++ b/packages/app/src/composer/insert-text.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { insertAtSelection } from "./insert-text"; + +describe("insertAtSelection", () => { + it("inserts at the caret and moves the caret after the insertion", () => { + expect(insertAtSelection({ text: "ab", selection: { start: 1, end: 1 } }, "XY")).toEqual({ + text: "aXYb", + selection: { start: 3, end: 3 }, + }); + }); + + it("replaces the selection, however it was dragged", () => { + expect(insertAtSelection({ text: "abcd", selection: { start: 3, end: 1 } }, "-")).toEqual({ + text: "a-d", + selection: { start: 2, end: 2 }, + }); + }); +}); diff --git a/packages/app/src/composer/insert-text.ts b/packages/app/src/composer/insert-text.ts new file mode 100644 index 0000000000..78127e744a --- /dev/null +++ b/packages/app/src/composer/insert-text.ts @@ -0,0 +1,15 @@ +import type { ComposerInputSnapshot } from "@/composer/input/input"; + +/** The text with `inserted` in place of the selection, and the caret after it. */ +export function insertAtSelection( + { text, selection }: ComposerInputSnapshot, + inserted: string, +): ComposerInputSnapshot { + const start = Math.min(selection.start, selection.end); + const end = Math.max(selection.start, selection.end); + const caret = start + inserted.length; + return { + text: text.slice(0, start) + inserted + text.slice(end), + selection: { start: caret, end: caret }, + }; +} diff --git a/packages/app/src/composer/session.tsx b/packages/app/src/composer/session.tsx new file mode 100644 index 0000000000..7f79f02b5f --- /dev/null +++ b/packages/app/src/composer/session.tsx @@ -0,0 +1,74 @@ +import { createContext, useContext, type ReactNode } from "react"; +import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types"; +import type { UserComposerAttachment } from "@/attachments/types"; +import type { ComposerTextSource } from "@/composer/text-source"; + +/** + * The text and attachments of the composer a control sits in. `Composer` provides it, so a + * control reaches the prompt without knowing which screen mounted the composer. + */ +export interface ComposerInput { + textSource: ComposerTextSource; + setText: (text: string) => void; + /** Inserts at the cursor, replacing the selection. */ + insertText: (text: string) => void; + attachments: readonly UserComposerAttachment[]; + changeAttachments: ( + updater: (current: UserComposerAttachment[]) => UserComposerAttachment[], + ) => void; +} + +/** + * The agent a composer configures, as one set of reads and commands. A running agent sends each + * command to the daemon; a draft keeps it locally until the agent is created. Controls call the + * same command either way. + */ +export interface ComposerAgent { + context: "agent" | "draft" | "new-workspace"; + provider: string | null; + model: string | null; + mode: string | null; + thinking: string | null; + /** The options the agent runs with, defaults included. */ + options: ProviderOptions; + metadata: Record; + setProvider: (provider: string) => Promise; + setModel: (modelId: string) => Promise; + setMode: (modeId: string) => Promise; + setThinking: (thinkingOptionId: string) => Promise; + /** Top-level keys replace; `null` removes the agent's own value. */ + setOptions: (patch: ProviderOptions) => Promise; + /** Top-level keys replace; `null` removes the key. */ + setMetadata: (patch: Record) => Promise; +} + +const ComposerInputContext = createContext(null); +const ComposerAgentContext = createContext(null); + +export function ComposerInputProvider({ + value, + children, +}: { + value: ComposerInput; + children: ReactNode; +}) { + return {children}; +} + +export function ComposerAgentProvider({ + value, + children, +}: { + value: ComposerAgent | null; + children: ReactNode; +}) { + return {children}; +} + +export function useComposerInput(): ComposerInput | null { + return useContext(ComposerInputContext); +} + +export function useComposerAgent(): ComposerAgent | null { + return useContext(ComposerAgentContext); +} diff --git a/packages/app/src/plugins/buttons/model.test.ts b/packages/app/src/plugins/buttons/model.test.ts index 8c07a8615e..7f7a1da2b4 100644 --- a/packages/app/src/plugins/buttons/model.test.ts +++ b/packages/app/src/plugins/buttons/model.test.ts @@ -23,6 +23,7 @@ function installation(): InstalledPlugin { sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], + composerItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/client-state/snapshots.test.ts b/packages/app/src/plugins/client-state/snapshots.test.ts index 0c90185e53..ab73182340 100644 --- a/packages/app/src/plugins/client-state/snapshots.test.ts +++ b/packages/app/src/plugins/client-state/snapshots.test.ts @@ -53,6 +53,7 @@ const agent: Agent = { attentionReason: "permission", parentAgentId: null, labels: { phase: "implementation" }, + metadata: { "linear.issue": { id: "ENG-1" } }, }; describe("plugin context snapshots", () => { @@ -107,6 +108,7 @@ describe("plugin context snapshots", () => { attentionReason: "permission", parentAgentId: null, labels: { phase: "implementation" }, + metadata: { "linear.issue": { id: "ENG-1" } }, }); expect(snapshot.labels).not.toBe(agent.labels); expect(Object.isFrozen(snapshot)).toBe(true); diff --git a/packages/app/src/plugins/client-state/snapshots.ts b/packages/app/src/plugins/client-state/snapshots.ts index 67a08830cd..f67d3c62d6 100644 --- a/packages/app/src/plugins/client-state/snapshots.ts +++ b/packages/app/src/plugins/client-state/snapshots.ts @@ -49,6 +49,7 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl attentionReason: agent.attentionReason ?? null, parentAgentId: agent.parentAgentId, labels: Object.freeze({ ...agent.labels }), + metadata: Object.freeze({ ...agent.metadata }), }); const byWorkspace = agentSnapshots.get(agent) ?? new Map(); byWorkspace.set(workspaceId, snapshot); diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index e70304b883..9eccfab943 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -46,6 +46,7 @@ const agent: PluginAgentSnapshot = { attentionReason: null, parentAgentId: null, labels: {}, + metadata: {}, }; const inspect = defineRpc({ @@ -82,6 +83,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], + composerItems: [], workspacePanels: [ { id: "details", diff --git a/packages/app/src/plugins/composer-items/index.tsx b/packages/app/src/plugins/composer-items/index.tsx new file mode 100644 index 0000000000..617289bfeb --- /dev/null +++ b/packages/app/src/plugins/composer-items/index.tsx @@ -0,0 +1,138 @@ +import { useMemo, useSyncExternalStore } from "react"; +import type { PluginHostProps } from "@getpaseo/plugin/client"; +import { PluginComposerProvider } from "@getpaseo/plugin/client/host"; +import { withUnistyles } from "react-native-unistyles"; +import { + ComposerControlSurfaceProvider, + type ComposerControlSurface, +} from "@/composer/agent-controls/composer-control"; +import { useComposerAgent, useComposerInput } from "@/composer/session"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useToast } from "@/contexts/toast-context"; +import { useHosts } from "@/runtime/host-runtime"; +import type { Theme } from "@/styles/theme"; +import { createPluginClientStateSource } from "../client-state/source"; +import { resolvePluginPlatform } from "../platform"; +import { PluginEnvironmentProvider, type PluginEnvironment } from "../popover"; +import { useInstalledPlugins } from "../registry"; +import { SurfaceErrorBoundary } from "../surface-error-boundary"; +import { toPluginTheme } from "../theme"; +import type { InstalledPlugin } from "../types"; +import { toPluginComposer } from "./model"; + +const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); + +function renderNothing() { + return null; +} + +function useServerPlugins(serverId: string | null): InstalledPlugin[] { + const installed = useInstalledPlugins(); + return useMemo( + () => + serverId === null + ? [] + : installed.filter( + (plugin) => plugin.serverId === serverId && plugin.composerItems.length > 0, + ), + [installed, serverId], + ); +} + +/** How many composer items the host's plugins registered; the toolbar budgets room for them. */ +export function usePluginComposerItemCount(serverId: string | null): number { + const plugins = useServerPlugins(serverId); + return plugins.reduce((count, plugin) => count + plugin.composerItems.length, 0); +} + +/** + * The host's plugin composer items. They read and command the composer they are mounted in, so + * they render only where a composer provides its input and agent. + */ +export function PluginComposerItems({ + serverId, + surface, +}: { + serverId: string | null; + surface: ComposerControlSurface; +}) { + const plugins = useServerPlugins(serverId); + if (serverId === null || plugins.length === 0) return null; + return ( + + {plugins.map((plugin) => ( + + ))} + + ); +} + +function PluginComposerItemsHost({ + plugin, + theme, +}: { + plugin: InstalledPlugin; + theme: PluginHostProps["theme"]; +}) { + const agent = useComposerAgent(); + const composerInput = useComposerInput(); + const toast = useToast(); + const compact = useIsCompactFormFactor(); + const hosts = useHosts(); + const hostLabel = + hosts.find((host) => host.serverId === plugin.serverId)?.label ?? plugin.serverId; + const textSource = composerInput?.textSource; + const prompt = useSyncExternalStore( + textSource?.subscribe ?? subscribeNothing, + textSource?.getSnapshot ?? emptyText, + textSource?.getSnapshot ?? emptyText, + ); + const environment = useMemo( + () => ({ + installation: plugin, + toast, + state: createPluginClientStateSource(plugin.serverId), + }), + [plugin, toast], + ); + const composer = useMemo( + () => + agent && composerInput ? toPluginComposer({ plugin, agent, composerInput, prompt }) : null, + [agent, composerInput, plugin, prompt], + ); + const props = useMemo( + () => ({ + theme, + host: { id: plugin.serverId, label: hostLabel }, + layout: { compact, platform: resolvePluginPlatform() }, + }), + [compact, hostLabel, plugin.serverId, theme], + ); + if (!composer) return null; + return ( + + + {plugin.composerItems.map(({ id, Component }) => ( + + + + ))} + + + ); +} + +const ThemedPluginComposerItems = withUnistyles(PluginComposerItemsHost); + +function subscribeNothing() { + return () => undefined; +} + +function emptyText() { + return ""; +} diff --git a/packages/app/src/plugins/composer-items/model.test.ts b/packages/app/src/plugins/composer-items/model.test.ts new file mode 100644 index 0000000000..301c9a7c4b --- /dev/null +++ b/packages/app/src/plugins/composer-items/model.test.ts @@ -0,0 +1,214 @@ +import { defineRpc } from "@getpaseo/plugin"; +import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; +import { QueryClient } from "@tanstack/react-query"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import { z } from "zod"; +import { describe, expect, it } from "vitest"; +import type { UserComposerAttachment } from "@/attachments/types"; +import type { ComposerAgent, ComposerInput } from "@/composer/session"; +import type { InstalledPlugin } from "../types"; +import { toPluginComposer } from "./model"; + +function installed(): InstalledPlugin { + return { + id: "review", + serverId: "host-1", + clientBundle: "bundle", + lifetime: new AbortController(), + locale: "en", + i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), + queryClient: new QueryClient(), + paseo: {} as InstalledPlugin["paseo"], + invoke: async () => undefined, + cleanup: () => {}, + settingsScreens: [], + surfaces: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], + filePanels: [], + composerItems: [], + workspacePanels: [], + commandCenterItems: [], + clientSlashCommands: [], + attachmentSources: [ + { + id: "issues", + title: "Issues", + pickerTitle: "Issues", + searchPlaceholder: "Search issues", + icon: "Bug", + search: defineRpc({ + name: "issues.search", + input: z.object({}), + output: z.array(z.object({})), + }), + }, + ], + themes: [], + timelineTransformers: [], + timelineRenderers: [], + }; +} + +const issue = { + id: "ISS-1", + identifier: "ISS-1", + title: "Fix it", + url: "https://example.com/ISS-1", + text: "Fix it", + resourceType: "issue", +}; + +function fakeAgent(context: ComposerAgent["context"]) { + const calls: string[] = []; + const agent: ComposerAgent = { + context, + provider: "claude", + model: "sonnet", + mode: "default", + thinking: null, + options: { effort: "high" }, + metadata: { flag: true }, + setProvider: async (provider) => void 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}`), + setOptions: async (patch) => void calls.push(`options:${JSON.stringify(patch)}`), + setMetadata: async (patch: Record) => + void calls.push(`metadata:${JSON.stringify(patch)}`), + }; + return { agent, calls }; +} + +function fakeInput(initial: UserComposerAttachment[] = []) { + const state = { text: "", attachments: initial }; + const input: ComposerInput = { + textSource: { getSnapshot: () => state.text, subscribe: () => () => {} }, + setText: (text) => { + state.text = text; + }, + insertText: (text) => { + state.text += text; + }, + get attachments() { + return state.attachments; + }, + changeAttachments: (updater) => { + state.attachments = updater(state.attachments); + }, + }; + return { input, state }; +} + +describe("toPluginComposer", () => { + it("reads the agent the composer configures and the context it sits in", () => { + const { agent } = fakeAgent("new-workspace"); + const { input } = fakeInput(); + const composer = toPluginComposer({ + plugin: installed(), + agent, + composerInput: input, + prompt: "hello", + }); + expect(composer.context).toBe("new-workspace"); + expect(composer.prompt).toBe("hello"); + expect(composer.agent).toEqual({ + provider: "claude", + model: "sonnet", + mode: "default", + thinking: null, + options: { effort: "high" }, + metadata: { flag: true }, + }); + }); + + it.each(["agent", "draft"] as const)( + "sends every command to the %s composer's agent", + async (context) => { + const { agent, calls } = fakeAgent(context); + const { input } = fakeInput(); + const composer = toPluginComposer({ + plugin: installed(), + agent, + 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", + 'options:{"effort":"low"}', + 'metadata:{"flag":null}', + ]); + }, + ); + + it("changes the prompt and inserts at the cursor", () => { + const { agent } = fakeAgent("draft"); + const { input, state } = fakeInput(); + const composer = toPluginComposer({ + plugin: installed(), + agent, + composerInput: input, + prompt: "", + }); + composer.setPrompt("a"); + composer.insertText("b"); + expect(state.text).toBe("ab"); + }); + + it("adds and removes the plugin's own attachments only", () => { + const { agent } = fakeAgent("agent"); + const other: UserComposerAttachment = { + kind: "plugin_resource", + pluginId: "other", + sourceId: "issues", + sourceTitle: "Issues", + sourceIcon: "Bug", + item: issue, + }; + const { input, state } = fakeInput([other]); + const composer = toPluginComposer({ + plugin: installed(), + agent, + composerInput: input, + prompt: "", + }); + + composer.addAttachment({ sourceId: "issues", item: issue }); + composer.addAttachment({ sourceId: "issues", item: issue }); + expect(state.attachments).toHaveLength(2); + + const next = toPluginComposer({ + plugin: installed(), + agent, + composerInput: input, + prompt: "", + }); + expect(next.attachments).toEqual([{ sourceId: "issues", item: issue }]); + + composer.removeAttachment({ sourceId: "issues", itemId: "ISS-1" }); + expect(state.attachments).toEqual([other]); + }); + + it("rejects an attachment from a source the plugin did not register", () => { + const { agent } = fakeAgent("agent"); + const { input } = fakeInput(); + const composer = toPluginComposer({ + plugin: installed(), + agent, + composerInput: input, + prompt: "", + }); + expect(() => composer.addAttachment({ sourceId: "nope", item: issue })).toThrow( + "Unknown attachment source: nope", + ); + }); +}); diff --git a/packages/app/src/plugins/composer-items/model.ts b/packages/app/src/plugins/composer-items/model.ts new file mode 100644 index 0000000000..8a43effebf --- /dev/null +++ b/packages/app/src/plugins/composer-items/model.ts @@ -0,0 +1,82 @@ +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import type { PluginComposer } from "@getpaseo/plugin/client"; +import type { UserComposerAttachment } from "@/attachments/types"; +import type { ComposerAgent, ComposerInput } from "@/composer/session"; +import type { InstalledPlugin } from "../types"; +import { createPluginResourceAttachment } from "../attachments/model"; + +function isPluginAttachment( + attachment: UserComposerAttachment, + pluginId: string, + sourceId: string, + itemId?: string, +): boolean { + return ( + attachment.kind === "plugin_resource" && + attachment.pluginId === pluginId && + attachment.sourceId === sourceId && + (itemId === undefined || attachment.item.id === itemId) + ); +} + +/** + * 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. + */ +export function toPluginComposer(input: { + plugin: InstalledPlugin; + agent: ComposerAgent; + composerInput: ComposerInput; + prompt: string; +}): PluginComposer { + const { plugin, agent, composerInput, prompt } = input; + return { + context: agent.context, + prompt, + attachments: composerInput.attachments.flatMap((attachment) => + attachment.kind === "plugin_resource" && attachment.pluginId === plugin.id + ? [{ sourceId: attachment.sourceId, item: attachment.item }] + : [], + ), + // Provider options are JSON on the wire. + agent: { + provider: agent.provider, + model: agent.model, + mode: agent.mode, + thinking: agent.thinking, + options: agent.options as Record, + metadata: agent.metadata, + }, + 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}`); + const attachment = createPluginResourceAttachment( + { + pluginId: plugin.id, + sourceId, + sourceTitle: plugin.i18n.resolve(source.title, plugin.locale), + sourceIcon: source.icon, + }, + item, + ); + composerInput.changeAttachments((current) => + current.some((candidate) => isPluginAttachment(candidate, plugin.id, sourceId, item.id)) + ? current + : [...current, attachment], + ); + }, + removeAttachment({ sourceId, itemId }) { + composerInput.changeAttachments((current) => + 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, + }; +} diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 1aae11f63d..3fddc96d39 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -531,6 +531,38 @@ describe("evaluatePluginClientBundle", () => { ).toThrow("Duplicate Command Center item: review"); }); + it("collects composer items and releases an id on removal", () => { + const plugin = evaluatePluginClientBundle( + "review", + bundle(` + function Item() { return null; } + const remove = plugin.addComposerItem({ id: "flag", Component: Item }); + remove(); + plugin.addComposerItem({ id: "flag", Component: Item }); + `), + ); + expect(plugin.composerItems.map((item) => item.id)).toEqual(["flag"]); + }); + + it("rejects duplicate and non-component composer items", () => { + expect(() => + evaluatePluginClientBundle( + "review", + bundle(` + function Item() { return null; } + plugin.addComposerItem({ id: "flag", Component: Item }); + plugin.addComposerItem({ id: "flag", Component: Item }); + `), + ), + ).toThrow("Duplicate composer item: flag"); + expect(() => + evaluatePluginClientBundle( + "review", + bundle(`plugin.addComposerItem({ id: "flag", Component: "nope" })`), + ), + ).toThrow("Composer item flag is not a component"); + }); + it("runs the client entry with the full runtime context", () => { const plugin = evaluatePluginClientBundle( "review", diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index ea37209f57..c2a7e82838 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -131,6 +131,7 @@ export function runPluginClientBundle( sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], + composerItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], @@ -146,6 +147,7 @@ export function runPluginClientBundle( footer: new Set(), }; const filePanelIds = new Set(); + const composerItemIds = new Set(); const workspacePanelIds = new Set(); const commandCenterItemIds = new Set(); const clientSlashCommandNames = new Set(); @@ -269,6 +271,20 @@ export function runPluginClientBundle( () => surfaceIds.delete(normalizedId), ); }, + addComposerItem(contribution) { + const normalizedId = requireId(contribution.id, "composer item id"); + if (composerItemIds.has(normalizedId)) + throw new Error(`Duplicate composer item: ${normalizedId}`); + if (!isComponentType(contribution.Component)) { + throw new Error(`Composer item ${normalizedId} is not a component`); + } + composerItemIds.add(normalizedId); + return register( + collector.composerItems, + { id: normalizedId, Component: contribution.Component }, + () => composerItemIds.delete(normalizedId), + ); + }, addSidebarHeaderItem(contribution: PluginSidebarItemContribution) { return addSidebarItem("header", contribution); }, @@ -585,6 +601,7 @@ export function runPluginClientBundle( sidebarItems: collector.sidebarItems, legacySidebarItems: collector.legacySidebarItems, filePanels: collector.filePanels, + composerItems: collector.composerItems, workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"], commandCenterItems: collector.commandCenterItems, clientSlashCommands: collector.clientSlashCommands, diff --git a/packages/app/src/plugins/installation-provider.test.tsx b/packages/app/src/plugins/installation-provider.test.tsx index 18af532044..8744ed78bd 100644 --- a/packages/app/src/plugins/installation-provider.test.tsx +++ b/packages/app/src/plugins/installation-provider.test.tsx @@ -36,6 +36,7 @@ function installation(invoked: string[]): InstalledPlugin { sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], + composerItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/react-native/composer-controls.tsx b/packages/app/src/plugins/react-native/composer-controls.tsx new file mode 100644 index 0000000000..8427d68b95 --- /dev/null +++ b/packages/app/src/plugins/react-native/composer-controls.tsx @@ -0,0 +1,85 @@ +import type { ActionProps, SelectProps, ToggleProps } from "@getpaseo/plugin/client/ui"; +import { useMemo, useState } from "react"; +import { + ComposerControl, + useComposerControlSurface, + type ComposerControlItem, +} from "@/composer/agent-controls/composer-control"; +import { parsePluginIcon, pluginIconComponent } from "../icons"; + +/** Icons resolve at render, so an invalid one throws to the item's boundary. */ +function usePluginControlIcon(icon: ToggleProps["icon"]) { + return useMemo(() => pluginIconComponent(parsePluginIcon("Composer control", icon)), [icon]); +} + +/** Draws one plugin control where its composer item sits: inline, or as a row in the sheet. */ +function PluginComposerControl({ + item, + disabled, +}: { + item: ComposerControlItem; + disabled: boolean | undefined; +}) { + const surface = useComposerControlSurface(); + const [open, setOpen] = useState(false); + return ( + + ); +} + +export function Toggle({ title, icon, disabled, value, onValueChange }: ToggleProps) { + const Icon = usePluginControlIcon(icon); + const item = useMemo( + () => ({ + id: "plugin-toggle", + type: "toggle", + title, + icon: Icon, + highlight: value ? "blue" : undefined, + value, + onChange: onValueChange, + }), + [Icon, onValueChange, title, value], + ); + return ; +} + +export function Select({ + title, + icon, + disabled, + value, + options, + onValueChange, +}: SelectProps) { + const Icon = usePluginControlIcon(icon); + const item = useMemo( + () => ({ + id: "plugin-select", + type: "select", + title, + icon: Icon, + value, + choices: options, + onChange: (next) => onValueChange(next as Value), + showCaret: true, + }), + [Icon, onValueChange, options, title, value], + ); + return ; +} + +export function Action({ title, icon, disabled, onPress }: ActionProps) { + const Icon = usePluginControlIcon(icon); + const item = useMemo( + () => ({ id: "plugin-action", type: "action", title, icon: Icon, onPress }), + [Icon, onPress, title], + ); + return ; +} diff --git a/packages/app/src/plugins/react-native/ui.ts b/packages/app/src/plugins/react-native/ui.ts index e431aa32eb..01d008574d 100644 --- a/packages/app/src/plugins/react-native/ui.ts +++ b/packages/app/src/plugins/react-native/ui.ts @@ -20,6 +20,7 @@ export { FilePanelLoading as Loading } from "@/file-panels/loading"; export { SettingsGroup } from "@/components/settings"; export { Icon } from "../icons"; +export { Action, Select, Toggle } from "./composer-controls"; export { Modal } from "./modal"; export { ScrollView, FlatList } from "./scroll-view"; export { TextInput } from "./text-input"; diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index cfe4f7212a..ce449d205b 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -106,6 +106,7 @@ export class PluginRegistry { sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], + composerItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index e204c28d9d..fc0dfd7854 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -34,6 +34,7 @@ function installed( }, legacySidebarItems, filePanels: [], + composerItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index 7e5f603ec1..dd44e940d5 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -36,6 +36,7 @@ function installation( icon: "Blocks", })), filePanels: [], + composerItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/theme.test.ts b/packages/app/src/plugins/theme.test.ts index 08ac674ca0..83e7b2dc1f 100644 --- a/packages/app/src/plugins/theme.test.ts +++ b/packages/app/src/plugins/theme.test.ts @@ -61,6 +61,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], + composerItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index ffd33ef0ff..33297a2d9a 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -24,6 +24,7 @@ function plugin(input: { sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], + composerItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 9c2a88a210..60b8aeb696 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -11,6 +11,7 @@ import type { PluginFilePanelContribution as SDKPluginFilePanelContribution, PluginCommandCenterItemContribution as SDKPluginCommandCenterItemContribution, PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution, + PluginComposerItemContribution, PluginComposerPillContribution as SDKPluginComposerPillContribution, PluginSidebarContribution as SDKPluginSidebarContribution, PluginSidebarItemContribution as SDKPluginSidebarItemContribution, @@ -53,6 +54,7 @@ export interface EvaluatedPlugin { /** `addSidebarItem` registrations, so `/plugin//sidebar/` routes keep resolving. */ legacySidebarItems: PluginSidebarContribution[]; filePanels: PluginFilePanelContribution[]; + composerItems: PluginComposerItemContribution[]; workspacePanels: EvaluatedPluginWorkspacePanelContribution[]; commandCenterItems: PluginCommandCenterItemContribution[]; clientSlashCommands: PluginClientSlashCommandContribution[]; @@ -82,6 +84,7 @@ export type { PluginAttachmentSourceContribution, PluginCommandCenterItemContribution, PluginClientSlashCommandContribution, + PluginComposerItemContribution, PluginComposerPillContribution, PluginSidebarContribution, PluginSidebarItemContribution, diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts index 1208106721..3dffea5558 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -21,6 +21,7 @@ function installed(): InstalledPlugin { sidebarItems: { header: [], footer: [] }, legacySidebarItems: [], filePanels: [], + composerItems: [], workspacePanels: [ { id: "details", diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 60b8f2756e..fd03037fa1 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -456,6 +456,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise 0 ? { metadata: composerState.metadata } : {}), initialPrompt: text, clientMessageId: `${input.draftId}:initial-message`, images: images?.length ? images : undefined, @@ -535,6 +536,7 @@ function buildComposerConfig(input: { initialServerId: serverId || null, initialValues: buildComposerInitialValues({ initialSetup }), initialProviderOptions: initialSetup?.providerOptions, + context: "new-workspace" as const, isVisible: true, lockedWorkingDir: workingDir, }; diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 29246ca2d3..50fd81dc04 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -27,6 +27,7 @@ import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; import type { AgentPermissionRequest, AgentOptionControl, + JsonValue, ProviderOptions, AgentProvider, AgentMode, @@ -101,6 +102,7 @@ export interface Agent { archivedAt?: Date | null; parentAgentId: string | null; labels: Record; + metadata?: Record; projectPlacement?: ProjectPlacementPayload | null; } diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index 9bf4ee3404..6dfae8e69c 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -82,6 +82,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload { ...(agent.lastError ? { lastError: agent.lastError } : {}), title: agent.title, labels: agent.labels, + ...(agent.metadata ? { metadata: agent.metadata } : {}), requiresAttention: agent.requiresAttention ?? false, attentionReason: agent.attentionReason ?? null, attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null, @@ -137,5 +138,6 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: archivedAt, parentAgentId, labels: snapshot.labels, + metadata: snapshot.metadata, }; } diff --git a/packages/app/test-stubs/react-native-unistyles.ts b/packages/app/test-stubs/react-native-unistyles.ts index adc8af93bf..bce7fdcba8 100644 --- a/packages/app/test-stubs/react-native-unistyles.ts +++ b/packages/app/test-stubs/react-native-unistyles.ts @@ -30,8 +30,9 @@ const testTheme = { borderAccent: "#a1a1aa", palette: { amber: { 500: "#f59e0b" }, - blue: { 300: "#93c5fd" }, - green: { 500: "#22c55e" }, + blue: { 300: "#93c5fd", 400: "#60a5fa" }, + green: { 400: "#4ade80", 500: "#22c55e" }, + yellow: { 400: "#facc15" }, red: { 300: "#fca5a5" }, white: "#ffffff", }, diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index ba4c0fc979..76d8608d95 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -236,6 +236,14 @@ export default defineConfig({ find: /^react-native\/Libraries\/Renderer\/shims\/ReactFabric$/, replacement: path.resolve(__dirname, "test-stubs/react-native-fabric-shim.ts"), }, + // The CJS entry requires the real react-native, whose Flow sources do not parse. + { + find: /^@floating-ui\/react-native$/, + replacement: path.resolve( + rootNodeModules, + "@floating-ui/react-native/dist/floating-ui.react-native.esm.js", + ), + }, // Point to the ESM build so Vite can transform its imports and apply the // react alias below (the CJS build uses require('react') which bypasses // Vite alias resolution). diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index 6d15281c6b..0d8da368bf 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -213,6 +213,43 @@ describe("plugin panel SDK boundary", () => { }); }); +describe("plugin composer SDK boundary", () => { + it("lets a composer item read and change the composer through typed commands and controls", () => { + expect( + typeErrors(` + import { useComposer, type PluginClientContext } from "@getpaseo/plugin/client"; + import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui"; + function Item() { + const composer = useComposer(); + const context: "agent" | "draft" | "new-workspace" = composer.context; + const provider: string | null = composer.agent.provider; + const flag: boolean = composer.agent.metadata.flag === true; + // @ts-expect-error reads are not writable + composer.agent.model = "other"; + composer.setProvider("codex").then(() => composer.setModel("gpt")); + composer.setOptions({ effort: null }); + composer.setMetadata({ flag: true, stale: null }); + composer.insertText("text"); + composer.addAttachment({ sourceId: "issues", item: { id: "1", identifier: "1", title: "t", url: "https://e.com", text: "t", resourceType: "issue" } }); + // @ts-expect-error commands take JSON values + composer.setMetadata({ fn: () => {} }); + return ( + <> + composer.setMetadata({ flag: next })} /> + + ); +} + +export function ReviewFlag() { + const composer = useComposer(); + const change = useCallback( + (next: boolean) => composer.setMetadata({ [REVIEW_KEY]: next ? true : null }), + [composer], + ); + return ( + + ); +} + +/** Works the same in the agent, draft and New workspace composers. */ +export function Checklist() { + const composer = useComposer(); + const insert = useCallback(() => composer.insertText("- [ ] Tests\n- [ ] Docs\n"), [composer]); + return ; +} diff --git a/plugin-examples/composer-controls/index.client.tsx b/plugin-examples/composer-controls/index.client.tsx new file mode 100644 index 0000000000..8a4cb5e66a --- /dev/null +++ b/plugin-examples/composer-controls/index.client.tsx @@ -0,0 +1,9 @@ +import type { PluginClientContext } from "@getpaseo/plugin/client"; +import { Checklist, ContextLength, ReviewFlag } from "./client/controls"; + +export default function contribute(client: PluginClientContext) { + client.addComposerItem({ id: "context-length", Component: ContextLength }); + client.addComposerItem({ id: "review-flag", Component: ReviewFlag }); + client.addComposerItem({ id: "checklist", Component: Checklist }); + return () => {}; +} diff --git a/plugin-examples/composer-controls/paseo-plugin.json b/plugin-examples/composer-controls/paseo-plugin.json new file mode 100644 index 0000000000..34107f376a --- /dev/null +++ b/plugin-examples/composer-controls/paseo-plugin.json @@ -0,0 +1,4 @@ +{ + "id": "composer-controls-example", + "requirements": { "paseo": ">=0.8.0" } +} diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index ce6b549bf9..a9c7915a73 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -14,6 +14,7 @@ A plugin is a TypeScript project installed into one Paseo daemon. It can add [file panels](/docs/plugins/reference#file-panels), [Command Center items](/docs/plugins/reference#command-center-items), [slash commands](/docs/plugins/reference#slash-commands), +[composer items](/docs/plugins/reference#composer-items), [composer pills](/docs/plugins/reference#composer-pills), [timeline items](/docs/plugins/reference#timeline-items), [themes](/docs/plugins/reference#contribute-a-theme), diff --git a/public-docs/plugins/index.zh-CN.md b/public-docs/plugins/index.zh-CN.md index 2f6c1bccff..07a79d2922 100644 --- a/public-docs/plugins/index.zh-CN.md +++ b/public-docs/plugins/index.zh-CN.md @@ -6,7 +6,7 @@ nav: 快速上手 # 插件快速上手 {#plugin-quickstart} -插件是安装到某个 Paseo daemon 中的 TypeScript 项目。它可以添加[界面和侧边栏项](/docs/plugins/reference#screens-and-sidebar-items)、[工作区面板](/docs/plugins/reference#workspace-panels)、[文件面板](/docs/plugins/reference#file-panels)、[Command Center 项](/docs/plugins/reference#command-center-items)、[斜杠命令](/docs/plugins/reference#slash-commands)、[Composer 标签](/docs/plugins/reference#composer-pills)、[时间线条目](/docs/plugins/reference#timeline-items)、[主题](/docs/plugins/reference#contribute-a-theme)、[附件来源](/docs/plugins/reference#add-a-composer-attachment-source)和 [daemon 端 RPC](/docs/plugins/reference#add-plugin-specific-backend-behavior)。它还可以[把编程 Agent 接入为 Provider](/docs/plugins/providers)。客户端贡献会在连接到该 daemon 的每个 Paseo 客户端上运行,包括移动端。 +插件是安装到某个 Paseo daemon 中的 TypeScript 项目。它可以添加[界面和侧边栏项](/docs/plugins/reference#screens-and-sidebar-items)、[工作区面板](/docs/plugins/reference#workspace-panels)、[文件面板](/docs/plugins/reference#file-panels)、[Command Center 项](/docs/plugins/reference#command-center-items)、[斜杠命令](/docs/plugins/reference#slash-commands)、[Composer 项](/docs/plugins/reference#composer-items)、[Composer 标签](/docs/plugins/reference#composer-pills)、[时间线条目](/docs/plugins/reference#timeline-items)、[主题](/docs/plugins/reference#contribute-a-theme)、[附件来源](/docs/plugins/reference#add-a-composer-attachment-source)和 [daemon 端 RPC](/docs/plugins/reference#add-plugin-specific-backend-behavior)。它还可以[把编程 Agent 接入为 Provider](/docs/plugins/providers)。客户端贡献会在连接到该 daemon 的每个 Paseo 客户端上运行,包括移动端。 浏览[已发布的插件](https://paseo.sh/plugins),然后在你的 daemon 上安装一个: diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index f9c207d5a1..287edc2023 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1894,6 +1894,79 @@ header has no room for plugin buttons, so each one is a row of the workspace men icon and title; its menus and popovers open as pages of that sheet. Placement and overflow are host decisions. +## Composer items + +`client.addComposerItem({ id, Component })` puts a component in the composer: the agent composer, +the draft composer for a new agent, and the composer on the New workspace screen. `useComposer()` +reads and changes what the composer configures, and `Toggle`, `Select` and `Action` from +`@getpaseo/plugin/client/ui` draw the controls. + +```tsx +import { useComposer, type PluginClientContext } from "@getpaseo/plugin/client"; +import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui"; + +function ReviewFlag() { + const composer = useComposer(); + return ( + composer.setMetadata({ "review.needsReview": next ? true : null })} + /> + ); +} + +function Checklist() { + const composer = useComposer(); + return ( + composer.insertText("- [ ] Tests\n- [ ] Docs\n")} + /> + ); +} + +export default function contribute(client: PluginClientContext) { + client.addComposerItem({ id: "review-flag", Component: ReviewFlag }); + client.addComposerItem({ id: "checklist", Component: Checklist }); + return () => {}; +} +``` + +`useComposer()` returns the composer the item sits in: + +| Field or command | Meaning | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `context` | `"agent"` for a running agent, `"draft"` for a new agent in a workspace, `"new-workspace"` for the New workspace screen. | +| `prompt`, `setPrompt(text)`, `insertText(text)` | The prompt. `insertText` replaces the selection or inserts at the cursor. | +| `attachments`, `addAttachment`, `removeAttachment` | Items from [your own attachment sources](#add-a-composer-attachment-source). Attachments from other plugins and Paseo's own are not listed. | +| `agent.provider`, `model`, `mode`, `thinking` | What the agent runs with, or what the draft will create. | +| `agent.options` | The provider options in effect, defaults included. | +| `agent.metadata` | The agent's [metadata](/docs/plugins/hooks#metadata). In New workspace, the first agent's. | +| `setProvider`, `setModel`, `setMode`, `setThinking` | Return promises. `setProvider` resets the model like the provider picker, and rejects on `context: "agent"`: a running agent keeps its provider. | +| `setOptions(patch)`, `setMetadata(patch)` | Top-level keys replace; `null` removes the key. Metadata set in a draft is sent when the agent is created and cleared once the prompt is sent. | + +The same call does the same thing in every composer; a plugin does not choose a composer's +implementation. `setMetadata` on a running agent needs a host that supports metadata. On an older +host the promise rejects with "Update the host to use metadata." + +`Toggle` takes `value` and `onValueChange`. `Select` takes `value`, `options` (`{ value, label, +description? }`) and `onValueChange`. `Action` takes `onPress`. All three take `title`, `icon` (a +[Lucide name, SVG or component](#icons)) and `disabled`. Paseo owns where each control sits: inline +in the toolbar when the composer has room, and a row in the composer's overflow sheet when it does +not, which is always the case on a phone. A `Select` opens its list in place of the sheet row. A +`Component` that renders something other than these controls draws it where its item is placed; in +the overflow sheet that is the sheet. + +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. + +The [composer controls example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls) +registers a `Select`, a `Toggle` and an `Action`. + ## Composer pills `client.addComposerPill({ id, workspaceId, agentId, button })` uses the same [button descriptor](#button-descriptor) diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index af811a993e..3de7af0aaf 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -1600,6 +1600,65 @@ review.remove(); 贡献移入一个共享的溢出菜单。在手机和较窄的窗口中,标题栏没有空间放置插件按钮,因此每个按钮都会成为工作区 菜单(`···`)中的一行,显示它的图标和标题;它的菜单和弹出框以该底部面板中的页面打开。位置和溢出由宿主决定。 +## Composer 项 {#composer-items} + +`client.addComposerItem({ id, Component })` 把一个组件放进 composer:Agent composer、为新 Agent 准备的草稿 composer,以及新建工作区界面上的 composer。`useComposer()` 读取并修改 composer 所配置的内容,`@getpaseo/plugin/client/ui` 中的 `Toggle`、`Select` 和 `Action` 负责绘制控件。 + +```tsx +import { useComposer, type PluginClientContext } from "@getpaseo/plugin/client"; +import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui"; + +function ReviewFlag() { + const composer = useComposer(); + return ( + composer.setMetadata({ "review.needsReview": next ? true : null })} + /> + ); +} + +function Checklist() { + const composer = useComposer(); + return ( + composer.insertText("- [ ] Tests\n- [ ] Docs\n")} + /> + ); +} + +export default function contribute(client: PluginClientContext) { + client.addComposerItem({ id: "review-flag", Component: ReviewFlag }); + client.addComposerItem({ id: "checklist", Component: Checklist }); + return () => {}; +} +``` + +`useComposer()` 返回该项所在的 composer: + +| 字段或命令 | 含义 | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `context` | `"agent"` 表示运行中的 Agent,`"draft"` 表示工作区中的新 Agent,`"new-workspace"` 表示新建工作区界面。 | +| `prompt`、`setPrompt(text)`、`insertText(text)` | 提示词。`insertText` 会替换选区,或在光标处插入。 | +| `attachments`、`addAttachment`、`removeAttachment` | 来自[你自己的附件来源](#add-a-composer-attachment-source)的项目。其他插件和 Paseo 自身的附件不会列出。 | +| `agent.provider`、`model`、`mode`、`thinking` | Agent 当前使用的配置,或草稿将要创建的配置。 | +| `agent.options` | 当前生效的 Provider 选项,包含默认值。 | +| `agent.metadata` | Agent 的[元数据](/docs/plugins/hooks#metadata)。在新建工作区中是第一个 Agent 的元数据。 | +| `setProvider`、`setModel`、`setMode`、`setThinking` | 返回 Promise。`setProvider` 会像 Provider 选择器一样重置模型,在 `context: "agent"` 下会被拒绝:运行中的 Agent 保持其 Provider。 | +| `setOptions(patch)`、`setMetadata(patch)` | 顶层键替换,`null` 删除该键。草稿中设置的元数据在创建 Agent 时发送,发送提示词后清空。 | + +同一个调用在每个 composer 中行为一致,插件不需要选择 composer 的实现。在运行中的 Agent 上调用 `setMetadata` 需要支持元数据的主机;主机过旧时,Promise 会以 “Update the host to use metadata.” 拒绝。 + +`Toggle` 接收 `value` 和 `onValueChange`。`Select` 接收 `value`、`options`(`{ value, label, description? }`)和 `onValueChange`。`Action` 接收 `onPress`。三者都接收 `title`、`icon`([Lucide 名称、SVG 或组件](#icons))和 `disabled`。控件的位置由 Paseo 决定:composer 空间足够时显示在工具栏中,不够时(手机上始终如此)成为 composer 溢出面板中的一行。`Select` 会就地打开列表,而不是替换面板中的行。`Component` 如果渲染这些控件以外的内容,会在其所在位置绘制;在溢出面板中,该位置就是面板。 + +在 Paseo 知道渲染结果之前,composer 项就已计入可容纳的数量。不渲染任何内容的项仍可能在窄 composer 上留下空的溢出面板,所以请始终渲染一个控件。 + +[Composer 控件示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)注册了一个 `Select`、一个 `Toggle` 和一个 `Action`。 + ## Composer 胶囊 {#composer-pills} `client.addComposerPill({ id, workspaceId, agentId, button })` 使用同样的[按钮描述符](#button-descriptor),