Let plugins contribute contextual composer pills (#3956)

* feat(plugin): allow composer pill contributions

Keep pill presentation host-owned while plugin components render contextual content. Pill callbacks reuse the selected host's API, RPC, surface, and registered-panel navigation capabilities without exposing router or workspace layout internals.

* fix(plugin): place composer pills in track bar

* fix(plugin): make composer pills client-owned
This commit is contained in:
Mohamed Boudra
2026-08-28 16:02:00 +08:00
committed by GitHub
parent a501e1e051
commit fe803f6e70
36 changed files with 1096 additions and 101 deletions
+44 -1
View File
@@ -1,7 +1,7 @@
# Plugins
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
composer pills, app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
Paseo does not sandbox it.
@@ -202,6 +202,49 @@ They use typed plugin RPC only for plugin-specific backend work. Navigation is l
plugin's registered global surfaces and workspace panels; plugins do not receive Expo Router or
workspace-layout store access.
## Contribute composer pills
Register a headless client entrypoint, then add and remove targeted pills from that client
lifecycle. `addClientSide` runs once per plugin installation in each connected app and never runs
in the daemon subprocess. It can subscribe to the client API, call plugin RPCs, and own arbitrary
client state without mounting a panel or surface.
```tsx
export function contributeClient(client: PluginClientContext) {
const pills = new Map<string, () => void>();
const unsubscribe = client.paseo.agents.subscribe((update) => {
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
const { id: agentId, workspaceId } = update.agent;
pills.get(agentId)?.();
pills.set(
agentId,
client.addComposerPill({
id: "review",
title: "Open review",
workspaceId,
agentId,
Component: ReviewPill,
async onPress() {
await client.rpc(refreshReview, { agentId });
client.openPanel("review", { workspaceId, agentId });
},
}),
);
});
return () => {
unsubscribe();
for (const remove of pills.values()) remove();
};
}
```
Wire it from `index.ts` with `plugin.addClientSide(contributeClient)`. `addComposerPill` exists only
on `PluginClientContext`; it returns an idempotent removal function. A pill appears only in the
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
the host, or unloading the app tears down the contribution.
## Contribute timeline items
Timeline transformers and renderers are client contributions. The daemon's canonical rows and
@@ -5,6 +5,7 @@ import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import { submitMessage } from "../support/helpers/composer";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { buildAgentRoute } from "../support/helpers/mock-agent";
@@ -31,7 +32,15 @@ function isSettledWorkspaceUrl(url: URL): boolean {
function pluginSource(): string {
return `import React, { useRef } from "react";
import { Text, View } from "react-native";
import { useAgent, useWorkspace } from "@getpaseo/plugin";
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
import { defineRpc } from "@getpaseo/plugin/server";
import { z } from "zod";
const recordComposerOpen = defineRpc({
name: "composer.open",
input: z.object({ workspaceId: z.string() }),
output: z.object({ opened: z.boolean() }),
});
function WorkspacePanel({ workspaceId, host, layout }) {
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
@@ -54,7 +63,56 @@ function SidebarCollisionSurface() {
return <View><Text>Sidebar collision surface</Text></View>;
}
function ComposerPill({ theme, workspaceId, agentId }) {
const workspace = useWorkspace(workspaceId, (value) => ({ title: value.title }));
const agent = useAgent(agentId, (value) => ({ title: value.title }));
return <><Icon name="Scan" size={14} color={theme.colors.foregroundMuted} /><Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, flexShrink: 1 }}>Review {workspace?.title}:{agent?.title}</Text></>;
}
function contributeClient(client) {
const pills = new Map();
const remove = (agentId) => {
pills.get(agentId)?.();
pills.delete(agentId);
};
const unsubscribe = client.paseo.agents.subscribe((update) => {
if (update.kind === "remove") {
remove(update.agentId);
return;
}
const agent = update.agent;
if (agent.title !== "Plugin panel context agent" || !agent.workspaceId) return;
remove(agent.id);
let removePill = () => {};
removePill = client.addComposerPill({
id: "review",
title: "Open composer review",
workspaceId: agent.workspaceId,
agentId: agent.id,
Component: ComposerPill,
async onPress() {
await client.rpc(recordComposerOpen, { workspaceId: agent.workspaceId });
removePill();
client.openPanel("agent", {
workspaceId: agent.workspaceId,
agentId: agent.id,
});
},
});
pills.set(agent.id, removePill);
});
return () => {
unsubscribe();
for (const removePill of pills.values()) removePill();
pills.clear();
};
}
export default function contribute(plugin) {
plugin.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
await paseo.workspaces.ref(workspaceId).setTitle("Opened from composer pill");
return { opened: true };
});
plugin.addSurface("collision", DirectCollisionSurface);
plugin.addSurface("sidebar-destination", SidebarCollisionSurface);
plugin.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
@@ -64,6 +122,7 @@ export default function contribute(plugin) {
plugin.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
plugin.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
plugin.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
plugin.addClientSide(contributeClient);
return () => {};
}`;
}
@@ -207,7 +266,29 @@ test.describe("plugin workspace panels and Command Center", () => {
});
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
await submitMessage(page, "emit 1 agent stream updates");
await expect(page.getByRole("button", { name: "1/1 tasks" })).toBeVisible({
timeout: 30_000,
});
const composerPill = page.getByRole("button", { name: "Open composer review" });
await expect(composerPill).toContainText(
"Review Unrelated title update:Plugin panel context agent",
);
await capture(page, testInfo, "plugin-composer-pill-wide");
await page.setViewportSize(COMPACT_VIEWPORT);
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
await capture(page, testInfo, "plugin-composer-pill-compact");
await page.getByRole("button", { name: "Open composer review" }).click();
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Opened from composer pill",
);
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
await capture(page, testInfo, "plugin-agent-panel-compact");
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0);
await openCompactSidebar(page);
await runCommand(page, "Open plugin agent");
await closeMobileAgentSidebar(page);
@@ -218,7 +299,6 @@ test.describe("plugin workspace panels and Command Center", () => {
).toBeVisible();
await expect(page.getByText(`Host ${getServerId()}`, { exact: true })).toBeVisible();
await expect(page.getByText("Layout compact", { exact: true })).toBeVisible();
await capture(page, testInfo, "plugin-agent-panel-compact");
});
await test.step("removing the active plugin renders the panel unavailable", async () => {
+5 -5
View File
@@ -19,12 +19,12 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles";
/**
* The strip of pills where a pane's ambient trackers live — subagents and tasks today.
* The strip of pills where a pane's ambient trackers and plugin actions live.
*
* Everything in it is a pill: a count you can read without opening anything, and a panel behind
* it for the detail. Trackers used to be stacked cards, so every one of them pushed the composer
* further down the pane. The bar has at most one subagent pill and one task pill, so it remains
* one line and has deterministic geometry on every platform.
* Trackers expose a count and a detail panel; plugin actions expose their own icon and text.
* Trackers used to be stacked cards, so every one of them pushed the composer further down the
* pane. Built-in and contributed pills share this one-line rail and its deterministic geometry
* on every platform.
*
* The bar floats over the transcript with no background, so content remains visible underneath.
* Its host gives the scroll viewport a small bottom inset only when the bar exists; that keeps
+5
View File
@@ -67,6 +67,7 @@ import {
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
import { RenderProfile } from "@/utils/render-profiler";
import { useHasPluginComposerPills } from "@/plugins";
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
import {
type HostRuntimeConnectionStatus,
@@ -1288,11 +1289,13 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
parentAgentId: agentId,
rows: subagentRows,
});
const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId);
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
const hasVisibleAgentTracks = hasAgentTracks({
subagentRows,
tasks,
archiveFinishedStatus: archiveFinishedSubagents.status,
hasPluginComposerPills,
});
const rawAgentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
@@ -1379,11 +1382,13 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
<AgentTracks
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
cwd={cwd}
subagentRows={subagentRows}
tasks={tasks}
archiveFinishedStatus={archiveFinishedSubagents.status}
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
hasPluginComposerPills={hasPluginComposerPills}
/>
) : null}
</ReanimatedAnimated.View>
+28 -2
View File
@@ -6,6 +6,7 @@ import { ComposerTrackBar } from "@/composer/tracks";
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
import { usePaneContext } from "@/panels/pane-context";
import { useSettings } from "@/hooks/use-settings";
import { PluginComposerPills } from "@/plugins";
import { useSessionStore } from "@/stores/session-store";
import {
type ArchiveFinishedStatus,
@@ -30,19 +31,23 @@ import { openComposerChanges } from "@/workspace-tabs/open-supporting-view";
export const AgentTracks = memo(function AgentTracks({
serverId,
workspaceId,
agentId,
cwd,
subagentRows,
tasks,
archiveFinishedStatus,
onArchiveFinished,
hasPluginComposerPills,
}: {
serverId: string;
workspaceId: string;
agentId: string;
cwd: string;
subagentRows: SubagentRow[];
tasks: TodoEntry[] | undefined;
archiveFinishedStatus: ArchiveFinishedStatus;
onArchiveFinished: () => void;
hasPluginComposerPills: boolean;
}): ReactElement | null {
const { tabId, openTab } = usePaneContext();
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
@@ -107,7 +112,15 @@ export const AgentTracks = memo(function AgentTracks({
});
}, [cwd, isCompact, openInSidePane, serverId, workspaceKey]);
if (!hasWorkspaceDiffStat && !hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) {
if (
!hasWorkspaceDiffStat &&
!hasAgentTracks({
subagentRows,
tasks,
archiveFinishedStatus,
hasPluginComposerPills,
})
) {
return null;
}
@@ -123,6 +136,12 @@ export const AgentTracks = memo(function AgentTracks({
archiveFinishedStatus={archiveFinishedStatus}
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
/>
<PluginComposerPills
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
compact={isCompact}
/>
<WorkspaceDiffStatPill
serverId={serverId}
workspaceId={workspaceId}
@@ -136,10 +155,17 @@ export function hasAgentTracks({
subagentRows,
tasks,
archiveFinishedStatus,
hasPluginComposerPills = false,
}: {
subagentRows: readonly SubagentRow[];
tasks: readonly TodoEntry[] | undefined;
archiveFinishedStatus: ArchiveFinishedStatus;
hasPluginComposerPills?: boolean;
}): boolean {
return subagentRows.length > 0 || Boolean(tasks?.length) || archiveFinishedStatus.kind !== "idle";
return (
subagentRows.length > 0 ||
Boolean(tasks?.length) ||
archiveFinishedStatus.kind !== "idle" ||
hasPluginComposerPills
);
}
+94
View File
@@ -0,0 +1,94 @@
import { callPluginRpc } from "@getpaseo/plugin/host";
import type {
PluginAgentCommandContext,
PluginCommandCapabilities,
PluginPanelLocation,
PluginWorkspaceCommandContext,
} from "@getpaseo/plugin";
import type { PluginClientStateSource } from "@getpaseo/plugin/host";
import { resolvePluginPanelOpenLocation } from "./workspace-panels/locations";
import type { PluginSurfaceRuntime } from "./surface-runtime";
import type { InstalledPlugin } from "./types";
export interface PluginNavigation {
openSurface(pluginId: string, surfaceId: string): void;
openWorkspacePanel(pluginId: string, panelId: string, location: PluginPanelLocation): void;
openAgentPanel(
pluginId: string,
panelId: string,
agentId: string,
location: PluginPanelLocation,
): void;
}
export function createPluginCapabilities(
plugin: InstalledPlugin,
runtime: PluginSurfaceRuntime,
navigation: PluginNavigation,
): PluginCommandCapabilities {
return {
paseo: runtime.paseo,
rpc: (contract, input) => callPluginRpc(contract, runtime.invoke, input),
openSurface(surfaceId) {
if (!plugin.surfaces.some((surface) => surface.id === surfaceId)) {
throw new Error(`Plugin surface is unavailable: ${surfaceId}`);
}
navigation.openSurface(plugin.id, surfaceId);
},
};
}
export function createPluginAgentActionContext(input: {
plugin: InstalledPlugin;
runtime: PluginSurfaceRuntime;
navigation: PluginNavigation;
state: PluginClientStateSource;
workspaceId: string;
agentId: string;
}): PluginAgentCommandContext | null {
const { plugin, runtime, navigation, state, workspaceId, agentId } = input;
const workspace = state.getWorkspace(workspaceId);
const agent = state.getAgent(agentId);
if (!workspace || !agent || agent.workspaceId !== workspace.id) return null;
return {
context: "agent",
...createPluginCapabilities(plugin, runtime, navigation),
workspace,
agent,
openPanel(panelId, options) {
const panel = plugin.workspacePanels.find((candidate) => candidate.id === panelId);
if (!panel) throw new Error(`Workspace panel is unavailable: ${panelId}`);
const location = resolvePluginPanelOpenLocation(panel, options?.location);
if (panel.context === "workspace") {
navigation.openWorkspacePanel(plugin.id, panelId, location);
return;
}
navigation.openAgentPanel(plugin.id, panelId, agent.id, location);
},
};
}
export function createPluginWorkspaceActionContext(input: {
plugin: InstalledPlugin;
runtime: PluginSurfaceRuntime;
navigation: PluginNavigation;
state: PluginClientStateSource;
workspaceId: string;
}): PluginWorkspaceCommandContext | null {
const { plugin, runtime, navigation, state, workspaceId } = input;
const workspace = state.getWorkspace(workspaceId);
if (!workspace) return null;
return {
context: "workspace",
...createPluginCapabilities(plugin, runtime, navigation),
workspace,
openPanel(panelId, options) {
const panel = plugin.workspacePanels.find(
(candidate) => candidate.id === panelId && candidate.context === "workspace",
);
if (!panel) throw new Error(`Workspace panel is unavailable: ${panelId}`);
const location = resolvePluginPanelOpenLocation(panel, options?.location);
navigation.openWorkspacePanel(plugin.id, panelId, location);
},
};
}
@@ -34,6 +34,7 @@ export function PluginCatalogSync({
if (!cancelled) {
const timelineChanged = pluginRegistry.installCatalog(serverId, catalog, {
replacePluginId,
client,
});
if (timelineChanged) {
useSessionStore
@@ -97,6 +97,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
onSelect: onAgentSelect,
},
],
clientSide: null,
attachmentSources: [],
themes: [],
timelineTransformers: [],
@@ -1,5 +1,3 @@
import { callPluginRpc } from "@getpaseo/plugin/host";
import type { PluginCommandCapabilities, PluginPanelLocation } from "@getpaseo/plugin";
import type { PluginClientStateSource } from "@getpaseo/plugin/host";
import type { CommandCenterContribution } from "@/command-center/contributions";
import { getCommandCenterIcon } from "@/command-center/icon";
@@ -7,17 +5,7 @@ import { resolvePluginIcon } from "../icons";
import { resolvePluginPanelOpenLocation } from "../workspace-panels/locations";
import type { PluginSurfaceRuntime } from "../surface-runtime";
import type { InstalledPlugin } from "../types";
export interface PluginCommandCenterNavigation {
openSurface(pluginId: string, surfaceId: string): void;
openWorkspacePanel(pluginId: string, panelId: string, location: PluginPanelLocation): void;
openAgentPanel(
pluginId: string,
panelId: string,
agentId: string,
location: PluginPanelLocation,
): void;
}
import { createPluginCapabilities, type PluginNavigation } from "../actions";
export interface PluginCommandCenterSource {
plugins: readonly InstalledPlugin[];
@@ -25,34 +13,17 @@ export interface PluginCommandCenterSource {
state: PluginClientStateSource;
workspaceId: string | null;
agentId: string | null;
navigation: PluginCommandCenterNavigation;
navigation: PluginNavigation;
reportError(error: unknown): void;
}
function capabilities(
plugin: InstalledPlugin,
runtime: PluginSurfaceRuntime,
navigation: PluginCommandCenterNavigation,
): PluginCommandCapabilities {
return {
paseo: runtime.paseo,
rpc: (contract, input) => callPluginRpc(contract, runtime.invoke, input),
openSurface(surfaceId) {
if (!plugin.surfaces.some((surface) => surface.id === surfaceId)) {
throw new Error(`Plugin surface is unavailable: ${surfaceId}`);
}
navigation.openSurface(plugin.id, surfaceId);
},
};
}
export function buildPluginCommandCenterContributions(
source: PluginCommandCenterSource,
): CommandCenterContribution[] {
const contributions: CommandCenterContribution[] = [];
for (const plugin of source.plugins) {
const runtime = source.runtime(plugin.id);
const common = capabilities(plugin, runtime, source.navigation);
const common = createPluginCapabilities(plugin, runtime, source.navigation);
for (const [rank, item] of plugin.commandCenterItems.entries()) {
if (item.context === "workspace" && !source.workspaceId) continue;
if (item.context === "agent" && (!source.workspaceId || !source.agentId)) continue;
@@ -1,23 +1,20 @@
import { router, usePathname } from "expo-router";
import { usePathname } from "expo-router";
import { useMemo } from "react";
import { useCommandCenterActions } from "@/command-center/provider";
import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import {
useActiveWorkspaceSelection,
navigateToWorkspace,
} from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceExists } from "@/stores/session-store-hooks";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import type { PluginPanelLocation } from "@getpaseo/plugin";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { createPluginClientStateSource } from "../client-state/source";
import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes";
import { hostIdFromPathname } from "../routes";
import { useInstalledPlugins } from "../registry";
import { createPluginSurfaceRuntime } from "../surface-runtime";
import { buildPluginCommandCenterContributions } from "./contributions";
import { getFocusedAgentId } from "./context";
import { createPluginNavigation } from "../navigation";
export function PluginCommandCenterActions() {
const pathname = usePathname();
@@ -64,33 +61,7 @@ export function PluginCommandCenterActions() {
state: stateSource,
workspaceId: workspaceExists ? workspaceId : null,
agentId: agentExists ? focusedAgentId : null,
navigation: {
openSurface(pluginId, surfaceId) {
router.push(
buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId }),
);
},
openWorkspacePanel(pluginId, panelId, location) {
if (!workspaceId) throw new Error("No active workspace");
const placement = resolvePluginPanelPlacement(workspaceKey, location);
navigateToWorkspace({
serverId,
workspaceId,
target: { kind: "plugin", pluginId, panelId, context: "workspace" },
placement,
});
},
openAgentPanel(pluginId, panelId, agentId, location) {
if (!workspaceId) throw new Error("No active workspace");
const placement = resolvePluginPanelPlacement(workspaceKey, location);
navigateToWorkspace({
serverId,
workspaceId,
target: { kind: "plugin", pluginId, panelId, context: "agent", agentId },
placement,
});
},
},
navigation: createPluginNavigation({ serverId, workspaceId }),
reportError(error) {
toast.error(error instanceof Error ? error.message : String(error));
},
@@ -105,7 +76,6 @@ export function PluginCommandCenterActions() {
toast,
workspaceExists,
workspaceId,
workspaceKey,
]);
useCommandCenterActions({
@@ -115,11 +85,3 @@ export function PluginCommandCenterActions() {
});
return null;
}
function resolvePluginPanelPlacement(workspaceKey: string | null, location: PluginPanelLocation) {
if (location !== "explorer") return undefined;
if (!workspaceKey) throw new Error("No active workspace");
const paneId = useWorkspaceLayoutStore.getState().showExplorerSidebar(workspaceKey);
if (!paneId) throw new Error("Explorer is unavailable");
return { mode: "pane" as const, paneId };
}
@@ -0,0 +1,180 @@
import { PluginClientStateProvider } from "@getpaseo/plugin/host";
import type { PluginComposerPillProps, PluginTheme } from "@getpaseo/plugin";
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
import { Platform, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { composerPillStyles } from "@/composer/pill-styles";
import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import { createPluginClientStateSource } from "../client-state/source";
import { PluginRuntimeBoundary } from "../runtime-boundary";
import { createPluginSurfaceRuntime } from "../surface-runtime";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
import { toPluginTheme } from "../theme";
import type { InstalledPlugin, PluginComposerPillContribution } from "../types";
import { pluginComposerPillStore } from "./store";
const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
function resolvePlatform(): PluginComposerPillProps["layout"]["platform"] {
if (Platform.OS === "ios") return "ios";
if (Platform.OS === "android") return "android";
return "web";
}
function PluginComposerPill({
plugin,
contribution,
serverId,
workspaceId,
agentId,
compact,
hostLabel,
theme,
}: {
plugin: InstalledPlugin;
contribution: PluginComposerPillContribution;
serverId: string;
workspaceId: string;
agentId: string;
compact: boolean;
hostLabel: string;
theme: PluginTheme;
}) {
const client = useHostRuntimeClient(serverId);
const toast = useToast();
const [pending, setPending] = useState(false);
const runtime = useMemo(() => createPluginSurfaceRuntime(client, plugin.id), [client, plugin.id]);
const state = useMemo(() => createPluginClientStateSource(serverId), [serverId]);
const props = useMemo<PluginComposerPillProps>(
() => ({
theme,
host: { id: serverId, label: hostLabel },
layout: { compact, platform: resolvePlatform() },
workspaceId,
agentId,
}),
[agentId, compact, hostLabel, serverId, theme, workspaceId],
);
const press = useCallback(async () => {
if (!runtime || pending) return;
setPending(true);
try {
await contribution.onPress();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setPending(false);
}
}, [contribution, pending, runtime, toast]);
const pillStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
composerPillStyles.body,
styles.pill,
(hovered || pressed) && composerPillStyles.bodyActive,
pending && styles.disabled,
],
[pending],
);
const accessibilityState = useMemo(() => ({ busy: pending, disabled: pending }), [pending]);
if (!runtime) return null;
const Component = contribution.Component;
return (
<SurfaceErrorBoundary installation={plugin} Surface={Component}>
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
<PluginClientStateProvider source={state}>
<Pressable
accessibilityRole="button"
accessibilityLabel={contribution.title}
accessibilityState={accessibilityState}
disabled={pending}
onPress={press}
style={pillStyle}
>
<Component {...props} />
{pending ? <LoadingSpinner size="small" color={theme.colors.foregroundMuted} /> : null}
</Pressable>
</PluginClientStateProvider>
</PluginRuntimeBoundary>
</SurfaceErrorBoundary>
);
}
const ThemedPluginComposerPill = withUnistyles(PluginComposerPill);
export function PluginComposerPills({
serverId,
workspaceId,
agentId,
compact,
}: {
serverId: string;
workspaceId: string;
agentId: string;
compact: boolean;
}) {
const registrations = useSyncExternalStore(
pluginComposerPillStore.subscribe,
pluginComposerPillStore.getSnapshot,
pluginComposerPillStore.getSnapshot,
);
const hosts = useHosts();
const visible = useMemo(
() =>
registrations.filter(
({ installation, contribution }) =>
installation.serverId === serverId &&
contribution.workspaceId === workspaceId &&
contribution.agentId === agentId,
),
[agentId, registrations, serverId, workspaceId],
);
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
return (
<>
{visible.map(({ installation, contribution }) => (
<ThemedPluginComposerPill
key={`${installation.id}/${contribution.id}`}
plugin={installation}
contribution={contribution}
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
compact={compact}
hostLabel={hostLabel}
uniProps={pluginThemeMapping}
/>
))}
</>
);
}
export function useHasPluginComposerPills(
serverId: string,
workspaceId: string,
agentId: string,
): boolean {
const registrations = useSyncExternalStore(
pluginComposerPillStore.subscribe,
pluginComposerPillStore.getSnapshot,
pluginComposerPillStore.getSnapshot,
);
return registrations.some(
({ installation, contribution }) =>
installation.serverId === serverId &&
contribution.workspaceId === workspaceId &&
contribution.agentId === agentId,
);
}
const styles = StyleSheet.create(() => ({
pill: {
flexShrink: 1,
minWidth: 0,
},
disabled: {
opacity: 0.5,
},
}));
@@ -0,0 +1,111 @@
import { QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginClientContext, PluginComposerPillProps } from "@getpaseo/plugin";
import { afterEach, describe, expect, it } from "vitest";
import type { InstalledPlugin } from "../types";
import { startPluginClientSide } from "./lifecycle";
import { pluginComposerPillStore } from "./store";
function Pill(_props: PluginComposerPillProps) {
return null;
}
function installation(clientSide: NonNullable<InstalledPlugin["clientSide"]>): InstalledPlugin {
return {
id: "review",
serverId: "host-a",
clientBundle: "bundle",
queryClient: new QueryClient(),
cleanup: () => undefined,
surfaces: [],
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSide,
attachmentSources: [],
themes: [],
timelineTransformers: [],
timelineRenderers: [],
};
}
const daemonClient = {} as DaemonClient;
const installations: InstalledPlugin[] = [];
afterEach(() => {
for (const plugin of installations.splice(0)) {
pluginComposerPillStore.removeInstallation(plugin);
}
});
describe("plugin client-side lifecycle", () => {
it("lets client code add and remove a targeted composer pill at runtime", async () => {
const captured: { client?: PluginClientContext } = {};
let cleanupCount = 0;
const plugin = installation((context) => {
captured.client = context;
return () => {
cleanupCount += 1;
};
});
installations.push(plugin);
const stop = startPluginClientSide(plugin, daemonClient);
const client = captured.client;
if (!client) throw new Error("Client lifecycle did not start");
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
const remove = client.addComposerPill({
id: "review-ready",
title: "Open review",
workspaceId: "workspace-a",
agentId: "agent-a",
Component: Pill,
onPress() {},
});
expect(
pluginComposerPillStore.getSnapshot().map(({ contribution }) => ({
id: contribution.id,
workspaceId: contribution.workspaceId,
agentId: contribution.agentId,
})),
).toEqual([{ id: "review-ready", workspaceId: "workspace-a", agentId: "agent-a" }]);
remove();
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
client.addComposerPill({
id: "review-ready",
title: "Open review",
workspaceId: "workspace-a",
agentId: "agent-a",
Component: Pill,
onPress() {},
});
await stop();
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
expect(cleanupCount).toBe(1);
});
it("rejects duplicate pills only within the same plugin target", () => {
const plugin = installation(() => () => undefined);
installations.push(plugin);
const contribution = {
id: "review-ready",
title: "Open review",
workspaceId: "workspace-a",
agentId: "agent-a",
Component: Pill,
onPress() {},
};
pluginComposerPillStore.add(plugin, contribution);
expect(() => pluginComposerPillStore.add(plugin, contribution)).toThrow(
"Duplicate composer pill: review-ready",
);
expect(() =>
pluginComposerPillStore.add(plugin, { ...contribution, agentId: "agent-b" }),
).not.toThrow();
});
});
@@ -0,0 +1,90 @@
import type {
PluginCleanup,
PluginClientContext,
PluginClientOpenPanelOptions,
} from "@getpaseo/plugin";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
createPluginAgentActionContext,
createPluginCapabilities,
createPluginWorkspaceActionContext,
} from "../actions";
import { createPluginClientStateSource } from "../client-state/source";
import { createPluginNavigation } from "../navigation";
import { createPluginSurfaceRuntime } from "../surface-runtime";
import type { InstalledPlugin } from "../types";
import { pluginComposerPillStore } from "./store";
export function startPluginClientSide(
installation: InstalledPlugin,
daemonClient: DaemonClient,
): PluginCleanup {
if (!installation.clientSide) return () => undefined;
const runtime = createPluginSurfaceRuntime(daemonClient, installation.id);
if (!runtime) throw new Error("Plugin host is offline");
const state = createPluginClientStateSource(installation.serverId);
const capabilities = createPluginCapabilities(
installation,
runtime,
createPluginNavigation({ serverId: installation.serverId, workspaceId: null }),
);
const context: PluginClientContext = {
...capabilities,
addComposerPill(contribution) {
return pluginComposerPillStore.add(installation, contribution);
},
openPanel(panelId, options) {
openClientPanel({ installation, runtime, state, panelId, options });
},
};
let cleanup: PluginCleanup;
try {
cleanup = installation.clientSide(context);
if (typeof cleanup !== "function") {
throw new Error("Plugin client-side entrypoint must return a cleanup function");
}
} catch (error) {
pluginComposerPillStore.removeInstallation(installation);
throw error;
}
let stopped = false;
return async () => {
if (stopped) return;
stopped = true;
pluginComposerPillStore.removeInstallation(installation);
await cleanup();
};
}
function openClientPanel(input: {
installation: InstalledPlugin;
runtime: NonNullable<ReturnType<typeof createPluginSurfaceRuntime>>;
state: ReturnType<typeof createPluginClientStateSource>;
panelId: string;
options: PluginClientOpenPanelOptions;
}): void {
const { installation, runtime, state, panelId, options } = input;
const workspaceId = options.workspaceId.trim();
const agentId = options.agentId?.trim();
const navigation = createPluginNavigation({ serverId: installation.serverId, workspaceId });
const action = agentId
? createPluginAgentActionContext({
plugin: installation,
runtime,
navigation,
state,
workspaceId,
agentId,
})
: createPluginWorkspaceActionContext({
plugin: installation,
runtime,
navigation,
state,
workspaceId,
});
if (!action) throw new Error("Plugin panel context is unavailable");
action.openPanel(panelId, { location: options.location });
}
@@ -0,0 +1,78 @@
import type { PluginComposerPillContribution } from "@getpaseo/plugin";
import type { InstalledPlugin } from "../types";
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
export interface RegisteredPluginComposerPill {
installation: InstalledPlugin;
contribution: PluginComposerPillContribution;
}
class PluginComposerPillStore {
private entries: RegisteredPluginComposerPill[] = [];
private readonly listeners = new Set<() => void>();
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): readonly RegisteredPluginComposerPill[] => this.entries;
add(installation: InstalledPlugin, input: PluginComposerPillContribution): () => void {
const contribution = validateContribution(input);
const duplicate = this.entries.some(
(entry) =>
entry.installation === installation &&
entry.contribution.workspaceId === contribution.workspaceId &&
entry.contribution.agentId === contribution.agentId &&
entry.contribution.id === contribution.id,
);
if (duplicate) {
throw new Error(`Duplicate composer pill: ${contribution.id}`);
}
const entry = { installation, contribution };
this.entries = [...this.entries, entry];
this.publish();
let removed = false;
return () => {
if (removed) return;
removed = true;
this.entries = this.entries.filter((candidate) => candidate !== entry);
this.publish();
};
}
removeInstallation(installation: InstalledPlugin): void {
const next = this.entries.filter((entry) => entry.installation !== installation);
if (next.length === this.entries.length) return;
this.entries = next;
this.publish();
}
private publish(): void {
for (const listener of this.listeners) listener();
}
}
function validateContribution(
contribution: PluginComposerPillContribution,
): PluginComposerPillContribution {
const id = contribution.id.trim();
const title = contribution.title.trim();
const workspaceId = contribution.workspaceId.trim();
const agentId = contribution.agentId.trim();
if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid composer pill id: ${contribution.id}`);
if (!title) throw new Error(`Composer pill ${id} has no title`);
if (!workspaceId) throw new Error(`Composer pill ${id} has no workspace`);
if (!agentId) throw new Error(`Composer pill ${id} has no agent`);
if (typeof contribution.Component !== "function") {
throw new Error(`Composer pill ${id} is not a component`);
}
if (typeof contribution.onPress !== "function") {
throw new Error(`Composer pill ${id} has no callback`);
}
return { ...contribution, id, title, workspaceId, agentId };
}
export const pluginComposerPillStore = new PluginComposerPillStore();
+22
View File
@@ -212,6 +212,28 @@ describe("evaluatePluginClientBundle", () => {
).toThrow("Duplicate Command Center item: review");
});
it("collects one explicit client-side entrypoint", () => {
const plugin = evaluatePluginClientBundle(
"review",
bundle(`
function contributeClient() { return function() {}; }
plugin.addClientSide(contributeClient);
`),
);
expect(plugin.clientSide).toBeTypeOf("function");
expect(() =>
evaluatePluginClientBundle(
"review",
bundle(`
function contributeClient() { return function() {}; }
plugin.addClientSide(contributeClient);
plugin.addClientSide(contributeClient);
`),
),
).toThrow("Plugin has more than one client-side entrypoint");
});
it("rejects duplicate attachment source ids", () => {
expect(() =>
evaluatePluginClientBundle(
+10
View File
@@ -10,6 +10,7 @@ import {
defineRpc,
type PluginAttachmentSourceContribution,
type PluginCommandCenterItemContribution,
type PluginClientContribution,
type PluginSidebarContribution,
type PluginSurfaceProps,
type PluginThemeContribution,
@@ -71,6 +72,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSide: null,
attachmentSources: [],
themes: [],
timelineTransformers: [],
@@ -163,6 +165,13 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean),
});
},
addClientSide(contribution: PluginClientContribution) {
if (collector.clientSide) throw new Error("Plugin has more than one client-side entrypoint");
if (typeof contribution !== "function") {
throw new Error("Plugin client-side entrypoint is not a function");
}
collector.clientSide = contribution;
},
addAttachmentSource(contribution: PluginAttachmentSourceContribution) {
const normalizedId = requireId(contribution.id, "attachment source id");
if (attachmentSourceIds.has(normalizedId)) {
@@ -299,6 +308,7 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
sidebarItems: collector.sidebarItems,
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
commandCenterItems: collector.commandCenterItems,
clientSide: collector.clientSide,
attachmentSources: collector.attachmentSources,
themes: collector.themes,
timelineTransformers: collector.timelineTransformers,
+1
View File
@@ -4,3 +4,4 @@ export { PluginSidebarItems } from "./sidebar-items";
export { PluginSurfaceScreen } from "./surface-screen";
export { usePluginAttachmentPicker } from "./attachments/picker";
export { PluginResourceAttachmentPill } from "./attachments/pill";
export { PluginComposerPills, useHasPluginComposerPills } from "./composer-pills";
+44
View File
@@ -0,0 +1,44 @@
import { router } from "expo-router";
import type { PluginPanelLocation } from "@getpaseo/plugin";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildPluginSurfaceRoute } from "./routes";
import type { PluginNavigation } from "./actions";
export function createPluginNavigation(input: {
serverId: string;
workspaceId: string | null;
}): PluginNavigation {
const { serverId, workspaceId } = input;
function placement(location: PluginPanelLocation) {
if (location !== "explorer") return undefined;
if (!workspaceId) throw new Error("No active workspace");
const workspaceKey = `${serverId}:${workspaceId}`;
const paneId = useWorkspaceLayoutStore.getState().showExplorerSidebar(workspaceKey);
if (!paneId) throw new Error("Explorer is unavailable");
return { mode: "pane" as const, paneId };
}
return {
openSurface(pluginId, surfaceId) {
router.push(buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId }));
},
openWorkspacePanel(pluginId, panelId, location) {
if (!workspaceId) throw new Error("No active workspace");
navigateToWorkspace({
serverId,
workspaceId,
target: { kind: "plugin", pluginId, panelId, context: "workspace" },
placement: placement(location),
});
},
openAgentPanel(pluginId, panelId, agentId, location) {
if (!workspaceId) throw new Error("No active workspace");
navigateToWorkspace({
serverId,
workspaceId,
target: { kind: "plugin", pluginId, panelId, context: "agent", agentId },
placement: placement(location),
});
},
};
}
+26 -10
View File
@@ -1,5 +1,7 @@
import { useSyncExternalStore } from "react";
import { QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { startPluginClientSide } from "./composer-pills/lifecycle";
import { evaluatePluginClientBundle } from "./evaluate";
import type { InstalledPlugin } from "./types";
@@ -29,7 +31,7 @@ class PluginRegistry {
installCatalog(
serverId: string,
catalog: CatalogPlugin[],
options: { replacePluginId?: string } = {},
options: { replacePluginId?: string; client?: DaemonClient } = {},
): boolean {
const previous = this.byHost.get(serverId) ?? [];
const previousTimelineBundles = previous
@@ -61,16 +63,30 @@ class PluginRegistry {
return [existing];
}
const queryClient = new QueryClient();
const evaluated = [
{
...evaluatePluginClientBundle(entry.id, entry.clientBundle),
serverId,
clientBundle: entry.clientBundle,
queryClient,
},
];
const evaluated = evaluatePluginClientBundle(entry.id, entry.clientBundle);
const installation: InstalledPlugin = {
...evaluated,
serverId,
clientBundle: entry.clientBundle,
queryClient,
};
if (installation.clientSide) {
if (!options.client) throw new Error("Plugin client runtime is unavailable");
let clientCleanup;
try {
clientCleanup = startPluginClientSide(installation, options.client);
} catch (error) {
queryClient.clear();
void Promise.resolve(evaluated.cleanup()).catch(() => undefined);
throw error;
}
installation.cleanup = async () => {
await clientCleanup();
await evaluated.cleanup();
};
}
this.evaluationErrors.delete(key);
return evaluated;
return [installation];
} catch (error) {
this.evaluationErrors.set(key, error instanceof Error ? error.message : String(error));
console.warn(`[Plugins] Failed to evaluate ${serverId}/${entry.id}`, error);
@@ -21,6 +21,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin {
],
workspacePanels: [],
commandCenterItems: [],
clientSide: null,
attachmentSources: [],
themes: [],
timelineTransformers: [],
@@ -26,6 +26,7 @@ function installation(
})),
workspacePanels: [],
commandCenterItems: [],
clientSide: null,
attachmentSources: [],
themes: [],
timelineTransformers: [],
+1
View File
@@ -54,6 +54,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSide: null,
attachmentSources: [],
themes,
timelineTransformers: [],
@@ -17,6 +17,7 @@ function plugin(input: {
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSide: null,
attachmentSources: [],
themes: [],
timelineTransformers: [
+6 -1
View File
@@ -2,6 +2,9 @@ import type { QueryClient } from "@tanstack/react-query";
import type {
PluginAttachmentSourceContribution,
PluginCommandCenterItemContribution,
PluginClientContribution,
PluginCleanup,
PluginComposerPillContribution,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginThemeContribution,
@@ -17,11 +20,12 @@ export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelCont
export interface EvaluatedPlugin {
id: string;
cleanup: () => void;
cleanup: PluginCleanup;
surfaces: PluginSurfaceContribution[];
sidebarItems: PluginSidebarContribution[];
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
commandCenterItems: PluginCommandCenterItemContribution[];
clientSide: PluginClientContribution | null;
attachmentSources: PluginAttachmentSourceContribution[];
themes: PluginThemeContribution[];
timelineTransformers: PluginTimelineTransformerContribution[];
@@ -37,6 +41,7 @@ export interface InstalledPlugin extends EvaluatedPlugin {
export type {
PluginAttachmentSourceContribution,
PluginCommandCenterItemContribution,
PluginComposerPillContribution,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginThemeContribution,
@@ -23,6 +23,7 @@ function installed(): InstalledPlugin {
},
],
commandCenterItems: [],
clientSide: null,
attachmentSources: [],
themes: [],
timelineTransformers: [],
@@ -81,10 +81,13 @@ import { Text } from "react-native";
import {
Icon,
type PluginAgentPanelProps,
type PluginClientContext,
type PluginComposerPillProps,
useAgent,
usePaseo,
useWorkspace,
} from "@getpaseo/plugin";
import { inspect } from "./inspect.shared";
export function Surface() {
const paseo = usePaseo();
@@ -108,12 +111,30 @@ export function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
});
return <Text>{workspaceName}: {agentTitle}</Text>;
}
export function ComposerPill({ workspaceId, agentId }: PluginComposerPillProps) {
return <Text>{workspaceId}: {agentId}</Text>;
}
export function contributeClient(client: PluginClientContext) {
return client.addComposerPill({
id: "open-review",
title: "Open review",
workspaceId: "workspace-a",
agentId: "agent-a",
Component: ComposerPill,
async onPress() {
await client.rpc(inspect, {});
client.openPanel("review", { workspaceId: "workspace-a", agentId: "agent-a" });
},
});
}
`,
),
writeFile(
path.join(directory, "index.ts"),
`import type { PluginContext } from "@getpaseo/plugin";
import { AgentPanel, Surface } from "./main.client";
import { AgentPanel, contributeClient, Surface } from "./main.client";
import { inspectConfig } from "./inspect.server";
import { inspect } from "./inspect.shared";
@@ -138,6 +159,7 @@ export default function contribute(plugin: PluginContext) {
openPanel("review");
},
});
plugin.addClientSide(contributeClient);
return () => {};
}
`,
@@ -155,8 +155,26 @@ declare module "@getpaseo/plugin" {
agentId: string;
}
export interface PluginComposerPillProps extends PluginHostProps {
workspaceId: string;
agentId: string;
}
export interface PluginComposerPillContribution {
id: string;
title: string;
workspaceId: string;
agentId: string;
Component: ComponentType<PluginComposerPillProps>;
onPress(): void | Promise<void>;
}
export type PluginPanelLocation = "workspace" | "explorer";
export interface PluginOpenPanelOptions { location?: PluginPanelLocation; }
export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
workspaceId: string;
agentId?: string;
}
export type PluginWorkspacePanelContribution =
| { id: string; title: string; icon: string; locations?: readonly PluginPanelLocation[]; context: "workspace"; Component: ComponentType<PluginWorkspacePanelProps> }
@@ -241,6 +259,12 @@ declare module "@getpaseo/plugin" {
openPanel(id: string, options?: PluginOpenPanelOptions): void;
}
export interface PluginClientContext extends PluginCommandCapabilities {
addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup;
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
}
export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
export type PluginCommandCenterItemContribution =
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "global"; onSelect(context: PluginGlobalCommandContext): void | Promise<void> }
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "workspace"; onSelect(context: PluginWorkspaceCommandContext): void | Promise<void> }
@@ -258,6 +282,7 @@ declare module "@getpaseo/plugin" {
addSidebarItem(contribution: PluginSidebarContribution): void;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
addClientSide(contribution: PluginClientContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
addTheme(contribution: PluginThemeContribution): void;
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(contribution: PluginTimelineTransformerContribution<ItemType>): void;
+27
View File
@@ -99,6 +99,32 @@ export interface PluginAgentPanelProps extends PluginHostProps {
agentId: string;
}
export interface PluginComposerPillProps extends PluginHostProps {
workspaceId: string;
agentId: string;
}
export interface PluginComposerPillContribution {
id: string;
title: string;
workspaceId: string;
agentId: string;
Component: ComponentType<PluginComposerPillProps>;
onPress(): void | Promise<void>;
}
export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
workspaceId: string;
agentId?: string;
}
export interface PluginClientContext extends PluginCommandCapabilities {
addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup;
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
}
export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup;
export type PluginWorkspacePanelContribution =
| (PluginWorkspacePanelBase & {
context: "workspace";
@@ -262,6 +288,7 @@ export interface PluginContext {
addSidebarItem(contribution: PluginSidebarContribution): void;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
addClientSide(contribution: PluginClientContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
addTheme(contribution: PluginThemeContribution): void;
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
+7
View File
@@ -3,6 +3,7 @@ export { PluginClientStateProvider, type PluginClientStateSource } from "./clien
import type {
PluginAttachmentSourceContribution,
PluginCommandCenterItemContribution,
PluginClientContribution,
PluginContext,
PluginSidebarContribution,
PluginSurfaceContribution,
@@ -22,6 +23,7 @@ interface PluginCollector {
addSidebarItem(contribution: PluginSidebarContribution): void;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
addClientSide(contribution: PluginClientContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
addTheme(contribution: PluginThemeContribution): void;
addTimelineTransformer(contribution: PluginTimelineTransformerContribution): void;
@@ -33,6 +35,7 @@ export interface PluginRegistrationCollector {
sidebarItems: PluginSidebarContribution[];
workspacePanels: PluginWorkspacePanelContribution[];
commandCenterItems: PluginCommandCenterItemContribution[];
clientSide: PluginClientContribution | null;
attachmentSources: PluginAttachmentSourceContribution[];
themes: PluginThemeContribution[];
timelineTransformers: PluginTimelineTransformerContribution[];
@@ -47,6 +50,7 @@ export function createPluginContext(
| "addSidebarItem"
| "addWorkspacePanel"
| "addCommandCenterItem"
| "addClientSide"
| "addAttachmentSource"
| "addTheme"
| "addTimelineTransformer"
@@ -65,6 +69,9 @@ export function createPluginContext(
addCommandCenterItem(contribution) {
collector.addCommandCenterItem(contribution);
},
addClientSide(contribution) {
collector.addClientSide(contribution);
},
addAttachmentSource(contribution) {
collector.addAttachmentSource(contribution);
},
+5
View File
@@ -20,6 +20,11 @@ export type {
PluginCleanup,
PluginCommandCapabilities,
PluginCommandCenterItemContribution,
PluginClientContext,
PluginClientContribution,
PluginClientOpenPanelOptions,
PluginComposerPillContribution,
PluginComposerPillProps,
PluginContribution,
PluginContext,
PluginGlobalCommandContext,
@@ -117,7 +117,7 @@ export default function contribute(plugin: PluginContext) {
});
describe("plugin contribution targets", () => {
it("keeps timeline contributions out of the server bundle", async () => {
it("keeps client contributions out of the server bundle", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-"));
temporaryDirectories.push(directory);
const entryPath = path.join(directory, "index.ts");
@@ -135,6 +135,16 @@ describe("plugin contribution targets", () => {
schema: { safeParse(value) { return { success: true, data: value }; } },
Component() { return null; },
});
plugin.addClientSide((client) => {
return client.addComposerPill({
id: "composer-card",
title: "Composer card",
workspaceId: "workspace-a",
agentId: "agent-a",
Component() { return null; },
onPress() {},
});
});
return () => undefined;
}
`,
@@ -142,7 +152,9 @@ describe("plugin contribution targets", () => {
const { clientBundle, serverBundle } = await compilePlugin(entryPath);
expect(clientBundle).toContain("timeline-card");
expect(clientBundle).toContain("composer-card");
expect(serverBundle).not.toContain("timeline-card");
expect(serverBundle).not.toContain("composer-card");
});
});
@@ -80,6 +80,7 @@ const REGISTRATIONS_REMOVED_BY_TARGET: Record<PluginBuildTarget, ReadonlySet<str
"addSidebarItem",
"addWorkspacePanel",
"addCommandCenterItem",
"addClientSide",
"addAttachmentSource",
"addTheme",
"addTimelineTransformer",
+2 -1
View File
@@ -1,5 +1,5 @@
import type { PluginContext } from "@getpaseo/plugin";
import { ExamplePanel } from "./main.client";
import { contributeClient, ExamplePanel } from "./main.client";
import { increment } from "./increment.server";
import { incrementRpc } from "./increment.shared";
@@ -22,5 +22,6 @@ export default function contribute(plugin: PluginContext) {
openPanel("counter");
},
});
plugin.addClientSide(contributeClient);
return () => {};
}
+65 -1
View File
@@ -1,9 +1,73 @@
import { useMutation } from "@tanstack/react-query";
import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@getpaseo/plugin";
import {
Icon,
type PluginClientContext,
type PluginComposerPillProps,
type PluginWorkspacePanelProps,
useAgent,
useRpc,
useWorkspace,
} from "@getpaseo/plugin";
import React, { useCallback, useMemo } from "react";
import { Pressable, Text, View } from "react-native";
import { incrementRpc } from "./increment.shared";
export function OpenCounterPill({ theme, workspaceId, agentId }: PluginComposerPillProps) {
const workspace = useWorkspace(workspaceId, ({ name }) => ({ name }));
const agent = useAgent(agentId, ({ title }) => ({ title }));
const textStyle = useMemo(() => ({ color: theme.colors.foregroundMuted }), [theme]);
return (
<>
<Icon name="Blocks" size={14} color={theme.colors.foregroundMuted} />
<Text style={textStyle} numberOfLines={1}>
{agent?.title ?? workspace?.name ?? "Counter"}
</Text>
</>
);
}
export function contributeClient(client: PluginClientContext) {
const pills = new Map<string, () => void>();
let stopped = false;
const register = (agent: { id: string; workspaceId?: string | null }) => {
if (stopped || !agent.workspaceId) return;
pills.get(agent.id)?.();
const workspaceId = agent.workspaceId;
const remove = client.addComposerPill({
id: "open-counter",
title: "Open plugin counter",
workspaceId,
agentId: agent.id,
Component: OpenCounterPill,
onPress() {
client.openPanel("counter", { workspaceId });
},
});
pills.set(agent.id, remove);
};
const remove = (agentId: string) => {
pills.get(agentId)?.();
pills.delete(agentId);
};
const unsubscribe = client.paseo.agents.subscribe((update) => {
if (update.kind === "remove") remove(update.agentId);
else register(update.agent);
});
void client.paseo.agents
.list()
.then(({ entries }) => {
for (const { agent } of entries) register(agent);
return undefined;
})
.catch(() => undefined);
return () => {
stopped = true;
unsubscribe();
for (const dispose of pills.values()) dispose();
pills.clear();
};
}
export function ExamplePanel({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
const workspace = useWorkspace(workspaceId, ({ name }) => ({ name }));
const callIncrement = useRpc(incrementRpc);
+1 -1
View File
@@ -14,7 +14,7 @@ category: Plugins
See the [plugin roadmap](https://github.com/getpaseo/paseo/labels/plugins) for planned contribution
surfaces and their current status.
Paseo plugins add native workspace panels, Command Center items, global surfaces, app themes, daemon behavior, and composer attachment sources. They run on every Paseo client connected to the host, including mobile.
Paseo plugins add native workspace panels, composer pills, Command Center items, global surfaces, app themes, daemon behavior, and composer attachment sources. They run on every Paseo client connected to the host, including mobile.
Plugins are trusted local code. Install only code you trust: backend code runs unsandboxed with access to the daemon machine, and client contributions run inside the Paseo app.
+86
View File
@@ -478,6 +478,92 @@ Every callback receives:
An agent callback may open either an agent panel or a workspace panel. A workspace callback may open only a workspace panel. Unknown surface and panel IDs fail visibly. Use `paseo` for normal workspace, agent, provider, and daemon-config operations. Use `rpc` for plugin-specific filesystem, credential, vendor, or daemon-local work.
## Composer pills
Register a headless client entrypoint from `index.ts`:
```ts
import { contributeClient } from "./review.client";
export default function contribute(plugin: PluginContext) {
plugin.addClientSide(contributeClient);
return () => {};
}
```
The client entrypoint owns pill creation and removal:
```tsx
import {
Icon,
type PluginClientContext,
type PluginComposerPillProps,
useAgent,
} from "@getpaseo/plugin";
import { Text } from "react-native";
function ReviewPill({ theme, agentId }: PluginComposerPillProps) {
const agent = useAgent(agentId, ({ title }) => ({ title }));
return (
<>
<Icon name="Scan" size={14} color={theme.colors.foregroundMuted} />
<Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, flexShrink: 1 }}>
{agent?.title ?? "Review"}
</Text>
</>
);
}
export function contributeClient(client: PluginClientContext) {
const pills = new Map<string, () => void>();
const unsubscribe = client.paseo.agents.subscribe((update) => {
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
const { id: agentId, workspaceId } = update.agent;
pills.get(agentId)?.();
pills.set(
agentId,
client.addComposerPill({
id: "review",
title: "Open review",
workspaceId,
agentId,
Component: ReviewPill,
async onPress() {
await client.rpc(refreshReview, { agentId });
client.openPanel("review", { workspaceId, agentId });
},
}),
);
});
return () => {
unsubscribe();
for (const remove of pills.values()) remove();
};
}
```
`addComposerPill` fields:
| Field | Required | Meaning |
| ------------- | -------- | ---------------------------------------------------------- |
| `id` | Yes | Plugin-local ID within the target agent. |
| `title` | Yes | Accessible button label. |
| `workspaceId` | Yes | Workspace whose composer track owns the pill. |
| `agentId` | Yes | Agent whose composer track owns the pill. |
| `Component` | Yes | React Native component rendering the pill's icon and text. |
| `onPress` | Yes | Client-side callback. |
`addClientSide` runs once per plugin installation in each connected app. Its context exposes
`paseo`, typed `rpc`, `openSurface`, explicit-context `openPanel`, and `addComposerPill`.
`addComposerPill` returns an idempotent removal function. Paseo also removes every outstanding pill
when the client entrypoint, plugin installation, or host connection is torn down.
Paseo owns the pressable, shared pill chrome, pending state, error reporting, and track-bar
placement. The component receives `theme`, `host`, `layout`, `workspaceId`, and `agentId`. Read
current values with `useWorkspace` and `useAgent`. The plugin owns when the pill exists, its icon
and text, and the callback. `openPanel(id, { workspaceId, agentId? })` opens or focuses a panel
registered by the same plugin.
## Use the Paseo SDK
Use `usePaseo()` for ordinary Paseo operations from a surface. It borrows the selected host's existing connection; do not create another client.