diff --git a/docs/plugins.md b/docs/plugins.md index 3490b908c5..e80943d2a9 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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 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 diff --git a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts index b320e7e5bf..a33f612c9b 100644 --- a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts +++ b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts @@ -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 Sidebar collision surface; } +function ComposerPill({ theme, workspaceId, agentId }) { + const workspace = useWorkspace(workspaceId, (value) => ({ title: value.title })); + const agent = useAgent(agentId, (value) => ({ title: value.title })); + return <>Review {workspace?.title}:{agent?.title}; +} + +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 () => { diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx index a4b2f889a8..3ff9e765c6 100644 --- a/packages/app/src/composer/tracks.tsx +++ b/packages/app/src/composer/tracks.tsx @@ -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 diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index e3b54f1085..f7b8f7d6b9 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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({ ) : null} diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 3ea15696c4..cf505b63bc 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -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} /> + 0 || Boolean(tasks?.length) || archiveFinishedStatus.kind !== "idle"; + return ( + subagentRows.length > 0 || + Boolean(tasks?.length) || + archiveFinishedStatus.kind !== "idle" || + hasPluginComposerPills + ); } diff --git a/packages/app/src/plugins/actions.ts b/packages/app/src/plugins/actions.ts new file mode 100644 index 0000000000..0d9278a702 --- /dev/null +++ b/packages/app/src/plugins/actions.ts @@ -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); + }, + }; +} diff --git a/packages/app/src/plugins/catalog-sync.tsx b/packages/app/src/plugins/catalog-sync.tsx index af465a89a7..4627aebc88 100644 --- a/packages/app/src/plugins/catalog-sync.tsx +++ b/packages/app/src/plugins/catalog-sync.tsx @@ -34,6 +34,7 @@ export function PluginCatalogSync({ if (!cancelled) { const timelineChanged = pluginRegistry.installCatalog(serverId, catalog, { replacePluginId, + client, }); if (timelineChanged) { useSessionStore diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index a8e3f6eeca..c0933c0975 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -97,6 +97,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { onSelect: onAgentSelect, }, ], + clientSide: null, attachmentSources: [], themes: [], timelineTransformers: [], diff --git a/packages/app/src/plugins/command-center/contributions.ts b/packages/app/src/plugins/command-center/contributions.ts index 70bc65bc51..fbe67e034f 100644 --- a/packages/app/src/plugins/command-center/contributions.ts +++ b/packages/app/src/plugins/command-center/contributions.ts @@ -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; diff --git a/packages/app/src/plugins/command-center/registration.tsx b/packages/app/src/plugins/command-center/registration.tsx index 462d3df148..2ee0d24ff2 100644 --- a/packages/app/src/plugins/command-center/registration.tsx +++ b/packages/app/src/plugins/command-center/registration.tsx @@ -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 }; -} diff --git a/packages/app/src/plugins/composer-pills/index.tsx b/packages/app/src/plugins/composer-pills/index.tsx new file mode 100644 index 0000000000..12797bb08d --- /dev/null +++ b/packages/app/src/plugins/composer-pills/index.tsx @@ -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( + () => ({ + 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 ( + + + + + + {pending ? : null} + + + + + ); +} + +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 }) => ( + + ))} + + ); +} + +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, + }, +})); diff --git a/packages/app/src/plugins/composer-pills/lifecycle.test.ts b/packages/app/src/plugins/composer-pills/lifecycle.test.ts new file mode 100644 index 0000000000..2d119b609a --- /dev/null +++ b/packages/app/src/plugins/composer-pills/lifecycle.test.ts @@ -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 { + 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(); + }); +}); diff --git a/packages/app/src/plugins/composer-pills/lifecycle.ts b/packages/app/src/plugins/composer-pills/lifecycle.ts new file mode 100644 index 0000000000..669e8c513d --- /dev/null +++ b/packages/app/src/plugins/composer-pills/lifecycle.ts @@ -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>; + state: ReturnType; + 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 }); +} diff --git a/packages/app/src/plugins/composer-pills/store.ts b/packages/app/src/plugins/composer-pills/store.ts new file mode 100644 index 0000000000..ae273eb2b5 --- /dev/null +++ b/packages/app/src/plugins/composer-pills/store.ts @@ -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(); diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index a4975a2046..799a13132d 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -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( diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index 8e7a5e5f04..b8e9e58ddb 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -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, diff --git a/packages/app/src/plugins/index.ts b/packages/app/src/plugins/index.ts index 6a3cf46aac..b0c9407cd0 100644 --- a/packages/app/src/plugins/index.ts +++ b/packages/app/src/plugins/index.ts @@ -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"; diff --git a/packages/app/src/plugins/navigation.ts b/packages/app/src/plugins/navigation.ts new file mode 100644 index 0000000000..cd1f9c129b --- /dev/null +++ b/packages/app/src/plugins/navigation.ts @@ -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), + }); + }, + }; +} diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index 3d7f2decee..4d93e70370 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -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); diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index 1f46b728a7..907c9531cd 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -21,6 +21,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin { ], workspacePanels: [], commandCenterItems: [], + clientSide: null, attachmentSources: [], themes: [], timelineTransformers: [], diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index c8f481cccf..66e897f0fd 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -26,6 +26,7 @@ function installation( })), workspacePanels: [], commandCenterItems: [], + clientSide: null, attachmentSources: [], themes: [], timelineTransformers: [], diff --git a/packages/app/src/plugins/theme.test.ts b/packages/app/src/plugins/theme.test.ts index e10b2b55b4..f0f5882bef 100644 --- a/packages/app/src/plugins/theme.test.ts +++ b/packages/app/src/plugins/theme.test.ts @@ -54,6 +54,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install sidebarItems: [], workspacePanels: [], commandCenterItems: [], + clientSide: null, attachmentSources: [], themes, timelineTransformers: [], diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index 390f5380a7..fbd4609238 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -17,6 +17,7 @@ function plugin(input: { sidebarItems: [], workspacePanels: [], commandCenterItems: [], + clientSide: null, attachmentSources: [], themes: [], timelineTransformers: [ diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 576700d533..ddaa3eeffd 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -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, diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts index 597ca9a2e8..38e6e9fdb1 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -23,6 +23,7 @@ function installed(): InstalledPlugin { }, ], commandCenterItems: [], + clientSide: null, attachmentSources: [], themes: [], timelineTransformers: [], diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index 11ae66466d..9bda00fe3a 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -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 {workspaceName}: {agentTitle}; } + +export function ComposerPill({ workspaceId, agentId }: PluginComposerPillProps) { + return {workspaceId}: {agentId}; +} + +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 () => {}; } `, diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index ee15dbe12f..defeb6aa7a 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -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; + onPress(): void | Promise; + } + 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 } @@ -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 } | { id: string; title: string; icon: string; keywords?: readonly string[]; context: "workspace"; onSelect(context: PluginWorkspaceCommandContext): void | Promise } @@ -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(contribution: PluginTimelineTransformerContribution): void; diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 1faf4924c4..75a95be881 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -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; + onPress(): void | Promise; +} + +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( diff --git a/packages/plugin/src/host.ts b/packages/plugin/src/host.ts index 244c9c20ea..61810e75d4 100644 --- a/packages/plugin/src/host.ts +++ b/packages/plugin/src/host.ts @@ -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); }, diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 67dc1b512f..295ebe74f5 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -20,6 +20,11 @@ export type { PluginCleanup, PluginCommandCapabilities, PluginCommandCenterItemContribution, + PluginClientContext, + PluginClientContribution, + PluginClientOpenPanelOptions, + PluginComposerPillContribution, + PluginComposerPillProps, PluginContribution, PluginContext, PluginGlobalCommandContext, diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index f79fbe1812..37584ae070 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -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"); }); }); diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index 499271fe37..e9c3e9e735 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -80,6 +80,7 @@ const REGISTRATIONS_REMOVED_BY_TARGET: Record {}; } diff --git a/plugin-examples/local-plugin/main.client.tsx b/plugin-examples/local-plugin/main.client.tsx index 16d9b0f46d..f364b1df42 100644 --- a/plugin-examples/local-plugin/main.client.tsx +++ b/plugin-examples/local-plugin/main.client.tsx @@ -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 ( + <> + + + {agent?.title ?? workspace?.name ?? "Counter"} + + + ); +} + +export function contributeClient(client: PluginClientContext) { + const pills = new Map 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); diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index acbeb13444..22112fba47 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -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. diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index f4ebca30e5..e8d88ceec0 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -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 ( + <> + + + {agent?.title ?? "Review"} + + + ); +} + +export function contributeClient(client: PluginClientContext) { + const pills = new Map 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.