mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 02:46:29 +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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user