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