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:
Mohamed Boudra
2026-11-02 05:36:04 +01:00
parent 77f90d3460
commit abd4d2c1ea
57 changed files with 2029 additions and 783 deletions
@@ -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,
};
}
+32
View File
@@ -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",
+17
View File
@@ -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";
+1
View File
@@ -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: [],
+1
View File
@@ -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: [],
+3
View File
@@ -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",