Let plugins add contextual workspace tools (#3465)

* feat(plugins): add workspace panels and command center contributions

Give local plugins contextual UI entry points without making the daemon interpret client placement or callbacks. Panels receive stable IDs and read cached client state through required selectors with shallow equality, avoiding unrelated guest rerenders and context-discovery RPCs.

* fix(plugins): align shared client state ownership

Workspace panels and Command Center callbacks consume the same frozen client snapshots, so their adapter belongs to the plugin boundary rather than either contribution surface. Refresh the Nix dependency hash for the selector-store dependency added by this feature.
This commit is contained in:
Mohamed Boudra
2026-08-17 19:26:40 +02:00
committed by GitHub
parent 1e2089cef7
commit 6d7606160e
64 changed files with 2557 additions and 122 deletions
@@ -46,7 +46,14 @@ describe("plugin scaffold", () => {
`import React from "react";
import { Text } from "react-native";
import { z } from "zod";
import { defineRpc, type PluginContext, usePaseo } from "@paseo/plugin";
import {
defineRpc,
type PluginContext,
type PluginAgentPanelProps,
usePaseo,
useAgent,
useWorkspace,
} from "@paseo/plugin";
const inspect = defineRpc({
name: "inspect",
@@ -63,12 +70,44 @@ function Surface() {
return <Text>Paseo API</Text>;
}
function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
const workspaceName = useWorkspace(workspaceId, (workspace) => {
// @ts-expect-error Plugin snapshots are readonly.
workspace.name = "mutated";
return workspace.name;
});
const agentTitle = useAgent(agentId, (agent) => {
// @ts-expect-error Nested plugin snapshot values are readonly.
agent.labels.phase = "mutated";
return agent.title;
});
return <Text>{workspaceName}: {agentTitle}</Text>;
}
export default function contribute(plugin: PluginContext) {
plugin.handle(inspect, async (_input, { paseo }) => ({
configured: Boolean((await paseo.config.get()).config),
}));
plugin.addSurface("main", Surface);
return () => undefined;
plugin.addWorkspacePanel({
id: "review",
title: "Review",
icon: "Scan",
context: "agent",
Component: AgentPanel,
});
plugin.addCommandCenterItem({
id: "open-review",
title: "Open review",
icon: "Scan",
context: "agent",
async onSelect({ paseo, rpc, workspace, openPanel }) {
await paseo.workspaces.ref(workspace.id).setTitle("Review");
await rpc(inspect, {});
openPanel("review");
},
});
return () => {};
}
`,
);
+97 -2
View File
@@ -7,12 +7,64 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
export interface PluginSurfaceProps {
export interface PluginHostProps {
theme: Record<string, unknown>;
host: { id: string; label: string };
layout: { compact: boolean; platform: "ios" | "android" | "web" };
}
export interface PluginSurfaceProps extends PluginHostProps {}
export interface PluginWorkspaceSnapshot {
readonly id: string;
readonly projectId: string;
readonly projectDisplayName: string;
readonly projectRootPath: string;
readonly directory: string;
readonly projectKind: "git" | "non_git" | "directory";
readonly kind: "directory" | "local_checkout" | "checkout" | "worktree";
readonly name: string;
readonly title: string | null;
readonly status: "needs_input" | "failed" | "running" | "attention" | "done";
readonly statusEnteredAt: string | null;
readonly archivingAt: string | null;
readonly diffStat: { readonly additions: number; readonly deletions: number } | null;
}
export interface PluginAgentSnapshot {
readonly id: string;
readonly workspaceId: string;
readonly provider: string;
readonly status: "initializing" | "idle" | "running" | "error" | "closed";
readonly createdAt: string;
readonly updatedAt: string;
readonly lastActivityAt: string;
readonly title: string | null;
readonly cwd: string;
readonly model: string | null;
readonly currentModeId: string | null;
readonly thinkingOptionId: string | null;
readonly requiresAttention: boolean;
readonly attentionReason: "finished" | "error" | "permission" | null;
readonly parentAgentId: string | null;
readonly labels: Readonly<Record<string, string>>;
}
export interface PluginWorkspacePanelProps extends PluginHostProps {
context: "workspace";
workspaceId: string;
}
export interface PluginAgentPanelProps extends PluginHostProps {
context: "agent";
workspaceId: string;
agentId: string;
}
export type PluginWorkspacePanelContribution =
| { id: string; title: string; icon: string; context: "workspace"; Component: ComponentType<PluginWorkspacePanelProps> }
| { id: string; title: string; icon: string; context: "agent"; Component: ComponentType<PluginAgentPanelProps> };
export interface PluginRpcContract<
InputSchema extends ZodType = ZodType,
OutputSchema extends ZodType = ZodType,
@@ -57,6 +109,37 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
Component: ComponentType<PluginSurfaceProps>;
}
export interface PluginCommandCapabilities {
paseo: PaseoApi;
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
input: ZodInput<InputSchema>,
): Promise<ZodOutput<OutputSchema>>;
openSurface(id: string): void;
}
export interface PluginGlobalCommandContext extends PluginCommandCapabilities {
context: "global";
}
export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities {
context: "workspace";
workspace: PluginWorkspaceSnapshot;
openPanel(id: string): void;
}
export interface PluginAgentCommandContext extends PluginCommandCapabilities {
context: "agent";
workspace: PluginWorkspaceSnapshot;
agent: PluginAgentSnapshot;
openPanel(id: string): void;
}
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> }
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "agent"; onSelect(context: PluginAgentCommandContext): void | Promise<void> };
export interface PluginHandlerContext {
paseo: PaseoApi;
}
@@ -71,6 +154,8 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
): void;
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
addSidebarItem(contribution: PluginSidebarContribution): void;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
}
@@ -89,6 +174,16 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
export function usePaseo(): PaseoApi;
export function useWorkspace<Selection>(
workspaceId: string,
selector: (workspace: PluginWorkspaceSnapshot) => Selection,
): Selection | null;
export function useAgent<Selection>(
agentId: string,
selector: (agent: PluginAgentSnapshot) => Selection,
): Selection | null;
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition;
@@ -124,7 +219,7 @@ function MainSurface() {
export default function contribute(plugin: PluginContext) {
plugin.addSurface("main", MainSurface);
return () => undefined;
return () => {};
}
`;