mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
feat(plugins): composer items and controls in all three composers
A plugin registers a component with addComposerItem and reads and changes the composer through useComposer(): provider, model, mode, thinking, options, prompt, attachments and agent metadata. The agent, draft and New workspace composers provide the same ComposerInput and ComposerAgent halves, so a command reaches the right model without the plugin knowing which composer it sits in. Toggle, Select and Action are drawn by one ComposerControl renderer that the thinking select and provider option controls now use too. Controls sit in the toolbar when there is room and become rows in the existing overflow sheet otherwise, so on a phone they appear in the overflow. Plugin items count in the density presence so the composer condenses to make room. Model and mode stay specialized but share the trigger, density and overflow. Draft metadata is kept locally, sent at creation and reset when the prompt is sent. PluginAgentSnapshot.metadata, deferred from the metadata phase, lands here. The unused provider badge is removed. vitest resolves @floating-ui/react-native to its ESM build: the CJS entry requires the real react-native, whose Flow sources do not parse.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<ComposerControlSurface>("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<View>(null);
|
||||
const isSheet = surface === "sheet";
|
||||
const sheetHeader = useMemo<SheetHeader>(() => ({ 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 = (
|
||||
<AgentControlTrigger
|
||||
ref={anchorRef}
|
||||
icon={item.icon}
|
||||
iconColor={item.highlight ? styles[item.highlight].color : undefined}
|
||||
surface={surface}
|
||||
label={item.title}
|
||||
value={view.value}
|
||||
showToolbarLabel={!view.iconOnly}
|
||||
showCaret={select?.showCaret === true && !isSheet}
|
||||
open={view.opensList && open}
|
||||
disabled={disabled}
|
||||
onPress={view.opensList ? handleOpenPress : handlePress}
|
||||
accessibilityLabel={item.accessibilityLabel ?? view.tooltip}
|
||||
testID={item.testID}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSheet ? (
|
||||
trigger
|
||||
) : (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
{trigger}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{view.tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{view.opensList ? (
|
||||
<Combobox
|
||||
options={view.options}
|
||||
value={view.selectedId}
|
||||
onSelect={handleSelect}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
anchorRef={anchorRef}
|
||||
presentation={isSheet ? "push" : undefined}
|
||||
desktopPlacement={isSheet ? undefined : "top-start"}
|
||||
desktopMinWidth={select?.desktopMinWidth}
|
||||
searchable={select ? select.searchable : false}
|
||||
renderOption={select?.renderOption}
|
||||
header={isSheet ? sheetHeader : undefined}
|
||||
/>
|
||||
) : 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) => (
|
||||
<ComposerControlSlot
|
||||
key={item.id}
|
||||
item={item}
|
||||
open={openId === item.id}
|
||||
onOpenIdChange={onOpenIdChange}
|
||||
{...rest}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposerControlSlot({
|
||||
item,
|
||||
open,
|
||||
onOpenIdChange,
|
||||
...rest
|
||||
}: Omit<ComposerControlsProps, "items" | "openId"> & { item: ComposerControlItem; open: boolean }) {
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => onOpenIdChange(next ? item.id : null),
|
||||
[item.id, onOpenIdChange],
|
||||
);
|
||||
return <ComposerControl item={item} open={open} onOpenChange={handleOpenChange} {...rest} />;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -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) => <ThinkingOption {...args} />,
|
||||
};
|
||||
}
|
||||
|
||||
function ThinkingOption({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const leadingSlot = useMemo(
|
||||
() => <ThinkingIcon size={16} color={styles.thinkingIcon.color} />,
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
leadingSlot={leadingSlot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
thinkingIcon: { color: theme.colors.foreground },
|
||||
}));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ describe("resolveAgentControlsMode", () => {
|
||||
selectedThinkingOptionId: "",
|
||||
onSelectThinkingOption: () => undefined,
|
||||
onApplyAgentProfile: () => undefined,
|
||||
metadata: {},
|
||||
onSetMetadata: () => undefined,
|
||||
}),
|
||||
).toBe("draft");
|
||||
});
|
||||
|
||||
@@ -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<DraftAgentControlsProps["context"]>;
|
||||
metadata: Record<string, JsonValue>;
|
||||
onSetMetadata: (patch: Record<string, JsonValue>) => 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
/** 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<string, unknown> | undefined;
|
||||
/** What plugins set on the agent this draft creates. */
|
||||
metadata: Record<string, JsonValue>;
|
||||
agentControls: DraftAgentControlsProps;
|
||||
commandDraft: DraftCommandTarget;
|
||||
};
|
||||
@@ -191,10 +197,18 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
[saveDraft],
|
||||
);
|
||||
|
||||
const [metadata, setMetadataState] = useState<Record<string, JsonValue>>({});
|
||||
const setMetadata = useCallback(
|
||||
(patch: Record<string, JsonValue>) =>
|
||||
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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, JsonValue>>,
|
||||
patch: Readonly<Record<string, JsonValue>>,
|
||||
): Record<string, JsonValue> {
|
||||
const next = { ...current };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) delete next[key];
|
||||
else next[key] = value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -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<string, unknown> | undefined;
|
||||
metadata: Record<string, JsonValue>;
|
||||
};
|
||||
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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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<ComposerInput>(
|
||||
() => ({
|
||||
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 ? (
|
||||
<ComposerInputProvider value={composerInput}>{controls}</ComposerInputProvider>
|
||||
) : null;
|
||||
}, [
|
||||
agentControls,
|
||||
agentId,
|
||||
composerInput,
|
||||
focusInput,
|
||||
isCompactLayout,
|
||||
mode.showAgentControls,
|
||||
serverId,
|
||||
]);
|
||||
|
||||
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||
attachButtonRef.current = node;
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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<string, JsonValue>;
|
||||
setProvider: (provider: string) => Promise<void>;
|
||||
setModel: (modelId: string) => Promise<void>;
|
||||
setMode: (modeId: string) => Promise<void>;
|
||||
setThinking: (thinkingOptionId: string) => Promise<void>;
|
||||
/** Top-level keys replace; `null` removes the agent's own value. */
|
||||
setOptions: (patch: ProviderOptions) => Promise<void>;
|
||||
/** Top-level keys replace; `null` removes the key. */
|
||||
setMetadata: (patch: Record<string, JsonValue>) => Promise<void>;
|
||||
}
|
||||
|
||||
const ComposerInputContext = createContext<ComposerInput | null>(null);
|
||||
const ComposerAgentContext = createContext<ComposerAgent | null>(null);
|
||||
|
||||
export function ComposerInputProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: ComposerInput;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <ComposerInputContext.Provider value={value}>{children}</ComposerInputContext.Provider>;
|
||||
}
|
||||
|
||||
export function ComposerAgentProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: ComposerAgent | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <ComposerAgentContext.Provider value={value}>{children}</ComposerAgentContext.Provider>;
|
||||
}
|
||||
|
||||
export function useComposerInput(): ComposerInput | null {
|
||||
return useContext(ComposerInputContext);
|
||||
}
|
||||
|
||||
export function useComposerAgent(): ComposerAgent | null {
|
||||
return useContext(ComposerAgentContext);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ function installation(): InstalledPlugin {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, PluginAgentSnapshot>();
|
||||
byWorkspace.set(workspaceId, snapshot);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<ComposerControlSurfaceProvider value={surface}>
|
||||
{plugins.map((plugin) => (
|
||||
<ThemedPluginComposerItems key={plugin.id} plugin={plugin} uniProps={pluginThemeMapping} />
|
||||
))}
|
||||
</ComposerControlSurfaceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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<PluginEnvironment>(
|
||||
() => ({
|
||||
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<PluginHostProps>(
|
||||
() => ({
|
||||
theme,
|
||||
host: { id: plugin.serverId, label: hostLabel },
|
||||
layout: { compact, platform: resolvePluginPlatform() },
|
||||
}),
|
||||
[compact, hostLabel, plugin.serverId, theme],
|
||||
);
|
||||
if (!composer) return null;
|
||||
return (
|
||||
<PluginEnvironmentProvider environment={environment}>
|
||||
<PluginComposerProvider composer={composer}>
|
||||
{plugin.composerItems.map(({ id, Component }) => (
|
||||
<SurfaceErrorBoundary
|
||||
key={id}
|
||||
installation={plugin}
|
||||
Surface={Component}
|
||||
renderError={renderNothing}
|
||||
>
|
||||
<Component {...props} />
|
||||
</SurfaceErrorBoundary>
|
||||
))}
|
||||
</PluginComposerProvider>
|
||||
</PluginEnvironmentProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedPluginComposerItems = withUnistyles(PluginComposerItemsHost);
|
||||
|
||||
function subscribeNothing() {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
function emptyText() {
|
||||
return "";
|
||||
}
|
||||
@@ -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<string, JsonValue>) =>
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, JsonValue>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string>();
|
||||
const composerItemIds = new Set<string>();
|
||||
const workspacePanelIds = new Set<string>();
|
||||
const commandCenterItemIds = new Set<string>();
|
||||
const clientSlashCommandNames = new Set<string>();
|
||||
@@ -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,
|
||||
|
||||
@@ -36,6 +36,7 @@ function installation(invoked: string[]): InstalledPlugin {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -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 (
|
||||
<ComposerControl
|
||||
item={item}
|
||||
surface={surface}
|
||||
disabled={disabled}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toggle({ title, icon, disabled, value, onValueChange }: ToggleProps) {
|
||||
const Icon = usePluginControlIcon(icon);
|
||||
const item = useMemo<ComposerControlItem>(
|
||||
() => ({
|
||||
id: "plugin-toggle",
|
||||
type: "toggle",
|
||||
title,
|
||||
icon: Icon,
|
||||
highlight: value ? "blue" : undefined,
|
||||
value,
|
||||
onChange: onValueChange,
|
||||
}),
|
||||
[Icon, onValueChange, title, value],
|
||||
);
|
||||
return <PluginComposerControl item={item} disabled={disabled} />;
|
||||
}
|
||||
|
||||
export function Select<Value extends string>({
|
||||
title,
|
||||
icon,
|
||||
disabled,
|
||||
value,
|
||||
options,
|
||||
onValueChange,
|
||||
}: SelectProps<Value>) {
|
||||
const Icon = usePluginControlIcon(icon);
|
||||
const item = useMemo<ComposerControlItem>(
|
||||
() => ({
|
||||
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 <PluginComposerControl item={item} disabled={disabled} />;
|
||||
}
|
||||
|
||||
export function Action({ title, icon, disabled, onPress }: ActionProps) {
|
||||
const Icon = usePluginControlIcon(icon);
|
||||
const item = useMemo<ComposerControlItem>(
|
||||
() => ({ id: "plugin-action", type: "action", title, icon: Icon, onPress }),
|
||||
[Icon, onPress, title],
|
||||
);
|
||||
return <PluginComposerControl item={item} disabled={disabled} />;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -106,6 +106,7 @@ export class PluginRegistry {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -34,6 +34,7 @@ function installed(
|
||||
},
|
||||
legacySidebarItems,
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -36,6 +36,7 @@ function installation(
|
||||
icon: "Blocks",
|
||||
})),
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -61,6 +61,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -24,6 +24,7 @@ function plugin(input: {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
|
||||
@@ -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/<id>/sidebar/<item>` 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,
|
||||
|
||||
@@ -21,6 +21,7 @@ function installed(): InstalledPlugin {
|
||||
sidebarItems: { header: [], footer: [] },
|
||||
legacySidebarItems: [],
|
||||
filePanels: [],
|
||||
composerItems: [],
|
||||
workspacePanels: [
|
||||
{
|
||||
id: "details",
|
||||
|
||||
@@ -456,6 +456,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
|
||||
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
|
||||
providerOptions: composerState.providerOptions,
|
||||
},
|
||||
...(Object.keys(composerState.metadata).length > 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,
|
||||
};
|
||||
|
||||
@@ -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<string, string>;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
projectPlacement?: ProjectPlacementPayload | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Toggle title="Flag" icon="Flag" value={flag} onValueChange={(next: boolean) => composer.setMetadata({ flag: next })} />
|
||||
<Select title="Size" icon="Gauge" value="a" options={[{ value: "a", label: "A" }, { value: "b", label: "B" }]} onValueChange={(next: "a" | "b") => {}} />
|
||||
<Action title="Insert" icon="Plus" onPress={() => composer.setPrompt("")} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default function contribute(client: PluginClientContext) {
|
||||
client.addComposerItem({ id: "item", Component: Item });
|
||||
return () => {};
|
||||
}
|
||||
`),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin agent tools SDK boundary", () => {
|
||||
it("lets a session hook attach typed tools without exposing their transport", () => {
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createContext, useContext, type ComponentType, type ReactNode } from "react";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { PluginAttachmentItem } from "../attachments.js";
|
||||
import type { PluginHostProps } from "./contracts.js";
|
||||
|
||||
/** One item the user attached from a source this plugin registered with `addAttachmentSource`. */
|
||||
export interface PluginComposerAttachment {
|
||||
readonly sourceId: string;
|
||||
readonly item: PluginAttachmentItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer the item sits in: the prompt, the attachments and the agent being configured.
|
||||
* The commands behave the same in every composer. `context` says where the composer is; it does
|
||||
* not change what a command does, except that a running agent keeps its provider.
|
||||
*/
|
||||
export interface PluginComposer {
|
||||
/** `"agent"`: a running agent. `"draft"`: a new agent in a workspace. `"new-workspace"`: the New workspace screen. */
|
||||
readonly context: "agent" | "draft" | "new-workspace";
|
||||
readonly prompt: string;
|
||||
/** This plugin's attachments only. */
|
||||
readonly attachments: readonly PluginComposerAttachment[];
|
||||
readonly agent: {
|
||||
readonly provider: string | null;
|
||||
readonly model: string | null;
|
||||
readonly mode: string | null;
|
||||
readonly thinking: string | null;
|
||||
/** The options the agent runs with, including provider defaults. */
|
||||
readonly options: Readonly<Record<string, JsonValue>>;
|
||||
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||
};
|
||||
setPrompt(text: string): void;
|
||||
/** Inserts at the cursor, replacing the selection. */
|
||||
insertText(text: string): void;
|
||||
addAttachment(input: { sourceId: string; item: PluginAttachmentItem }): void;
|
||||
removeAttachment(input: { sourceId: string; itemId: string }): void;
|
||||
/** Resets the model like the built-in control. Rejects on a running agent, which keeps its provider. */
|
||||
setProvider(provider: string): Promise<void>;
|
||||
setModel(modelId: string): Promise<void>;
|
||||
setMode(modeId: string): Promise<void>;
|
||||
setThinking(thinkingOptionId: string): Promise<void>;
|
||||
/** Top-level keys replace; `null` removes the agent's own value. */
|
||||
setOptions(patch: Record<string, JsonValue>): Promise<void>;
|
||||
/** Top-level keys replace; `null` removes the key. */
|
||||
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginComposerItemProps extends PluginHostProps {}
|
||||
|
||||
export interface PluginComposerItemContribution {
|
||||
id: string;
|
||||
Component: ComponentType<PluginComposerItemProps>;
|
||||
}
|
||||
|
||||
const PluginComposerContext = createContext<PluginComposer | null>(null);
|
||||
|
||||
export function PluginComposerProvider({
|
||||
composer,
|
||||
children,
|
||||
}: {
|
||||
composer: PluginComposer;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PluginComposerContext.Provider value={composer}>{children}</PluginComposerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePluginComposerValue(): PluginComposer | null {
|
||||
return useContext(PluginComposerContext);
|
||||
}
|
||||
|
||||
/** The composer this item sits in. Call it from a component registered with `addComposerItem`. */
|
||||
export function useComposer(): PluginComposer {
|
||||
const composer = usePluginComposerValue();
|
||||
if (!composer) throw new Error("useComposer must run inside a composer item");
|
||||
return composer;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
||||
import type { PluginFile } from "./file.js";
|
||||
import type { PluginComposerItemContribution } from "./composer.js";
|
||||
import type { PluginRpcContract } from "../rpc.js";
|
||||
import type {
|
||||
PluginButtonRegistration,
|
||||
@@ -194,6 +195,7 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl
|
||||
contribution: PluginComposerPillContribution<PluginText>,
|
||||
): PluginButtonRegistration<PluginText>;
|
||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution<PluginText>): PluginCleanup;
|
||||
addComposerItem(contribution: PluginComposerItemContribution): PluginCleanup;
|
||||
addTheme(contribution: PluginThemeContribution<PluginText>): PluginCleanup;
|
||||
addFilePanel(contribution: PluginFilePanelContribution<PluginText>): PluginCleanup;
|
||||
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginText } from "../text.js";
|
||||
import { PluginAttachmentSearchPayloadSchema } from "../attachments.js";
|
||||
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
|
||||
export { PluginComposerProvider } from "./composer.js";
|
||||
export {
|
||||
usePluginRuntimeContextBridge,
|
||||
type PluginRuntimeContextBridge,
|
||||
|
||||
@@ -71,6 +71,13 @@ export {
|
||||
} from "./file.js";
|
||||
export { usePaseo } from "./paseo-context.js";
|
||||
export { useAgent, useWorkspace } from "./client-state.js";
|
||||
export { useComposer } from "./composer.js";
|
||||
export type {
|
||||
PluginComposer,
|
||||
PluginComposerAttachment,
|
||||
PluginComposerItemContribution,
|
||||
PluginComposerItemProps,
|
||||
} from "./composer.js";
|
||||
export { useRpc } from "./rpc-context.js";
|
||||
import type { SettingsDefinition } from "../settings.js";
|
||||
import type { SettingsState } from "./contracts.js";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PluginTranslationProvider, usePluginTranslationRuntime } from "./translations.js";
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { PluginComposerProvider, usePluginComposerValue } from "./composer.js";
|
||||
import { PluginClientStateProvider, usePluginClientStateSource } from "./client-state.js";
|
||||
import { PaseoApiProvider, usePaseoContextValue } from "./paseo-context.js";
|
||||
import { PluginRpcProvider, usePluginRpcContextValue } from "./rpc-context.js";
|
||||
@@ -12,6 +13,7 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
||||
const paseo = usePaseoContextValue();
|
||||
const rpc = usePluginRpcContextValue();
|
||||
const state = usePluginClientStateSource();
|
||||
const composer = usePluginComposerValue();
|
||||
|
||||
if (!paseo || !rpc) {
|
||||
throw new Error("Plugin UI must run inside a contributed plugin surface");
|
||||
@@ -19,11 +21,16 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
||||
|
||||
return useCallback(
|
||||
(children: ReactNode) => {
|
||||
const content = state ? (
|
||||
const withState = state ? (
|
||||
<PluginClientStateProvider source={state}>{children}</PluginClientStateProvider>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
const content = composer ? (
|
||||
<PluginComposerProvider composer={composer}>{withState}</PluginComposerProvider>
|
||||
) : (
|
||||
withState
|
||||
);
|
||||
return (
|
||||
<PaseoApiProvider paseo={paseo}>
|
||||
<PluginRpcProvider invoke={rpc.invoke}>
|
||||
@@ -32,6 +39,6 @@ export function usePluginRuntimeContextBridge(): PluginRuntimeContextBridge {
|
||||
</PaseoApiProvider>
|
||||
);
|
||||
},
|
||||
[paseo, rpc, state, translations],
|
||||
[composer, paseo, rpc, state, translations],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,3 +185,30 @@ export interface MarkdownProps {
|
||||
|
||||
/** Renders Markdown using Paseo’s theme, selection, and link behavior. */
|
||||
export declare const Markdown: ComponentType<MarkdownProps>;
|
||||
|
||||
export interface ComposerControlProps {
|
||||
/** The control's label and tooltip. */
|
||||
title: string;
|
||||
/** A Lucide icon name, inline SVG, or a component. */
|
||||
icon: PluginIcon;
|
||||
disabled?: boolean;
|
||||
}
|
||||
export interface ToggleProps extends ComposerControlProps {
|
||||
value: boolean;
|
||||
onValueChange(value: boolean): void;
|
||||
}
|
||||
export interface SelectProps<Value extends string = string> extends ComposerControlProps {
|
||||
value: Value;
|
||||
options: readonly { value: Value; label: string; description?: string }[];
|
||||
onValueChange(value: Value): void;
|
||||
}
|
||||
export interface ActionProps extends ComposerControlProps {
|
||||
onPress(): void;
|
||||
}
|
||||
/**
|
||||
* Composer controls. Render them from a composer item's `Component`. Paseo draws them inline when
|
||||
* the composer has room and as rows in its overflow sheet when it does not.
|
||||
*/
|
||||
export declare const Toggle: ComponentType<ToggleProps>;
|
||||
export declare function Select<Value extends string>(props: SelectProps<Value>): ReactNode;
|
||||
export declare const Action: ComponentType<ActionProps>;
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface PluginAgentSnapshot {
|
||||
readonly attentionReason: "finished" | "error" | "permission" | null;
|
||||
readonly parentAgentId: string | null;
|
||||
readonly labels: Readonly<Record<string, string>>;
|
||||
/** Structured data plugins store on the agent. */
|
||||
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
export interface PluginThemeColors {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Composer controls
|
||||
|
||||
Install this directory as a plugin and open the agent, draft, or New workspace composer. Three
|
||||
controls appear beside the model and thinking controls.
|
||||
|
||||
| Control | Component | What it does |
|
||||
| -------------------- | --------- | ----------------------------------------------------------------------- |
|
||||
| **Context length** | `Select` | Saves `composer-controls.contextLength` in the agent's metadata. |
|
||||
| **Needs review** | `Toggle` | Sets or clears `composer-controls.needsReview` in the agent's metadata. |
|
||||
| **Insert checklist** | `Action` | Inserts a task list at the cursor through `composer.insertText`. |
|
||||
|
||||
Each component calls `useComposer()` and uses the same commands in every composer. Paseo draws the
|
||||
controls inline when the composer has room and as rows in the composer's overflow sheet when it
|
||||
does not, including on a phone.
|
||||
|
||||
[client/controls.tsx](client/controls.tsx) holds the three components; [index.client.tsx](index.client.tsx)
|
||||
registers them with `addComposerItem`.
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from "react";
|
||||
import { useComposer } from "@getpaseo/plugin/client";
|
||||
import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui";
|
||||
|
||||
type ContextLength = "short" | "long";
|
||||
|
||||
const CONTEXT_OPTIONS = [
|
||||
{ value: "short", label: "Short", description: "Answer from the files you name." },
|
||||
{ value: "long", label: "Long", description: "Read the surrounding code first." },
|
||||
] as const;
|
||||
|
||||
const CONTEXT_KEY = "composer-controls.contextLength";
|
||||
const REVIEW_KEY = "composer-controls.needsReview";
|
||||
|
||||
/** Reads and writes agent metadata, so the choice is saved with the agent. */
|
||||
export function ContextLength() {
|
||||
const composer = useComposer();
|
||||
const value: ContextLength = composer.agent.metadata[CONTEXT_KEY] === "long" ? "long" : "short";
|
||||
const change = useCallback(
|
||||
(next: ContextLength) => composer.setMetadata({ [CONTEXT_KEY]: next }),
|
||||
[composer],
|
||||
);
|
||||
return (
|
||||
<Select
|
||||
title="Context length"
|
||||
icon="Gauge"
|
||||
value={value}
|
||||
options={CONTEXT_OPTIONS}
|
||||
onValueChange={change}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReviewFlag() {
|
||||
const composer = useComposer();
|
||||
const change = useCallback(
|
||||
(next: boolean) => composer.setMetadata({ [REVIEW_KEY]: next ? true : null }),
|
||||
[composer],
|
||||
);
|
||||
return (
|
||||
<Toggle
|
||||
title="Needs review"
|
||||
icon="Flag"
|
||||
value={composer.agent.metadata[REVIEW_KEY] === true}
|
||||
onValueChange={change}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 <Action title="Insert checklist" icon="ListChecks" onPress={insert} />;
|
||||
}
|
||||
@@ -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 () => {};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"id": "composer-controls-example",
|
||||
"requirements": { "paseo": ">=0.8.0" }
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 上安装一个:
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Toggle
|
||||
title="Needs review"
|
||||
icon="Flag"
|
||||
value={composer.agent.metadata["review.needsReview"] === true}
|
||||
onValueChange={(next) => composer.setMetadata({ "review.needsReview": next ? true : null })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Checklist() {
|
||||
const composer = useComposer();
|
||||
return (
|
||||
<Action
|
||||
title="Insert checklist"
|
||||
icon="ListChecks"
|
||||
onPress={() => 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)
|
||||
|
||||
@@ -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 (
|
||||
<Toggle
|
||||
title="Needs review"
|
||||
icon="Flag"
|
||||
value={composer.agent.metadata["review.needsReview"] === true}
|
||||
onValueChange={(next) => composer.setMetadata({ "review.needsReview": next ? true : null })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Checklist() {
|
||||
const composer = useComposer();
|
||||
return (
|
||||
<Action
|
||||
title="Insert checklist"
|
||||
icon="ListChecks"
|
||||
onPress={() => 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),
|
||||
|
||||
Reference in New Issue
Block a user