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
+3 -1
View File
@@ -17,7 +17,9 @@ Each layout owns only the routes directly inside its directory.
`h/[serverId]/index`.
- `packages/app/src/app/h/[serverId]/_layout.tsx` owns the host leaves with
relative screen names: `index`, `workspace/[workspaceId]/index`,
`agent/[agentId]`, `sessions`, `open-project`, and `settings`.
`agent/[agentId]`, `sessions`, `open-project`, `settings`,
`plugin/[pluginId]/[surfaceId]`, and
`plugin/[pluginId]/[contributionKind]/[contributionId]`.
Expo Router warns with `[Layout children]: No route named ...` when a layout
registers grandchildren. Treat that warning as a route-tree bug. On native, that
+21 -5
View File
@@ -1,8 +1,9 @@
# Local plugins
Local plugins contribute daemon RPCs, native app surfaces, and composer attachment sources from one
`index.tsx`. Paseo executes the server contribution in a subprocess and evaluates the client
contribution in the app runtime. Plugin code is trusted code; this first slice does not sandbox it.
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
and composer attachment sources from one `index.tsx`. Paseo executes the server contribution in a
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
this first slice does not sandbox it.
## Install a directory source
@@ -136,7 +137,7 @@ export default function contribute(plugin: PluginContext) {
icon: "MessageCircle",
surface: "main",
});
return () => undefined;
return () => {};
}
```
@@ -170,6 +171,21 @@ When the same plugin contribution exists on multiple hosts, Paseo shows it once
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Plugin code cannot address another host.
Workspace panels and Command Center items remain client contributions. The daemon transports their
compiled bundle without interpreting placement or callbacks. Panel props contain workspace and agent
IDs. Required-selector hooks read normalized client state synchronously and use shallow equality, so a
panel does not subscribe to fields it does not render. Command callbacks materialize their snapshots
only when invoked. Contribution discovery and panel opening never fetch active context through plugin
RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cannot mutate normalized
app state or a memoized selection. Panels use one persisted
`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the
current installed-plugin catalog. A missing contribution renders unavailable inside the tab.
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
They use typed plugin RPC only for plugin-specific backend work. Navigation is limited to the
plugin's registered global surfaces and workspace panels; plugins do not receive Expo Router or
workspace-layout store access.
## Contribute composer attachments
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
@@ -207,7 +223,7 @@ const issues = defineAttachmentSource({
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
plugin.addAttachmentSource(issues);
return () => undefined;
return () => {};
}
```
+1 -1
View File
@@ -1 +1 @@
sha256-WblXeNn1wUUaxElDGNXHcWdvvHJhqp7LySanhLDv5lQ=
sha256-HNuEVNenfuV4EfbbhPdn/33/1VHteTsH/L634coB7EU=
+3
View File
@@ -38687,6 +38687,9 @@
"packages/plugin": {
"name": "@paseo/plugin",
"version": "0.3.0",
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.4.0",
"@types/node": "^20.9.0",
@@ -0,0 +1,247 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
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 { addConnectedHostAndReload } from "../support/helpers/hosts";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { buildAgentRoute } from "../support/helpers/mock-agent";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
} from "../support/helpers/sidebar";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
const PLUGIN_ID = "workspace-panel-e2e";
const WIDE_VIEWPORT = { width: 1280, height: 900 };
const COMPACT_VIEWPORT = { width: 390, height: 844 };
function isSettledWorkspaceUrl(url: URL): boolean {
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
}
function pluginSource(): string {
return `import React, { useRef } from "react";
import { Text, View } from "react-native";
import { useAgent, useWorkspace } from "@paseo/plugin";
function WorkspacePanel({ workspaceId, host, layout }) {
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
const renderCount = useRef(0);
renderCount.current += 1;
return <View><Text>Workspace bridge {workspace?.id}</Text><Text>Workspace renders {renderCount.current}</Text><Text>Host {host.id}</Text><Text>Layout {layout.compact ? "compact" : "wide"}</Text></View>;
}
function AgentPanel({ workspaceId, agentId, host, layout }) {
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
const agent = useAgent(agentId, (value) => ({ id: value.id }));
return <View><Text>Agent bridge {agent?.id}</Text><Text>Workspace {workspace?.id}</Text><Text>Host {host.id}</Text><Text>Layout {layout.compact ? "compact" : "wide"}</Text></View>;
}
function DirectCollisionSurface() {
return <View><Text>Direct collision surface</Text></View>;
}
function SidebarCollisionSurface() {
return <View><Text>Sidebar collision surface</Text></View>;
}
export default function contribute(plugin) {
plugin.addSurface("collision", DirectCollisionSurface);
plugin.addSurface("sidebar-destination", SidebarCollisionSurface);
plugin.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
plugin.addWorkspacePanel({ id: "workspace", title: "Workspace inspector", icon: "PanelsTopLeft", context: "workspace", Component: WorkspacePanel });
plugin.addWorkspacePanel({ id: "agent", title: "Agent inspector", icon: "PanelTop", context: "agent", Component: AgentPanel });
plugin.addCommandCenterItem({ id: "global", title: "Plugin global action", icon: "Blocks", context: "global", onSelect() {} });
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"); } });
return () => {};
}`;
}
async function searchCommands(page: Page, query: string) {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(query);
return panel;
}
async function expectCommandAvailable(page: Page, title: string): Promise<void> {
const panel = await searchCommands(page, title);
await expect(panel.getByRole("button", { name: title, exact: true })).toBeVisible();
await page.keyboard.press("Escape");
}
async function expectCommandUnavailable(page: Page, title: string): Promise<void> {
const panel = await searchCommands(page, title);
await expect(panel.getByRole("button", { name: title, exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
}
async function runCommand(page: Page, title: string): Promise<void> {
const panel = await searchCommands(page, title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
async function openCompactSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const screenshot = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: screenshot });
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
}
test.describe("plugin workspace panels and Command Center", () => {
test.describe.configure({ timeout: 240_000 });
test("follows workspace, agent, host, compact, and unavailable state", async ({
page,
}, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-workspace-panel-e2e-"));
const primaryClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await primaryClient.getDaemonConfig();
const primary = await seedWorkspace({ repoPrefix: "plugin-panel-primary-" });
const secondaryDaemon = await startIsolatedHostDaemon("plugin-panel-secondary");
const secondary = await seedWorkspace({
repoPrefix: "plugin-panel-secondary-",
port: secondaryDaemon.port,
});
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
await writeFile(path.join(directory, "index.tsx"), pluginSource());
try {
await primaryClient.patchDaemonConfig({ pluginsEnabled: true });
await primaryClient.installDirectoryPlugin(directory);
await page.setViewportSize(WIDE_VIEWPORT);
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: secondaryDaemon.serverId,
label: "Secondary plugin host",
port: secondaryDaemon.port,
primaryLabel: "Primary plugin host",
});
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: primary.workspaceId,
});
await waitForWorkspaceInSidebar(page, {
serverId: secondaryDaemon.serverId,
workspaceId: secondary.workspaceId,
});
await test.step("workspace context opens the real wide panel bridge", async () => {
await switchWorkspaceViaSidebar({
page,
serverId: getServerId(),
workspaceId: primary.workspaceId,
});
await expectCommandAvailable(page, "Plugin global action");
await expectCommandAvailable(page, "Open plugin workspace");
await expectCommandUnavailable(page, "Open plugin agent");
await runCommand(page, "Open plugin workspace");
await expect(page.getByText(`Workspace bridge ${primary.workspaceId}`)).toBeVisible();
const renderCount = Number(
(await page.getByText(/Workspace renders \d+/).textContent())?.split(" ").at(-1),
);
await primaryClient.setWorkspaceTitle(primary.workspaceId, "Unrelated title update");
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Unrelated title update",
);
expect(
Number((await page.getByText(/Workspace renders \d+/).textContent())?.split(" ").at(-1)),
).toBe(renderCount);
await expect(page.getByText(`Host ${getServerId()}`, { exact: true })).toBeVisible();
await expect(page.getByText("Layout wide", { exact: true })).toBeVisible();
await capture(page, testInfo, "plugin-workspace-panel-wide");
});
await test.step("direct and sidebar routes preserve same-id contribution kind", async () => {
await runCommand(page, "Open direct collision surface");
await expect(page.getByText("Direct collision surface", { exact: true })).toBeVisible();
await expect(page.getByText("Sidebar collision surface", { exact: true })).toHaveCount(0);
await page.getByTestId("plugin-surface-close").click();
await page.getByTestId(`plugin-sidebar-${PLUGIN_ID}-collision`).click();
await expect(page.getByText("Sidebar collision surface", { exact: true })).toBeVisible();
await expect(page.getByText("Direct collision surface", { exact: true })).toHaveCount(0);
await capture(page, testInfo, "plugin-surface-kind-collision");
await page.getByTestId("plugin-surface-close").click();
});
await test.step("switching hosts removes commands from an uninstalled host", async () => {
await switchWorkspaceViaSidebar({
page,
serverId: secondaryDaemon.serverId,
workspaceId: secondary.workspaceId,
});
await expectCommandUnavailable(page, "Plugin global action");
await expectCommandUnavailable(page, "Open plugin workspace");
});
await test.step("legacy sidebar URLs survive a full browser navigation", async () => {
await page.goto(`/h/${encodeURIComponent(getServerId())}/plugin/${PLUGIN_ID}/collision`);
await page.waitForURL(new RegExp(`/plugin/${PLUGIN_ID}/sidebar/collision(?:\\?.*)?$`));
await expect(page.getByText("Sidebar collision surface", { exact: true })).toBeVisible();
});
await test.step("agent context opens the compact panel with synchronous snapshots", async () => {
const agent = await primary.client.createAgent({
provider: "mock",
cwd: primary.repoPath,
workspaceId: primary.workspaceId,
title: "Plugin panel context agent",
model: "ten-second-stream",
modeId: "load-test",
});
await page.goto(buildAgentRoute(primary.workspaceId, agent.id));
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
await page.setViewportSize(COMPACT_VIEWPORT);
await openCompactSidebar(page);
await runCommand(page, "Open plugin agent");
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
await expect(
page.getByText(`Workspace ${primary.workspaceId}`, { exact: true }),
).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 () => {
await primaryClient.removePlugin(PLUGIN_ID);
await expect(
page.getByText("This plugin panel is unavailable.", { exact: true }),
).toBeVisible({
timeout: 30_000,
});
await capture(page, testInfo, "plugin-panel-unavailable");
await openCompactSidebar(page);
await expectCommandUnavailable(page, "Open plugin agent");
});
} finally {
await primaryClient.removePlugin(PLUGIN_ID).catch(() => undefined);
await primaryClient
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
.catch(() => undefined);
await primaryClient.close().catch(() => undefined);
await primary.cleanup().catch(() => undefined);
await secondary.cleanup().catch(() => undefined);
await secondaryDaemon.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
});
@@ -19,6 +19,7 @@ type NewWorkspaceDaemonClient = Pick<
| "fetchWorkspaces"
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "installDirectoryPlugin"
| "inspectWorkspaceRecovery"
| "listProjects"
| "on"
@@ -26,6 +27,7 @@ type NewWorkspaceDaemonClient = Pick<
| "removeProject"
| "removePlugin"
| "reloadPlugin"
| "setWorkspaceTitle"
>;
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
+2
View File
@@ -25,6 +25,7 @@ import { CommandCenter } from "@/command-center/command-center";
import { CommandCenterRootActions } from "@/command-center/root-registration";
import { CommandCenterProvider } from "@/command-center/provider";
import { CommandCenterWorkspaceActions } from "@/command-center/workspace-registration";
import { PluginCommandCenterActions } from "@/plugins/command-center/registration";
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
@@ -590,6 +591,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<WorktreeSetupCalloutSource />
<CommandCenterRootActions />
<CommandCenterWorkspaceActions />
<PluginCommandCenterActions />
<WorkspacePinShortcutHandler />
<CommandCenter />
<AddProjectFlowHost />
@@ -42,6 +42,7 @@ function KnownHostRoute() {
<Stack.Screen name="open-project" />
<Stack.Screen name="settings" />
<Stack.Screen name="plugin/[pluginId]/[surfaceId]" />
<Stack.Screen name="plugin/[pluginId]/[contributionKind]/[contributionId]" />
</ThemedStack>
);
@@ -0,0 +1,5 @@
import { PluginSurfaceScreen } from "@/plugins";
export default function PluginSurfaceRoute() {
return <PluginSurfaceScreen />;
}
@@ -1,5 +1,23 @@
import { PluginSurfaceScreen } from "@/plugins";
import { Redirect, useLocalSearchParams } from "expo-router";
import { buildLegacyPluginSurfaceRedirectRoute } from "@/plugins";
export default function PluginSurfaceRoute() {
return <PluginSurfaceScreen />;
function routeParam(value: string | string[] | undefined): string {
return typeof value === "string" ? value : "";
}
export default function LegacyPluginSurfaceRoute() {
const params = useLocalSearchParams<{
serverId?: string | string[];
pluginId?: string | string[];
surfaceId?: string | string[];
}>();
return (
<Redirect
href={buildLegacyPluginSurfaceRedirectRoute(
routeParam(params.serverId),
routeParam(params.pluginId),
routeParam(params.surfaceId),
)}
/>
);
}
@@ -9,6 +9,7 @@ import { setupPanelRegistration } from "@/panels/setup-panel";
import { terminalPanelRegistration } from "@/panels/terminal-panel";
import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel";
import { pullRequestPanelRegistration } from "@/panels/pull-request-panel";
import { pluginPanelRegistration } from "@/plugins/workspace-panels/panel";
let panelsRegistered = false;
@@ -27,5 +28,6 @@ export function ensurePanelsRegistered(): void {
registerPanel(pullRequestPanelRegistration);
registerPanel(commitDiffPanelRegistration);
registerPanel(workingDiffPanelRegistration);
registerPanel(pluginPanelRegistration);
panelsRegistered = true;
}
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots";
const workspace: WorkspaceDescriptor = {
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo/paseo",
workspaceDirectory: "/repo/paseo/review",
projectKind: "git",
workspaceKind: "worktree",
name: "Review",
title: "Plugin review",
pinnedAt: null,
status: "running",
statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"),
archivingAt: null,
diffStat: { additions: 12, deletions: 3 },
scripts: [],
};
const agent: Agent = {
serverId: "host-1",
id: "agent-1",
provider: "codex",
status: "running",
activeTurn: null,
createdAt: new Date("2026-08-16T10:01:00.000Z"),
updatedAt: new Date("2026-08-16T10:02:00.000Z"),
lastUserMessageAt: null,
lastActivityAt: new Date("2026-08-16T10:03:00.000Z"),
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: "code",
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Implement plugin panels",
cwd: "/repo/paseo/review",
workspaceId: "workspace-1",
model: "gpt-5.6",
thinkingOptionId: "high",
requiresAttention: true,
attentionReason: "permission",
parentAgentId: null,
labels: { phase: "implementation" },
};
describe("plugin context snapshots", () => {
it("commits a deeply immutable normalized workspace snapshot synchronously", () => {
const snapshot = createPluginWorkspaceSnapshot(workspace);
expect(createPluginWorkspaceSnapshot(workspace)).toBe(snapshot);
expect(snapshot).toEqual({
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo/paseo",
directory: "/repo/paseo/review",
projectKind: "git",
kind: "worktree",
name: "Review",
title: "Plugin review",
status: "running",
statusEnteredAt: "2026-08-16T10:00:00.000Z",
archivingAt: null,
diffStat: { additions: 12, deletions: 3 },
});
expect(snapshot.diffStat).not.toBe(workspace.diffStat);
expect(Object.isFrozen(snapshot)).toBe(true);
expect(Object.isFrozen(snapshot.diffStat)).toBe(true);
expect(Reflect.set(snapshot, "name", "Hostile rename")).toBe(false);
expect(Reflect.set(snapshot.diffStat!, "additions", 999)).toBe(false);
expect(snapshot.name).toBe("Review");
expect(snapshot.diffStat).toEqual({ additions: 12, deletions: 3 });
expect(workspace.name).toBe("Review");
expect(workspace.diffStat).toEqual({ additions: 12, deletions: 3 });
});
it("commits a deeply immutable matching agent snapshot without exposing store records", () => {
const snapshot = createPluginAgentSnapshot(agent, workspace.id);
expect(createPluginAgentSnapshot(agent, workspace.id)).toBe(snapshot);
expect(snapshot).toEqual({
id: "agent-1",
workspaceId: "workspace-1",
provider: "codex",
status: "running",
createdAt: "2026-08-16T10:01:00.000Z",
updatedAt: "2026-08-16T10:02:00.000Z",
lastActivityAt: "2026-08-16T10:03:00.000Z",
title: "Implement plugin panels",
cwd: "/repo/paseo/review",
model: "gpt-5.6",
currentModeId: "code",
thinkingOptionId: "high",
requiresAttention: true,
attentionReason: "permission",
parentAgentId: null,
labels: { phase: "implementation" },
});
expect(snapshot.labels).not.toBe(agent.labels);
expect(Object.isFrozen(snapshot)).toBe(true);
expect(Object.isFrozen(snapshot.labels)).toBe(true);
expect(Reflect.set(snapshot, "title", "Hostile rename")).toBe(false);
expect(Reflect.set(snapshot.labels, "phase", "hostile")).toBe(false);
expect(snapshot.title).toBe("Implement plugin panels");
expect(snapshot.labels).toEqual({ phase: "implementation" });
expect(agent.title).toBe("Implement plugin panels");
expect(agent.labels).toEqual({ phase: "implementation" });
});
});
@@ -0,0 +1,57 @@
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@paseo/plugin";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
const workspaceSnapshots = new WeakMap<WorkspaceDescriptor, PluginWorkspaceSnapshot>();
const agentSnapshots = new WeakMap<Agent, Map<string, PluginAgentSnapshot>>();
export function createPluginWorkspaceSnapshot(
workspace: WorkspaceDescriptor,
): PluginWorkspaceSnapshot {
const cached = workspaceSnapshots.get(workspace);
if (cached) return cached;
const diffStat = workspace.diffStat ? Object.freeze({ ...workspace.diffStat }) : null;
const snapshot = Object.freeze({
id: workspace.id,
projectId: workspace.projectId,
projectDisplayName: workspace.projectDisplayName,
projectRootPath: workspace.projectRootPath,
directory: workspace.workspaceDirectory,
projectKind: workspace.projectKind,
kind: workspace.workspaceKind,
name: workspace.name,
title: workspace.title ?? null,
status: workspace.status,
statusEnteredAt: workspace.statusEnteredAt?.toISOString() ?? null,
archivingAt: workspace.archivingAt,
diffStat,
});
workspaceSnapshots.set(workspace, snapshot);
return snapshot;
}
export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): PluginAgentSnapshot {
const cached = agentSnapshots.get(agent)?.get(workspaceId);
if (cached) return cached;
const snapshot = Object.freeze({
id: agent.id,
workspaceId,
provider: agent.provider,
status: agent.status,
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
lastActivityAt: agent.lastActivityAt.toISOString(),
title: agent.title,
cwd: agent.cwd,
model: agent.model,
currentModeId: agent.currentModeId,
thinkingOptionId: agent.thinkingOptionId ?? null,
requiresAttention: agent.requiresAttention ?? false,
attentionReason: agent.attentionReason ?? null,
parentAgentId: agent.parentAgentId,
labels: Object.freeze({ ...agent.labels }),
});
const byWorkspace = agentSnapshots.get(agent) ?? new Map<string, PluginAgentSnapshot>();
byWorkspace.set(workspaceId, snapshot);
agentSnapshots.set(agent, byWorkspace);
return snapshot;
}
@@ -0,0 +1,21 @@
import type { PluginClientStateSource } from "@paseo/plugin/host";
import { useSessionStore } from "@/stores/session-store";
import { selectWorkspace } from "@/stores/session-store-hooks/selectors";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots";
export function createPluginClientStateSource(serverId: string): PluginClientStateSource {
return {
subscribe: useSessionStore.subscribe,
getWorkspace(workspaceId) {
const workspace = selectWorkspace(useSessionStore.getState(), serverId, workspaceId);
return workspace ? createPluginWorkspaceSnapshot(workspace) : null;
},
getAgent(agentId) {
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
const workspaceId = normalizeWorkspaceOpaqueId(agent?.workspaceId);
return agent && workspaceId ? createPluginAgentSnapshot(agent, workspaceId) : null;
},
};
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { normalizeLayout, type WorkspaceLayout } from "@/stores/workspace-layout-store";
import { getFocusedAgentId } from "./context";
function layout(target: import("@/workspace-tabs/model").WorkspaceTabTarget): WorkspaceLayout {
return normalizeLayout({
focusedPaneId: "main",
root: {
kind: "pane",
pane: {
id: "main",
tabIds: ["focused"],
focusedTabId: "focused",
tabs: [{ tabId: "focused", target, createdAt: 1 }],
},
},
});
}
describe("plugin Command Center agent context", () => {
it("uses the focused agent tab", () => {
expect(getFocusedAgentId(layout({ kind: "agent", agentId: "agent-1" }))).toBe("agent-1");
});
it("preserves agent context while its plugin panel is focused", () => {
expect(
getFocusedAgentId(
layout({
kind: "plugin",
pluginId: "review",
panelId: "details",
context: "agent",
agentId: "agent-1",
}),
),
).toBe("agent-1");
});
it("does not invent agent context for workspace panels", () => {
expect(
getFocusedAgentId(
layout({
kind: "plugin",
pluginId: "review",
panelId: "details",
context: "workspace",
}),
),
).toBeNull();
});
});
@@ -0,0 +1,18 @@
import {
collectAllTabs,
findPaneById,
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
export function getFocusedAgentId(layout: WorkspaceLayout | null): string | null {
if (!layout) return null;
const pane = findPaneById(layout.root, layout.focusedPaneId);
if (!pane?.focusedTabId) return null;
const tab = collectAllTabs(layout.root).find(
(candidate) => candidate.tabId === pane.focusedTabId,
);
if (tab?.target.kind === "agent") return tab.target.agentId;
return tab?.target.kind === "plugin" && tab.target.context === "agent"
? tab.target.agentId
: null;
}
@@ -0,0 +1,230 @@
import { QueryClient } from "@tanstack/react-query";
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
defineRpc,
type PluginAgentSnapshot,
type PluginCommandCenterItemContribution,
type PluginWorkspaceSnapshot,
} from "@paseo/plugin";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import type { InstalledPlugin } from "../types";
import { buildPluginCommandCenterContributions } from "./contributions";
const workspace: PluginWorkspaceSnapshot = {
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo/paseo",
directory: "/repo/paseo/review",
projectKind: "git",
kind: "worktree",
name: "Review",
title: null,
status: "running",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
};
const agent: PluginAgentSnapshot = {
id: "agent-1",
workspaceId: workspace.id,
provider: "codex",
status: "running",
createdAt: "2026-08-16T10:00:00.000Z",
updatedAt: "2026-08-16T10:00:00.000Z",
lastActivityAt: "2026-08-16T10:00:00.000Z",
title: "Review",
cwd: workspace.directory,
model: "gpt-5.6",
currentModeId: null,
thinkingOptionId: null,
requiresAttention: false,
attentionReason: null,
parentAgentId: null,
labels: {},
};
const inspect = defineRpc({
name: "review.inspect",
input: z.object({ value: z.number() }),
output: z.object({ value: z.number() }),
});
type AgentCommandItem = Extract<PluginCommandCenterItemContribution, { context: "agent" }>;
function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
return {
id: "review",
serverId: "host-1",
clientBundle: "bundle",
queryClient: new QueryClient(),
cleanup: () => {},
surfaces: [{ id: "main", Component: () => null }],
sidebarItems: [],
workspacePanels: [
{
id: "details",
title: "Details",
icon: "Scan",
context: "agent",
Component: () => null,
},
],
commandCenterItems: [
{
id: "global",
title: "Global review",
icon: "Scan",
context: "global",
onSelect: () => {},
},
{
id: "workspace",
title: "Workspace review",
icon: "Scan",
context: "workspace",
onSelect: () => {},
},
{
id: "agent",
title: "Agent review",
icon: "Scan",
context: "agent",
onSelect: onAgentSelect,
},
],
attachmentSources: [],
};
}
function stateSource() {
return {
subscribe() {
return () => {};
},
getWorkspace(id: string) {
return id === workspace.id ? workspace : null;
},
getAgent(id: string) {
return id === agent.id ? agent : null;
},
};
}
function createRuntime(pluginId: string) {
const client = new DaemonClient({
url: "ws://127.0.0.1:1",
clientId: "plugin-command-test",
clientType: "cli",
});
return {
paseo: createPaseoApi(client),
invoke: async (method: string, input: unknown) => {
expect(pluginId).toBe("review");
expect(method).toBe("review.inspect");
return { value: inspect.input.parse(input).value + 1 };
},
};
}
describe("plugin Command Center contributions", () => {
it("shows only contributions whose synchronous context exists", () => {
const installed = plugin(() => undefined);
const common = {
plugins: [installed],
runtime: createRuntime,
state: stateSource(),
navigation: {
openSurface() {},
openWorkspacePanel() {},
openAgentPanel() {},
},
reportError() {},
};
expect(
buildPluginCommandCenterContributions({
...common,
workspaceId: null,
agentId: null,
}).map((item) => item.id),
).toEqual(["review:global"]);
expect(
buildPluginCommandCenterContributions({
...common,
workspaceId: workspace.id,
agentId: null,
}).map((item) => item.id),
).toEqual(["review:global", "review:workspace"]);
expect(
buildPluginCommandCenterContributions({
...common,
workspaceId: workspace.id,
agentId: agent.id,
}).map((item) => item.id),
).toEqual(["review:global", "review:workspace", "review:agent"]);
});
it("supplies the direct API, typed RPC, snapshots, and narrow navigation", async () => {
const opened: string[] = [];
let rpcValue = 0;
let receivedPaseo: PaseoApi | null = null;
const installed = plugin(async (context) => {
expect(context.workspace).toBe(workspace);
expect(context.agent).toBe(agent);
receivedPaseo = context.paseo;
rpcValue = (await context.rpc(inspect, { value: 4 })).value;
context.openSurface("main");
context.openPanel("details");
});
const runtime = createRuntime("review");
const actions = buildPluginCommandCenterContributions({
plugins: [installed],
runtime: () => runtime,
state: stateSource(),
workspaceId: workspace.id,
agentId: agent.id,
navigation: {
openSurface(pluginId, surfaceId) {
opened.push(`${pluginId}/surface/${surfaceId}`);
},
openWorkspacePanel(pluginId, panelId) {
opened.push(`${pluginId}/workspace/${panelId}`);
},
openAgentPanel(pluginId, panelId, agentId) {
opened.push(`${pluginId}/agent/${panelId}/${agentId}`);
},
},
reportError(error) {
throw error;
},
});
await actions.find((action) => action.id === "review:agent")?.run();
expect(rpcValue).toBe(5);
expect(receivedPaseo).toBe(runtime.paseo);
expect(opened).toEqual(["review/surface/main", "review/agent/details/agent-1"]);
});
it("removes every contribution when its installation disappears", () => {
expect(
buildPluginCommandCenterContributions({
plugins: [],
runtime: createRuntime,
state: stateSource(),
workspaceId: workspace.id,
agentId: agent.id,
navigation: {
openSurface() {},
openWorkspacePanel() {},
openAgentPanel() {},
},
reportError() {},
}),
).toEqual([]);
});
});
@@ -0,0 +1,117 @@
import { callPluginRpc } from "@paseo/plugin/host";
import type { PluginCommandCapabilities } from "@paseo/plugin";
import type { PluginClientStateSource } from "@paseo/plugin/host";
import type { CommandCenterContribution } from "@/command-center/contributions";
import { getCommandCenterIcon } from "@/command-center/icon";
import { resolvePluginIcon } from "../icons";
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): void;
openAgentPanel(pluginId: string, panelId: string, agentId: string): void;
}
export interface PluginCommandCenterSource {
plugins: readonly InstalledPlugin[];
runtime(pluginId: string): PluginSurfaceRuntime;
state: PluginClientStateSource;
workspaceId: string | null;
agentId: string | null;
navigation: PluginCommandCenterNavigation;
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);
for (const [rank, item] of plugin.commandCenterItems.entries()) {
if (item.context === "workspace" && !source.workspaceId) continue;
if (item.context === "agent" && (!source.workspaceId || !source.agentId)) continue;
const run = async () => {
try {
if (item.context === "global") {
await item.onSelect({ context: "global", ...common });
return;
}
const workspace = source.workspaceId
? source.state.getWorkspace(source.workspaceId)
: null;
if (!workspace) return;
if (item.context === "workspace") {
await item.onSelect({
context: "workspace",
...common,
workspace,
openPanel(panelId) {
const panel = plugin.workspacePanels.find(
(candidate) => candidate.id === panelId && candidate.context === "workspace",
);
if (!panel) throw new Error(`Workspace panel is unavailable: ${panelId}`);
source.navigation.openWorkspacePanel(plugin.id, panelId);
},
});
return;
}
const agent = source.agentId ? source.state.getAgent(source.agentId) : null;
if (!agent) return;
await item.onSelect({
context: "agent",
...common,
workspace,
agent,
openPanel(panelId) {
const panel = plugin.workspacePanels.find((candidate) => candidate.id === panelId);
if (!panel) throw new Error(`Workspace panel is unavailable: ${panelId}`);
if (panel.context === "workspace") {
source.navigation.openWorkspacePanel(plugin.id, panelId);
return;
}
source.navigation.openAgentPanel(plugin.id, panelId, agent.id);
},
});
} catch (error) {
source.reportError(error);
}
};
contributions.push({
id: `${plugin.id}:${item.id}`,
group: `plugin:${plugin.id}`,
groupRank: 5,
rank,
keywords: item.keywords ?? [],
visibility: "always",
presentation: {
kind: "action",
title: item.title,
sectionTitle: plugin.id,
icon: getCommandCenterIcon(resolvePluginIcon(item.icon)),
},
run,
});
}
}
return contributions;
}
@@ -0,0 +1,111 @@
import { router, 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 { useSessionStore } from "@/stores/session-store";
import { useWorkspaceExists } from "@/stores/session-store-hooks";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { createPluginClientStateSource } from "../client-state/source";
import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes";
import { useInstalledPlugins } from "../registry";
import { createPluginSurfaceRuntime } from "../surface-runtime";
import { buildPluginCommandCenterContributions } from "./contributions";
import { getFocusedAgentId } from "./context";
export function PluginCommandCenterActions() {
const pathname = usePathname();
const selection = useActiveWorkspaceSelection();
const serverId = selection?.serverId ?? hostIdFromPathname(pathname);
const workspaceId = selection?.workspaceId ?? null;
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const workspaceKey = serverId && workspaceId ? `${serverId}:${workspaceId}` : null;
const layout = useWorkspaceLayoutStore((state) =>
workspaceKey ? (state.layoutByWorkspace[workspaceKey] ?? null) : null,
);
const focusedAgentId = getFocusedAgentId(layout);
const agentExists = useSessionStore((state) => {
if (!serverId || !focusedAgentId) return null;
const session = state.sessions[serverId];
const agent =
session?.agents.get(focusedAgentId) ?? session?.agentDetails.get(focusedAgentId) ?? null;
return (
Boolean(agent) &&
Boolean(workspaceId) &&
normalizeWorkspaceOpaqueId(agent?.workspaceId) === normalizeWorkspaceOpaqueId(workspaceId)
);
});
const client = useHostRuntimeClient(serverId ?? "");
const installed = useInstalledPlugins();
const plugins = useMemo(
() => installed.filter((plugin) => plugin.serverId === serverId),
[installed, serverId],
);
const stateSource = useMemo(
() => (serverId ? createPluginClientStateSource(serverId) : null),
[serverId],
);
const toast = useToast();
const actions = useMemo(() => {
if (!client || !serverId || !stateSource) return [];
return buildPluginCommandCenterContributions({
plugins,
runtime(pluginId) {
const runtime = createPluginSurfaceRuntime(client, pluginId);
if (!runtime) throw new Error("Plugin host is offline");
return runtime;
},
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) {
if (!workspaceId) throw new Error("No active workspace");
navigateToWorkspace({
serverId,
workspaceId,
target: { kind: "plugin", pluginId, panelId, context: "workspace" },
});
},
openAgentPanel(pluginId, panelId, agentId) {
if (!workspaceId) throw new Error("No active workspace");
navigateToWorkspace({
serverId,
workspaceId,
target: { kind: "plugin", pluginId, panelId, context: "agent", agentId },
});
},
},
reportError(error) {
toast.error(error instanceof Error ? error.message : String(error));
},
});
}, [
agentExists,
client,
focusedAgentId,
plugins,
serverId,
stateSource,
toast,
workspaceExists,
workspaceId,
]);
useCommandCenterActions({
sourceId: serverId ? `plugins:${serverId}` : "plugins",
enabled: Boolean(client && plugins.length > 0),
actions,
});
return null;
}
+65
View File
@@ -54,6 +54,71 @@ describe("evaluatePluginClientBundle", () => {
]);
});
it("collects contextual workspace panels and Command Center items", () => {
const plugin = evaluatePluginClientBundle(
"review",
bundle(`
function ReviewPanel() { return null; }
plugin.addWorkspacePanel({
id: "review",
title: "Review",
icon: "Scan",
context: "agent",
Component: ReviewPanel,
});
plugin.addCommandCenterItem({
id: "open-review",
title: "Open review",
icon: "Scan",
context: "agent",
onSelect() {},
});
`),
);
expect(
plugin.workspacePanels.map(({ id, title, icon, context }) => ({
id,
title,
icon,
context,
})),
).toEqual([{ id: "review", title: "Review", icon: "Scan", context: "agent" }]);
expect(
plugin.commandCenterItems.map(({ id, title, icon, context }) => ({
id,
title,
icon,
context,
})),
).toEqual([{ id: "open-review", title: "Open review", icon: "Scan", context: "agent" }]);
});
it("rejects duplicate workspace panel and Command Center ids", () => {
expect(() =>
evaluatePluginClientBundle(
"review",
bundle(`
function Panel() { return null; }
const panel = { id: "review", title: "Review", icon: "Scan", context: "workspace", Component: Panel };
plugin.addWorkspacePanel(panel);
plugin.addWorkspacePanel(panel);
`),
),
).toThrow("Duplicate workspace panel: review");
expect(() =>
evaluatePluginClientBundle(
"review",
bundle(`
const item = { id: "review", title: "Review", icon: "Scan", context: "global", onSelect() {} };
plugin.addCommandCenterItem(item);
plugin.addCommandCenterItem(item);
`),
),
).toThrow("Duplicate Command Center item: review");
});
it("rejects duplicate attachment source ids", () => {
expect(() =>
evaluatePluginClientBundle(
+68 -1
View File
@@ -8,9 +8,13 @@ import {
defineAttachmentSource,
defineRpc,
type PluginAttachmentSourceContribution,
type PluginCommandCenterItemContribution,
type PluginSidebarContribution,
type PluginSurfaceProps,
type PluginWorkspacePanelContribution,
usePaseo,
useAgent,
useWorkspace,
useRpc,
} from "@paseo/plugin";
import { createPluginContext, type PluginRegistrationCollector } from "@paseo/plugin/host";
@@ -30,10 +34,14 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
const collector: PluginRegistrationCollector = {
surfaces: [],
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
attachmentSources: [],
};
const surfaceIds = new Set<string>();
const sidebarItemIds = new Set<string>();
const workspacePanelIds = new Set<string>();
const commandCenterItemIds = new Set<string>();
const attachmentSourceIds = new Set<string>();
const pluginContext = createPluginContext({
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
@@ -59,6 +67,54 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
surface: requireId(contribution.surface, "sidebar surface id"),
});
},
addWorkspacePanel(contribution: PluginWorkspacePanelContribution) {
const normalizedId = requireId(contribution.id, "workspace panel id");
if (workspacePanelIds.has(normalizedId)) {
throw new Error(`Duplicate workspace panel: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`);
if (!icon) throw new Error(`Workspace panel ${normalizedId} has no icon`);
if (contribution.context !== "workspace" && contribution.context !== "agent") {
throw new Error(`Workspace panel ${normalizedId} has invalid context`);
}
if (typeof contribution.Component !== "function") {
throw new Error(`Workspace panel ${normalizedId} is not a component`);
}
resolvePluginIcon(icon);
workspacePanelIds.add(normalizedId);
collector.workspacePanels.push({ ...contribution, id: normalizedId, title, icon });
},
addCommandCenterItem(contribution: PluginCommandCenterItemContribution) {
const normalizedId = requireId(contribution.id, "Command Center item id");
if (commandCenterItemIds.has(normalizedId)) {
throw new Error(`Duplicate Command Center item: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
if (!title) throw new Error(`Command Center item ${normalizedId} has no title`);
if (!icon) throw new Error(`Command Center item ${normalizedId} has no icon`);
if (
contribution.context !== "global" &&
contribution.context !== "workspace" &&
contribution.context !== "agent"
) {
throw new Error(`Command Center item ${normalizedId} has invalid context`);
}
if (typeof contribution.onSelect !== "function") {
throw new Error(`Command Center item ${normalizedId} has no callback`);
}
resolvePluginIcon(icon);
commandCenterItemIds.add(normalizedId);
collector.commandCenterItems.push({
...contribution,
id: normalizedId,
title,
icon,
keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean),
});
},
addAttachmentSource(contribution: PluginAttachmentSourceContribution) {
const normalizedId = requireId(contribution.id, "attachment source id");
if (attachmentSourceIds.has(normalizedId)) {
@@ -92,7 +148,16 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
if (name === "react") return React;
if (name === "react/jsx-runtime") return ReactJsxRuntime;
if (name === "react-native") return ReactNative;
if (name === "@paseo/plugin") return { defineAttachmentSource, defineRpc, usePaseo, useRpc };
if (name === "@paseo/plugin") {
return {
defineAttachmentSource,
defineRpc,
usePaseo,
useAgent,
useWorkspace,
useRpc,
};
}
if (name === "@tanstack/react-query") return ReactQuery;
if (name === "zod") return Zod;
throw new Error(`Module "${name}" is not available in plugin client code`);
@@ -133,6 +198,8 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
cleanup,
surfaces: collector.surfaces,
sidebarItems: collector.sidebarItems,
workspacePanels: collector.workspacePanels,
commandCenterItems: collector.commandCenterItems,
attachmentSources: collector.attachmentSources,
};
}
+1
View File
@@ -1,4 +1,5 @@
export { PluginCatalogSync } from "./catalog-sync";
export { buildLegacyPluginSurfaceRedirectRoute } from "./routes";
export { PluginSidebarItems } from "./sidebar-items";
export { PluginSurfaceScreen } from "./surface-screen";
export { usePluginAttachmentPicker } from "./attachments/picker";
@@ -8,6 +8,8 @@ function bundle(marker: string): string {
function Surface() { return ${JSON.stringify(marker)}; }
plugin.addSurface("main", Surface);
plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "main" });
plugin.addWorkspacePanel({ id: "review", title: "Review", icon: "Blocks", context: "workspace", Component: Surface });
plugin.addCommandCenterItem({ id: "open-review", title: "Open review", icon: "Blocks", context: "workspace", onSelect() {} });
return function() { globalThis.__pluginCleanups = (globalThis.__pluginCleanups || 0) + 1; };
};
return module.exports;
@@ -85,6 +87,8 @@ describe("PluginRegistry", () => {
const second = pluginRegistry.getSnapshot()[0];
expect(second).not.toBe(first);
expect(second?.workspacePanels.map((panel) => panel.id)).toEqual(["review"]);
expect(second?.commandCenterItems.map((item) => item.id)).toEqual(["open-review"]);
expect(first?.queryClient.getQueryData(["attachment-search"])).toBeUndefined();
expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1);
});
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { buildLegacyPluginSurfaceRedirectRoute, buildPluginSurfaceRoute } from "./routes";
describe("buildPluginSurfaceRoute", () => {
it("keeps direct surfaces and sidebar contributions in separate route namespaces", () => {
expect(buildPluginSurfaceRoute("host/one", "review", { kind: "surface", id: "overview" })).toBe(
"/h/host%2Fone/plugin/review/surface/overview",
);
expect(buildPluginSurfaceRoute("host/one", "review", { kind: "sidebar", id: "overview" })).toBe(
"/h/host%2Fone/plugin/review/sidebar/overview",
);
});
it("redirects legacy plugin surface URLs to their sidebar contribution identity", () => {
expect(buildLegacyPluginSurfaceRedirectRoute("host/one", "review", "overview/item")).toBe(
"/h/host%2Fone/plugin/review/sidebar/overview%2Fitem",
);
});
});
+22 -4
View File
@@ -1,9 +1,27 @@
export function buildPluginSurfaceRoute(
import type { PluginSurfaceContributionIdentity } from "./surface-contribution";
type PluginSurfaceRoute<Kind extends PluginSurfaceContributionIdentity["kind"]> =
`/h/${string}/plugin/${string}/${Kind}/${string}`;
export function buildPluginSurfaceRoute<Identity extends PluginSurfaceContributionIdentity>(
serverId: string,
pluginId: string,
contributionId: string,
): `/h/${string}/plugin/${string}/${string}` {
return `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${encodeURIComponent(contributionId)}`;
identity: Identity,
): PluginSurfaceRoute<Identity["kind"]> {
return `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${identity.kind}/${encodeURIComponent(identity.id)}` as PluginSurfaceRoute<
Identity["kind"]
>;
}
export function buildLegacyPluginSurfaceRedirectRoute(
serverId: string,
pluginId: string,
sidebarContributionId: string,
): `/h/${string}/plugin/${string}/sidebar/${string}` {
return buildPluginSurfaceRoute(serverId, pluginId, {
kind: "sidebar",
id: sidebarContributionId,
});
}
export function hostIdFromPathname(pathname: string): string | null {
@@ -0,0 +1,23 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { PaseoApiProvider, PluginRpcProvider } from "@paseo/plugin/host";
import type { ReactNode } from "react";
import type { InstalledPlugin } from "./types";
import type { PluginSurfaceRuntime } from "./surface-runtime";
export function PluginRuntimeBoundary({
plugin,
runtime,
children,
}: {
plugin: InstalledPlugin;
runtime: PluginSurfaceRuntime;
children: ReactNode;
}) {
return (
<QueryClientProvider client={plugin.queryClient}>
<PaseoApiProvider paseo={runtime.paseo}>
<PluginRpcProvider invoke={runtime.invoke}>{children}</PluginRpcProvider>
</PaseoApiProvider>
</QueryClientProvider>
);
}
@@ -19,6 +19,8 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin {
surface: "surface",
},
],
workspacePanels: [],
commandCenterItems: [],
attachmentSources: [],
};
}
@@ -40,6 +42,9 @@ describe("groupPluginSidebarContributions", () => {
installed("host-b", "settings"),
]);
expect(groups.map((group) => group.key)).toEqual(["example/main", "example/settings"]);
expect(groups.map((group) => group.key)).toEqual([
"example/sidebar/main",
"example/sidebar/settings",
]);
});
});
+1 -1
View File
@@ -20,7 +20,7 @@ export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): Plu
const groups = new Map<string, PluginSidebarGroup>();
for (const plugin of plugins) {
for (const item of plugin.sidebarItems) {
const key = `${plugin.id}/${item.id}`;
const key = `${plugin.id}/sidebar/${item.id}`;
const existing = groups.get(key);
if (existing) {
existing.targets.push({ plugin, item });
+8 -6
View File
@@ -52,15 +52,17 @@ function PluginSidebarItemRow({
onBeforeNavigate?: () => void;
}) {
const target = selectTarget(group, currentHostId);
const route = buildPluginSurfaceRoute(
target.plugin.serverId,
group.pluginId,
group.contributionId,
);
const route = buildPluginSurfaceRoute(target.plugin.serverId, group.pluginId, {
kind: "sidebar",
id: group.contributionId,
});
const isActive = group.targets.some(
(candidate) =>
pathname ===
buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, group.contributionId),
buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, {
kind: "sidebar",
id: group.contributionId,
}),
);
const navigate = useCallback(() => {
rememberPluginContributionHost(group.key, target.plugin.serverId);
@@ -0,0 +1,79 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { InstalledPlugin } from "./types";
import {
getPluginSurfaceContributionServerIds,
resolvePluginSurfaceContribution,
} from "./surface-contribution";
function installation(
serverId: string,
surfaces: string[],
sidebarItems: Array<{ id: string; surface: string }> = [],
pluginId = "review",
): InstalledPlugin {
return {
id: pluginId,
serverId,
clientBundle: serverId,
queryClient: new QueryClient(),
cleanup: () => undefined,
surfaces: surfaces.map((id) => ({ id, Component: () => null })),
sidebarItems: sidebarItems.map((item) => ({
...item,
title: item.id,
icon: "Blocks",
})),
workspacePanels: [],
commandCenterItems: [],
attachmentSources: [],
};
}
describe("plugin surface contribution identity", () => {
const installations = [
installation(
"host-v1",
["overview", "surface-v1"],
[{ id: "overview", surface: "surface-v1" }],
),
installation(
"host-v2",
["overview", "surface-v2"],
[{ id: "overview", surface: "surface-v2" }],
),
installation("same-surface", ["overview"]),
installation("other-contribution", ["surface-v1"], [{ id: "other", surface: "surface-v1" }]),
installation(
"other-plugin",
["overview", "surface-v1"],
[{ id: "overview", surface: "surface-v1" }],
"other",
),
];
it("resolves a sidebar contribution by its explicit identity across host version drift", () => {
const identity = { kind: "sidebar", id: "overview" } as const;
const resolved = resolvePluginSurfaceContribution(installations[0] ?? null, identity);
expect(resolved.sidebarItem?.id).toBe("overview");
expect(resolved.surface?.id).toBe("surface-v1");
expect(getPluginSurfaceContributionServerIds(installations, "review", identity)).toEqual([
"host-v1",
"host-v2",
]);
});
it("does not let a same-id sidebar contribution capture a direct surface", () => {
const identity = { kind: "surface", id: "overview" } as const;
const resolved = resolvePluginSurfaceContribution(installations[0] ?? null, identity);
expect(resolved.sidebarItem).toBeNull();
expect(resolved.surface?.id).toBe("overview");
expect(getPluginSurfaceContributionServerIds(installations, "review", identity)).toEqual([
"host-v1",
"host-v2",
"same-surface",
]);
});
});
@@ -0,0 +1,39 @@
import type { InstalledPlugin } from "./types";
export type PluginSurfaceContributionIdentity =
| { kind: "sidebar"; id: string }
| { kind: "surface"; id: string };
export function resolvePluginSurfaceContribution(
plugin: InstalledPlugin | null,
identity: PluginSurfaceContributionIdentity | null,
): {
sidebarItem: InstalledPlugin["sidebarItems"][number] | null;
surface: InstalledPlugin["surfaces"][number] | null;
} {
if (!identity) return { sidebarItem: null, surface: null };
const sidebarItem =
identity.kind === "sidebar"
? (plugin?.sidebarItems.find((contribution) => contribution.id === identity.id) ?? null)
: null;
const surfaceId = identity.kind === "sidebar" ? sidebarItem?.surface : identity.id;
const surface = surfaceId
? (plugin?.surfaces.find((contribution) => contribution.id === surfaceId) ?? null)
: null;
return { sidebarItem, surface };
}
export function getPluginSurfaceContributionServerIds(
installations: readonly InstalledPlugin[],
pluginId: string,
identity: PluginSurfaceContributionIdentity,
): string[] {
return installations
.filter((installation) => {
if (installation.id !== pluginId) return false;
return identity.kind === "sidebar"
? installation.sidebarItems.some((contribution) => contribution.id === identity.id)
: installation.surfaces.some((contribution) => contribution.id === identity.id);
})
.map((installation) => installation.serverId);
}
+42 -39
View File
@@ -1,9 +1,5 @@
import { router, useLocalSearchParams } from "expo-router";
import { QueryClientProvider } from "@tanstack/react-query";
import type { PluginSurfaceProps } from "@paseo/plugin";
import { PluginRpcProvider } from "@paseo/plugin/host";
import { PaseoApiProvider } from "@paseo/plugin/host";
import type { PaseoApi } from "@getpaseo/client";
import { ChevronDown, X } from "lucide-react-native";
import { useCallback, useMemo, useRef, useState, type ComponentType } from "react";
import { Platform, Pressable, Text, View } from "react-native";
@@ -23,6 +19,12 @@ import { buildPluginSurfaceRoute } from "./routes";
import { rememberPluginContributionHost } from "./sidebar-groups";
import { SurfaceErrorBoundary } from "./surface-error-boundary";
import { createPluginSurfaceRuntime } from "./surface-runtime";
import { PluginRuntimeBoundary } from "./runtime-boundary";
import {
getPluginSurfaceContributionServerIds,
resolvePluginSurfaceContribution,
type PluginSurfaceContributionIdentity,
} from "./surface-contribution";
const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = [];
const EMPTY_THEME_DTO: Record<string, unknown> = {};
@@ -51,29 +53,23 @@ const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon);
function SurfaceRenderer({
Surface,
invoke,
paseo,
runtime,
plugin,
layout,
host,
themeDto = EMPTY_THEME_DTO,
}: {
Surface: ComponentType<PluginSurfaceProps>;
invoke(method: string, input: unknown): Promise<unknown>;
paseo: PaseoApi;
runtime: NonNullable<ReturnType<typeof createPluginSurfaceRuntime>>;
plugin: NonNullable<ReturnType<typeof useInstalledPlugin>>;
layout: PluginSurfaceProps["layout"];
host: PluginSurfaceProps["host"];
themeDto?: Record<string, unknown>;
}) {
return (
<QueryClientProvider client={plugin.queryClient}>
<PaseoApiProvider paseo={paseo}>
<PluginRpcProvider invoke={invoke}>
<Surface theme={themeDto} host={host} layout={layout} />
</PluginRpcProvider>
</PaseoApiProvider>
</QueryClientProvider>
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
<Surface theme={themeDto} host={host} layout={layout} />
</PluginRuntimeBoundary>
);
}
@@ -88,12 +84,12 @@ function resolvePlatform(): PluginSurfaceProps["layout"]["platform"] {
function PluginHostSwitcher({
serverId,
pluginId,
contributionId,
identity,
serverIds,
}: {
serverId: string;
pluginId: string;
contributionId: string;
identity: PluginSurfaceContributionIdentity;
serverIds: string[];
}) {
const allHosts = useHosts();
@@ -106,10 +102,10 @@ function PluginHostSwitcher({
const selectedLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const selectHost = useCallback(
(nextServerId: string) => {
rememberPluginContributionHost(`${pluginId}/${contributionId}`, nextServerId);
router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, contributionId));
rememberPluginContributionHost(`${pluginId}/${identity.kind}/${identity.id}`, nextServerId);
router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, identity));
},
[contributionId, pluginId],
[identity, pluginId],
);
const openPicker = useCallback(() => setOpen(true), []);
const show = serverIds.length > 1 && hosts.length > 1;
@@ -148,28 +144,34 @@ export function PluginSurfaceScreen() {
const params = useLocalSearchParams<{
serverId?: string | string[];
pluginId?: string | string[];
surfaceId?: string | string[];
contributionKind?: string | string[];
contributionId?: string | string[];
}>();
const serverId = routeParam(params.serverId);
const pluginId = routeParam(params.pluginId);
const contributionId = routeParam(params.surfaceId);
const contributionKind = routeParam(params.contributionKind);
const contributionId = routeParam(params.contributionId);
const identity = useMemo<PluginSurfaceContributionIdentity | null>(() => {
if (contributionKind !== "sidebar" && contributionKind !== "surface") return null;
return { kind: contributionKind, id: contributionId };
}, [contributionId, contributionKind]);
const plugin = useInstalledPlugin(serverId, pluginId);
const installations = usePluginInstallations(pluginId);
const hosts = useHosts();
const client = useHostRuntimeClient(serverId);
const runtime = useMemo(() => createPluginSurfaceRuntime(client, pluginId), [client, pluginId]);
const compact = useIsCompactFormFactor();
const sidebarItem = plugin?.sidebarItems.find((entry) => entry.id === contributionId) ?? null;
const surface = plugin?.surfaces.find((entry) => entry.id === sidebarItem?.surface) ?? null;
const { sidebarItem, surface } = useMemo(
() => resolvePluginSurfaceContribution(plugin, identity),
[identity, plugin],
);
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const contributionServerIds = useMemo(
() =>
installations
.filter((entry) => entry.sidebarItems.some((item) => item.id === contributionId))
.map((entry) => entry.serverId),
[contributionId, installations],
identity ? getPluginSurfaceContributionServerIds(installations, pluginId, identity) : [],
[identity, installations, pluginId],
);
const title = sidebarItem?.title ?? (pluginId || "Plugin");
const title = sidebarItem?.title ?? surface?.id ?? (pluginId || "Plugin");
const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null;
const close = useCallback(() => {
if (router.canGoBack()) router.back();
@@ -193,12 +195,14 @@ export function PluginSurfaceScreen() {
const headerRight = useMemo(
() => (
<>
<PluginHostSwitcher
serverId={serverId}
pluginId={pluginId}
contributionId={contributionId}
serverIds={contributionServerIds}
/>
{identity ? (
<PluginHostSwitcher
serverId={serverId}
pluginId={pluginId}
identity={identity}
serverIds={contributionServerIds}
/>
) : null}
<HeaderToggleButton
accessibilityLabel="Close plugin"
onPress={close}
@@ -211,7 +215,7 @@ export function PluginSurfaceScreen() {
</HeaderToggleButton>
</>
),
[close, contributionId, contributionServerIds, pluginId, serverId],
[close, contributionServerIds, identity, pluginId, serverId],
);
return (
@@ -220,14 +224,13 @@ export function PluginSurfaceScreen() {
<View style={styles.body}>
{plugin && surface && runtime ? (
<SurfaceErrorBoundary
key={`${serverId}/${pluginId}/${contributionId}`}
key={`${serverId}/${pluginId}/${identity?.kind}/${contributionId}`}
installation={plugin}
Surface={surface.Component}
>
<ThemedSurfaceRenderer
Surface={surface.Component}
invoke={runtime.invoke}
paseo={runtime.paseo}
runtime={runtime}
plugin={plugin}
host={host}
layout={layout}
+6
View File
@@ -1,8 +1,10 @@
import type { QueryClient } from "@tanstack/react-query";
import type {
PluginAttachmentSourceContribution,
PluginCommandCenterItemContribution,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginWorkspacePanelContribution,
} from "@paseo/plugin";
export interface EvaluatedPlugin {
@@ -10,6 +12,8 @@ export interface EvaluatedPlugin {
cleanup: () => void;
surfaces: PluginSurfaceContribution[];
sidebarItems: PluginSidebarContribution[];
workspacePanels: PluginWorkspacePanelContribution[];
commandCenterItems: PluginCommandCenterItemContribution[];
attachmentSources: PluginAttachmentSourceContribution[];
}
@@ -21,6 +25,8 @@ export interface InstalledPlugin extends EvaluatedPlugin {
export type {
PluginAttachmentSourceContribution,
PluginCommandCenterItemContribution,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginWorkspacePanelContribution,
};
@@ -0,0 +1,178 @@
import type {
PluginAgentPanelProps,
PluginHostProps,
PluginWorkspacePanelProps,
} from "@paseo/plugin";
import { PluginClientStateProvider } from "@paseo/plugin/host";
import { CircleAlert } from "lucide-react-native";
import { useMemo } from "react";
import { Platform, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceExists } from "@/stores/session-store-hooks";
import type { Theme } from "@/styles/theme";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { createPluginClientStateSource } from "../client-state/source";
import { resolvePluginIcon } from "../icons";
import { useInstalledPlugin } from "../registry";
import { PluginRuntimeBoundary } from "../runtime-boundary";
import { createPluginSurfaceRuntime } from "../surface-runtime";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
import { resolvePluginWorkspacePanel } from "./resolution";
const EMPTY_THEME_DTO: Record<string, unknown> = {};
const pluginThemeMapping = (theme: Theme) => ({
themeDto: JSON.parse(JSON.stringify(theme)) as Record<string, unknown>,
});
function resolvePlatform(): PluginHostProps["layout"]["platform"] {
if (Platform.OS === "ios") return "ios";
if (Platform.OS === "android") return "android";
return "web";
}
function PluginPanelBody({ themeDto = EMPTY_THEME_DTO }: { themeDto?: Record<string, unknown> }) {
const { serverId, workspaceId, target } = usePaneContext();
invariant(target.kind === "plugin", "PluginPanel requires plugin target");
const plugin = useInstalledPlugin(serverId, target.pluginId);
const contribution = resolvePluginWorkspacePanel(plugin, target);
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const agentExists = useSessionStore((state) => {
if (target.context !== "agent") return null;
const session = state.sessions[serverId];
const agent = session?.agents.get(target.agentId) ?? session?.agentDetails.get(target.agentId);
return (
Boolean(agent) &&
normalizeWorkspaceOpaqueId(agent?.workspaceId) === normalizeWorkspaceOpaqueId(workspaceId)
);
});
const client = useHostRuntimeClient(serverId);
const runtime = useMemo(
() => createPluginSurfaceRuntime(client, target.pluginId),
[client, target.pluginId],
);
const compact = useIsCompactFormFactor();
const hosts = useHosts();
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]);
const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]);
const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]);
if (!plugin || !contribution || !workspaceExists) {
return <PluginPanelUnavailable />;
}
if (!runtime) {
return <PluginPanelUnavailable message="Plugin host is offline." />;
}
let panel;
let Surface: unknown;
if (contribution.context === "workspace") {
const props: PluginWorkspacePanelProps = {
context: "workspace",
theme: themeDto,
host,
layout,
workspaceId,
};
const Component = contribution.Component;
Surface = Component;
panel = <Component {...props} />;
} else if (agentExists && target.context === "agent") {
const props: PluginAgentPanelProps = {
context: "agent",
theme: themeDto,
host,
layout,
workspaceId,
agentId: target.agentId,
};
const Component = contribution.Component;
Surface = Component;
panel = <Component {...props} />;
} else {
return <PluginPanelUnavailable />;
}
return (
<SurfaceErrorBoundary
installation={plugin}
Surface={Surface}
key={`${serverId}/${target.pluginId}/${target.panelId}/${target.context}`}
>
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
<PluginClientStateProvider source={stateSource}>{panel}</PluginClientStateProvider>
</PluginRuntimeBoundary>
</SurfaceErrorBoundary>
);
}
const ThemedPluginPanelBody = withUnistyles(PluginPanelBody);
function PluginPanel() {
return <ThemedPluginPanelBody uniProps={pluginThemeMapping} />;
}
function PluginPanelUnavailable({
message = "This plugin panel is unavailable.",
}: {
message?: string;
}) {
return (
<View style={styles.unavailable}>
<Text style={styles.unavailableText}>{message}</Text>
</View>
);
}
function usePluginPanelDescriptor(
target: Extract<import("@/workspace-tabs/model").WorkspaceTabTarget, { kind: "plugin" }>,
context: { serverId: string },
): PanelDescriptor {
const plugin = useInstalledPlugin(context.serverId, target.pluginId);
const panel = plugin?.workspacePanels.find(
(contribution) => contribution.id === target.panelId && contribution.context === target.context,
);
if (!panel) {
return {
label: "Plugin unavailable",
subtitle: target.pluginId,
tooltip: "This plugin panel is unavailable",
titleState: "ready",
icon: CircleAlert,
statusBucket: null,
};
}
return {
label: panel.title,
subtitle: target.pluginId,
tooltip: panel.title,
titleState: "ready",
icon: resolvePluginIcon(panel.icon),
statusBucket: null,
};
}
export const pluginPanelRegistration: PanelRegistration<"plugin"> = {
kind: "plugin",
component: PluginPanel,
useDescriptor: usePluginPanelDescriptor,
};
const styles = StyleSheet.create((theme) => ({
unavailable: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
backgroundColor: theme.colors.surface0,
},
unavailableText: {
color: theme.colors.foregroundMuted,
},
}));
@@ -0,0 +1,61 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { InstalledPlugin } from "../types";
import { resolvePluginWorkspacePanel } from "./resolution";
function installed(): InstalledPlugin {
return {
id: "review",
serverId: "host-1",
clientBundle: "bundle",
queryClient: new QueryClient(),
cleanup: () => {},
surfaces: [],
sidebarItems: [],
workspacePanels: [
{
id: "details",
title: "Details",
icon: "Scan",
context: "workspace",
Component: () => null,
},
],
commandCenterItems: [],
attachmentSources: [],
};
}
describe("plugin workspace panel resolution", () => {
it("resolves only the exact plugin, panel, and context", () => {
const plugin = installed();
expect(
resolvePluginWorkspacePanel(plugin, {
kind: "plugin",
pluginId: "review",
panelId: "details",
context: "workspace",
})?.title,
).toBe("Details");
expect(
resolvePluginWorkspacePanel(plugin, {
kind: "plugin",
pluginId: "review",
panelId: "details",
context: "agent",
agentId: "agent-1",
}),
).toBeNull();
});
it("returns unavailable after the installation disappears", () => {
expect(
resolvePluginWorkspacePanel(null, {
kind: "plugin",
pluginId: "review",
panelId: "details",
context: "workspace",
}),
).toBeNull();
});
});
@@ -0,0 +1,14 @@
import type { PluginWorkspacePanelContribution } from "@paseo/plugin";
import type { InstalledPlugin } from "../types";
import type { PluginWorkspaceTabTarget } from "@/workspace-tabs/model";
export function resolvePluginWorkspacePanel(
plugin: InstalledPlugin | null,
target: PluginWorkspaceTabTarget,
): PluginWorkspacePanelContribution | null {
return (
plugin?.workspacePanels.find(
(panel) => panel.id === target.panelId && panel.context === target.context,
) ?? null
);
}
@@ -421,6 +421,9 @@ function getFallbackTabOptionDescription(
if (tab.target.kind === "pull_request") {
return labels.pullRequest;
}
if (tab.target.kind === "plugin") {
return tab.target.panelId;
}
return tab.target.path;
}
@@ -154,6 +154,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "files" || tab.target.kind === "pull_request") {
return `workspace-${tab.target.kind}-close`;
}
if (tab.target.kind === "plugin") {
return `workspace-plugin-close-${encodeFilePathForPathSegment(buildDeterministicWorkspaceTabId(tab.target))}`;
}
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
}
@@ -483,6 +483,57 @@ describe("workspace-layout-store actions", () => {
expect(findPaneById(layout.root, explorerPane?.paneId)?.focusedTabId).toBe(tabId);
});
it("restores workspace and agent plugin panel targets", async () => {
const workspaceTarget = {
kind: "plugin",
pluginId: "review",
panelId: "summary",
context: "workspace",
};
const agentTarget = {
kind: "plugin",
pluginId: "review",
panelId: "details",
context: "agent",
agentId: "agent-1",
};
await AsyncStorage.setItem(
"workspace-layout-state",
JSON.stringify({
state: {
layoutByWorkspace: {
workspace: {
root: {
kind: "pane",
pane: {
id: "main",
tabIds: ["workspace-panel", "agent-panel"],
focusedTabId: "agent-panel",
tabs: [
{ tabId: "workspace-panel", target: workspaceTarget, createdAt: 1 },
{ tabId: "agent-panel", target: agentTarget, createdAt: 2 },
],
},
},
focusedPaneId: "main",
},
},
splitSizesByWorkspace: {},
},
version: 1,
}),
);
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
await restored.persist.rehydrate();
const layout = restored.getState().layoutByWorkspace.workspace;
expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([
workspaceTarget,
agentTarget,
]);
});
it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
const workspaceKey = createWorkspaceKey();
@@ -183,6 +183,21 @@ const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
}),
z.strictObject({ kind: z.literal("setup"), workspaceId: z.string() }),
z.strictObject({ kind: z.literal("commit_diff"), sha: z.string() }),
z.discriminatedUnion("context", [
z.strictObject({
kind: z.literal("plugin"),
pluginId: z.string(),
panelId: z.string(),
context: z.literal("workspace"),
}),
z.strictObject({
kind: z.literal("plugin"),
pluginId: z.string(),
panelId: z.string(),
context: z.literal("agent"),
agentId: z.string(),
}),
]),
]);
const WorkspaceTabStorageSchema = z.strictObject({
tabId: z.string(),
@@ -145,3 +145,42 @@ describe("commit diff tab identity", () => {
).toBeNull();
});
});
describe("plugin panel tab identity", () => {
it("normalizes exact workspace and agent context", () => {
expect(
normalizeWorkspaceTabTarget({
kind: "plugin",
pluginId: " review ",
panelId: " details ",
context: "agent",
agentId: " agent-1 ",
}),
).toEqual({
kind: "plugin",
pluginId: "review",
panelId: "details",
context: "agent",
agentId: "agent-1",
});
});
it("gives workspace and agent instances distinct stable ids", () => {
const workspace = buildDeterministicWorkspaceTabId({
kind: "plugin",
pluginId: "review",
panelId: "details",
context: "workspace",
});
const agent = buildDeterministicWorkspaceTabId({
kind: "plugin",
pluginId: "review",
panelId: "details",
context: "agent",
agentId: "agent-1",
});
expect(workspace).toBe("plugin_workspace_6_review_7_details");
expect(agent).toBe("plugin_agent_6_review_7_details_7_agent-1");
});
});
@@ -32,6 +32,9 @@ export function normalizeWorkspaceTabTarget(
if (value.kind === "working_diff") {
return normalizeWorkingDiffTabTarget(value);
}
if (value.kind === "plugin") {
return normalizePluginTabTarget(value);
}
return normalizeSimpleWorkspaceTabTarget(value);
}
@@ -108,6 +111,15 @@ export function workspaceTabTargetsEqual(
if (left.kind === "terminal" && right.kind === "terminal") {
return left.terminalId === right.terminalId;
}
if (left.kind === "plugin" && right.kind === "plugin") {
return (
left.pluginId === right.pluginId &&
left.panelId === right.panelId &&
left.context === right.context &&
(left.context === "workspace" ||
(right.context === "agent" && left.agentId === right.agentId))
);
}
return secondaryWorkspaceTabTargetsEqual(left, right);
}
@@ -200,9 +212,28 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
if (target.kind === "files" || target.kind === "pull_request") {
return target.kind;
}
if (target.kind === "plugin") {
const identity = `${target.pluginId.length}_${target.pluginId}_${target.panelId.length}_${target.panelId}`;
return target.context === "workspace"
? `plugin_workspace_${identity}`
: `plugin_agent_${identity}_${target.agentId.length}_${target.agentId}`;
}
return `file_${target.path}`;
}
function normalizePluginTabTarget(
value: Extract<WorkspaceTabTarget, { kind: "plugin" }>,
): WorkspaceTabTarget | null {
const pluginId = trimNonEmpty(value.pluginId);
const panelId = trimNonEmpty(value.panelId);
if (!pluginId || !panelId) return null;
if (value.context === "workspace") {
return { kind: "plugin", pluginId, panelId, context: "workspace" };
}
const agentId = trimNonEmpty(value.agentId);
return agentId ? { kind: "plugin", pluginId, panelId, context: "agent", agentId } : null;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
+16
View File
@@ -16,6 +16,21 @@ export interface WorkspaceWorkingDiffTabTarget {
focusRequestId?: number;
}
export type PluginWorkspaceTabTarget =
| {
kind: "plugin";
pluginId: string;
panelId: string;
context: "workspace";
}
| {
kind: "plugin";
pluginId: string;
panelId: string;
context: "agent";
agentId: string;
};
export type WorkspaceTabTarget =
| { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
| { kind: "agent"; agentId: string }
@@ -26,6 +41,7 @@ export type WorkspaceTabTarget =
| { kind: "pull_request" }
| WorkspaceFileTabTarget
| WorkspaceWorkingDiffTabTarget
| PluginWorkspaceTabTarget
| { kind: "setup"; workspaceId: string }
| { kind: "commit_diff"; sha: string };
@@ -71,6 +71,7 @@ export const MoreVertical = StubIcon;
export const MousePointer2 = StubIcon;
export const PackagePlus = StubIcon;
export const PanelLeft = StubIcon;
export const PanelsTopLeft = StubIcon;
export const Paperclip = StubIcon;
export const Pencil = StubIcon;
export const PencilRuler = StubIcon;
@@ -83,6 +84,7 @@ export const QrCode = StubIcon;
export const RefreshCcw = StubIcon;
export const RefreshCw = StubIcon;
export const RotateCw = StubIcon;
export const Scan = StubIcon;
export const Search = StubIcon;
export const Server = StubIcon;
export const Settings = StubIcon;
@@ -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 () => {};
}
`;
+21
View File
@@ -375,6 +375,27 @@ test("workspace handles keep identity and refresh snapshots through existing dri
expect(updates).toEqual(["sdk pushed"]);
expect(workspace.current()).toEqual(pushedWorkspace);
const titlePromise = workspace.setTitle("SDK review", "workspace-title-request");
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "workspace.title.set.request",
requestId: "workspace-title-request",
workspaceId: "workspace_sdk",
title: "SDK review",
});
ws.message(
sessionMessage({
type: "workspace.title.set.response",
payload: {
requestId: "workspace-title-request",
workspaceId: "workspace_sdk",
accepted: true,
title: "SDK review",
error: null,
},
}),
);
await expect(titlePromise).resolves.toEqual({ title: "SDK review" });
unsubscribe();
ws.message(
sessionMessage({
+2
View File
@@ -132,6 +132,7 @@ export interface PaseoWorkspaceHandle {
};
current(): PaseoWorkspace | null;
refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>;
archive(requestId?: string): Promise<PaseoWorkspaceArchiveResult>;
/**
* Subscribes to already-emitted daemon workspace_update events for this id.
@@ -526,6 +527,7 @@ function createWorkspaceHandleFactory(
},
current: () => current,
refresh,
setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId),
archive: async (requestId) => {
const result = await daemonClient.archiveWorkspace(id, requestId);
if (current) {
+3
View File
@@ -27,6 +27,9 @@
"typecheck": "tsgo --noEmit && tsgo -p tsconfig.examples.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.4.0",
"@types/node": "^20.9.0",
+62
View File
@@ -0,0 +1,62 @@
import { createContext, useContext, type ReactNode } from "react";
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector";
import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "./contracts.js";
import { shallowEqual } from "./shallow.js";
export interface PluginClientStateSource {
subscribe(listener: () => void): () => void;
getWorkspace(id: string): PluginWorkspaceSnapshot | null;
getAgent(id: string): PluginAgentSnapshot | null;
}
const PluginClientStateContext = createContext<PluginClientStateSource | null>(null);
function usePluginEntity<Entity, Selection>(input: {
id: string;
read(source: PluginClientStateSource, id: string): Entity | null;
selector(entity: Entity): Selection;
}): Selection | null {
const source = useContext(PluginClientStateContext);
if (!source) throw new Error("Plugin state hooks must run inside a workspace panel");
return useSyncExternalStoreWithSelector(
source.subscribe,
() => input.read(source, input.id),
() => input.read(source, input.id),
(entity) => (entity ? input.selector(entity) : null),
shallowEqual,
);
}
export function useWorkspace<Selection>(
workspaceId: string,
selector: (workspace: PluginWorkspaceSnapshot) => Selection,
): Selection | null {
return usePluginEntity({
id: workspaceId,
read: (source, id) => source.getWorkspace(id),
selector,
});
}
export function useAgent<Selection>(
agentId: string,
selector: (agent: PluginAgentSnapshot) => Selection,
): Selection | null {
return usePluginEntity({
id: agentId,
read: (source, id) => source.getAgent(id),
selector,
});
}
export function PluginClientStateProvider({
children,
source,
}: {
children: ReactNode;
source: PluginClientStateSource;
}) {
return (
<PluginClientStateContext.Provider value={source}>{children}</PluginClientStateContext.Provider>
);
}
+114 -1
View File
@@ -3,7 +3,7 @@ import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginRpcContract } from "./rpc.js";
export interface PluginSurfaceProps {
export interface PluginHostProps {
theme: Record<string, unknown>;
host: {
id: string;
@@ -15,6 +15,70 @@ export interface PluginSurfaceProps {
};
}
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>>;
}
interface PluginWorkspacePanelBase {
id: string;
title: string;
icon: string;
}
export interface PluginWorkspacePanelProps extends PluginHostProps {
context: "workspace";
workspaceId: string;
}
export interface PluginAgentPanelProps extends PluginHostProps {
context: "agent";
workspaceId: string;
agentId: string;
}
export type PluginWorkspacePanelContribution =
| (PluginWorkspacePanelBase & {
context: "workspace";
Component: ComponentType<PluginWorkspacePanelProps>;
})
| (PluginWorkspacePanelBase & {
context: "agent";
Component: ComponentType<PluginAgentPanelProps>;
});
export interface PluginSurfaceContribution {
id: string;
Component: ComponentType<PluginSurfaceProps>;
@@ -36,6 +100,53 @@ export interface PluginAttachmentSourceContribution {
search: PluginRpcContract;
}
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;
}
interface PluginCommandCenterItemBase {
id: string;
title: string;
icon: string;
keywords?: readonly string[];
}
export type PluginCommandCenterItemContribution =
| (PluginCommandCenterItemBase & {
context: "global";
onSelect(context: PluginGlobalCommandContext): void | Promise<void>;
})
| (PluginCommandCenterItemBase & {
context: "workspace";
onSelect(context: PluginWorkspaceCommandContext): void | Promise<void>;
})
| (PluginCommandCenterItemBase & {
context: "agent";
onSelect(context: PluginAgentCommandContext): void | Promise<void>;
});
export interface PluginHandlerContext {
paseo: PaseoApi;
}
@@ -50,6 +161,8 @@ export interface PluginContext {
): void;
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
addSidebarItem(contribution: PluginSidebarContribution): void;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
}
+21 -1
View File
@@ -1,10 +1,13 @@
import { PluginAttachmentSearchPayloadSchema } from "./attachments.js";
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
import type {
PluginAttachmentSourceContribution,
PluginCommandCenterItemContribution,
PluginContext,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSurfaceProps,
PluginWorkspacePanelContribution,
} from "./contracts.js";
import { PluginRpcProvider } from "./rpc-context.js";
import { PaseoApiProvider } from "./paseo-context.js";
@@ -14,18 +17,29 @@ import type { ComponentType } from "react";
interface PluginCollector {
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
addSidebarItem(contribution: PluginSidebarContribution): void;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
}
export interface PluginRegistrationCollector {
surfaces: PluginSurfaceContribution[];
sidebarItems: PluginSidebarContribution[];
workspacePanels: PluginWorkspacePanelContribution[];
commandCenterItems: PluginCommandCenterItemContribution[];
attachmentSources: PluginAttachmentSourceContribution[];
}
export function createPluginContext(
collector: PluginCollector,
): Pick<PluginContext, "addSurface" | "addSidebarItem" | "addAttachmentSource"> {
): Pick<
PluginContext,
| "addSurface"
| "addSidebarItem"
| "addWorkspacePanel"
| "addCommandCenterItem"
| "addAttachmentSource"
> {
return {
addSurface(id, Component) {
collector.addSurface(id, Component);
@@ -33,6 +47,12 @@ export function createPluginContext(
addSidebarItem(contribution) {
collector.addSidebarItem(contribution);
},
addWorkspacePanel(contribution) {
collector.addWorkspacePanel(contribution);
},
addCommandCenterItem(contribution) {
collector.addCommandCenterItem(contribution);
},
addAttachmentSource(contribution) {
collector.addAttachmentSource(contribution);
},
+12
View File
@@ -6,15 +6,27 @@ export {
} from "./attachments.js";
export type {
PluginAttachmentSourceContribution,
PluginAgentCommandContext,
PluginAgentPanelProps,
PluginAgentSnapshot,
PluginCleanup,
PluginCommandCapabilities,
PluginCommandCenterItemContribution,
PluginContribution,
PluginContext,
PluginGlobalCommandContext,
PluginHandlerContext,
PluginHostProps,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSurfaceProps,
PluginWorkspaceCommandContext,
PluginWorkspacePanelContribution,
PluginWorkspacePanelProps,
PluginWorkspaceSnapshot,
} from "./contracts.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
import type { PluginAttachmentSourceContribution } from "./contracts.js";
export { useRpc } from "./rpc-context.js";
export { defineRpc, type PluginRpcContract } from "./rpc.js";
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { shallowEqual } from "./shallow.js";
describe("plugin selector shallow equality", () => {
it("keeps equivalent selector objects and arrays stable", () => {
const shared = { id: "shared" };
expect(shallowEqual({ name: "Paseo", shared }, { name: "Paseo", shared })).toBe(true);
expect(shallowEqual(["Paseo", shared], ["Paseo", shared])).toBe(true);
expect(shallowEqual({ name: "Paseo" }, { name: "Changed" })).toBe(false);
});
it("compares map and set selections by their shallow entries", () => {
expect(shallowEqual(new Map([["status", "running"]]), new Map([["status", "running"]]))).toBe(
true,
);
expect(shallowEqual(new Set(["workspace", "agent"]), new Set(["workspace", "agent"]))).toBe(
true,
);
expect(shallowEqual(new Set(["workspace"]), new Set(["agent"]))).toBe(false);
});
});
+47
View File
@@ -0,0 +1,47 @@
function isIterable(value: object): value is Iterable<unknown> {
return Symbol.iterator in value;
}
function hasEntries(value: object): value is { entries(): Iterable<[unknown, unknown]> } {
return "entries" in value;
}
function compareEntries(
left: { entries(): Iterable<[unknown, unknown]> },
right: { entries(): Iterable<[unknown, unknown]> },
): boolean {
const leftEntries = left instanceof Map ? left : new Map(left.entries());
const rightEntries = right instanceof Map ? right : new Map(right.entries());
if (leftEntries.size !== rightEntries.size) return false;
for (const [key, value] of leftEntries) {
if (!rightEntries.has(key) || !Object.is(value, rightEntries.get(key))) return false;
}
return true;
}
function compareIterables(left: Iterable<unknown>, right: Iterable<unknown>): boolean {
const leftIterator = left[Symbol.iterator]();
const rightIterator = right[Symbol.iterator]();
let leftValue = leftIterator.next();
let rightValue = rightIterator.next();
while (!leftValue.done && !rightValue.done) {
if (!Object.is(leftValue.value, rightValue.value)) return false;
leftValue = leftIterator.next();
rightValue = rightIterator.next();
}
return Boolean(leftValue.done && rightValue.done);
}
export function shallowEqual(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
if (Object.getPrototypeOf(left) !== Object.getPrototypeOf(right)) return false;
if (isIterable(left) && isIterable(right)) {
if (hasEntries(left) && hasEntries(right)) return compareEntries(left, right);
return compareIterables(left, right);
}
return compareEntries(
{ entries: () => Object.entries(left) },
{ entries: () => Object.entries(right) },
);
}
+9
View File
@@ -0,0 +1,9 @@
declare module "use-sync-external-store/shim/with-selector" {
export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot: () => Snapshot,
selector: (snapshot: Snapshot) => Selection,
isEqual?: (left: Selection, right: Selection) => boolean,
): Selection;
}
@@ -7,7 +7,13 @@ type PluginBuildTarget = "client" | "server";
const REGISTRATIONS_REMOVED_BY_TARGET: Record<PluginBuildTarget, ReadonlySet<string>> = {
client: new Set(["handle"]),
server: new Set(["addSurface", "addSidebarItem", "addAttachmentSource"]),
server: new Set([
"addSurface",
"addSidebarItem",
"addWorkspacePanel",
"addCommandCenterItem",
"addAttachmentSource",
]),
};
function registrationName(statement: unknown, contextName: string): string | null {
@@ -467,6 +467,10 @@ function HelloSurface() {
return <Text>Hello from native UI</Text>;
}
function ReviewPanel() {
return <Text>Workspace review panel</Text>;
}
export default function contribute(plugin: any) {
plugin.handle(greetRpc, async (input: { name: string }) => ({
message: "Hello, " + input.name,
@@ -474,6 +478,8 @@ export default function contribute(plugin: any) {
}));
plugin.addSurface("main", HelloSurface);
plugin.addSidebarItem({ id: "hello", title: "Hello", icon: "Sparkles", surface: "main" });
plugin.addWorkspacePanel({ id: "review", title: "Review", icon: "Scan", context: "workspace", Component: ReviewPanel });
plugin.addCommandCenterItem({ id: "open-review", title: "Open review", icon: "Scan", context: "workspace", onSelect() {} });
plugin.addAttachmentSource(attachments);
return () => undefined;
}`,
@@ -487,6 +493,8 @@ export default function contribute(plugin: any) {
expect(catalog[0]?.id).toBe("hello");
expect(catalog[0]?.clientBundle).toContain("Hello from native UI");
expect(catalog[0]?.clientBundle).toContain("Attach example issue");
expect(catalog[0]?.clientBundle).toContain("Workspace review panel");
expect(catalog[0]?.clientBundle).toContain("Open review");
expect(catalog[0]?.clientBundle).not.toContain("node:os");
expect(catalog[0]?.clientBundle).not.toContain("get: () => from[key]");
await expect(runtime.invoke("hello", "greet", { name: "Paseo" })).resolves.toMatchObject({
+1 -1
View File
@@ -42,5 +42,5 @@ export default function contribute(plugin: PluginContext) {
return linear.search(query);
});
plugin.addAttachmentSource(issueAttachments);
return () => undefined;
return () => {};
}
+26 -9
View File
@@ -2,7 +2,13 @@ import React, { useCallback, useMemo } from "react";
import { Pressable, Text, View } from "react-native";
import { useMutation } from "@tanstack/react-query";
import { z } from "zod";
import { defineRpc, type PluginContext, type PluginSurfaceProps, useRpc } from "@paseo/plugin";
import {
defineRpc,
type PluginContext,
type PluginWorkspacePanelProps,
useWorkspace,
useRpc,
} from "@paseo/plugin";
const incrementRpc = defineRpc({
name: "increment",
@@ -21,8 +27,9 @@ const ExampleThemeSchema = z.object({
}),
});
function ExampleSurface({ theme }: PluginSurfaceProps) {
function ExamplePanel({ theme, workspaceId }: PluginWorkspacePanelProps) {
const { colors } = ExampleThemeSchema.parse(theme);
const workspace = useWorkspace(workspaceId, ({ name }) => ({ name }));
const callIncrement = useRpc(incrementRpc);
const { data, error, isPending, mutate } = useMutation({ mutationFn: callIncrement });
const value = data?.value ?? 0;
@@ -43,7 +50,8 @@ function ExampleSurface({ theme }: PluginSurfaceProps) {
return (
<View style={styles.screen}>
<Text style={styles.title}>Native plugin surface</Text>
<Text style={styles.title}>Workspace plugin panel</Text>
<Text style={styles.detail}>{workspace?.name}</Text>
<Text style={styles.detail}>{data?.handledBy ?? "The RPC has not run yet."}</Text>
<Pressable
accessibilityRole="button"
@@ -65,12 +73,21 @@ export default function contribute(plugin: PluginContext) {
value: input.value + 1,
handledBy: "plugin subprocess",
}));
plugin.addSurface("main", ExampleSurface);
plugin.addSidebarItem({
id: "main",
title: "Local plugin",
plugin.addWorkspacePanel({
id: "counter",
title: "Plugin counter",
icon: "Blocks",
surface: "main",
context: "workspace",
Component: ExamplePanel,
});
return () => undefined;
plugin.addCommandCenterItem({
id: "open-counter",
title: "Open plugin counter",
icon: "Blocks",
context: "workspace",
onSelect({ openPanel }) {
openPanel("counter");
},
});
return () => {};
}
+36 -33
View File
@@ -1,6 +1,6 @@
---
title: Plugin quickstart
description: Build, install, and reload a trusted local Paseo plugin with a native sidebar surface.
description: Build, install, and reload a trusted local Paseo plugin with a workspace panel.
nav: Quickstart
order: 45
category: Plugins
@@ -8,7 +8,7 @@ category: Plugins
# Plugin quickstart
Paseo plugins add native surfaces, sidebar items, daemon behavior, and composer attachment sources. They run on every Paseo client connected to the host, including mobile.
Paseo plugins add native workspace panels, Command Center items, global surfaces, 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.
@@ -27,8 +27,8 @@ Enabling starts configured plugins; disabling tears them down. Automation must i
Use an absolute path on the daemon machine:
```bash
paseo plugin init /absolute/path/to/counter-plugin
cd /absolute/path/to/counter-plugin
paseo plugin init /absolute/path/to/workspace-plugin
cd /absolute/path/to/workspace-plugin
npm install
```
@@ -37,51 +37,54 @@ npm install
Replace `index.tsx` with:
```tsx
import type { PluginContext } from "@paseo/plugin";
import React, { useState } from "react";
import { Pressable, Text, View } from "react-native";
function Counter() {
const [count, setCount] = useState(0);
import { type PluginContext, type PluginWorkspacePanelProps, useWorkspace } from "@paseo/plugin";
import { Text, View } from "react-native";
function WorkspaceOverview({ workspaceId }: PluginWorkspacePanelProps) {
const workspace = useWorkspace(workspaceId, ({ name, directory }) => ({
name,
directory,
}));
return (
<View style={{ flex: 1, padding: 24, gap: 16 }}>
<Text style={{ fontSize: 48 }}>{count}</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Increment counter, currently ${count}`}
onPress={() => setCount((value) => value + 1)}
style={{ padding: 16, borderRadius: 12, backgroundColor: "#238253" }}
>
<Text style={{ color: "white", textAlign: "center" }}>Count me in</Text>
</Pressable>
<View style={{ flex: 1, padding: 24, gap: 8 }}>
<Text style={{ fontSize: 24 }}>{workspace?.name}</Text>
<Text>{workspace?.directory}</Text>
</View>
);
}
export default function contribute(plugin: PluginContext) {
plugin.addSurface("main", Counter);
plugin.addSidebarItem({
id: "main",
title: "Counter",
icon: "ListPlus",
surface: "main",
plugin.addWorkspacePanel({
id: "overview",
title: "Workspace overview",
icon: "PanelsTopLeft",
context: "workspace",
Component: WorkspaceOverview,
});
return () => undefined;
plugin.addCommandCenterItem({
id: "open-overview",
title: "Open workspace overview",
icon: "PanelsTopLeft",
context: "workspace",
onSelect({ openPanel }) {
openPanel("overview");
},
});
return () => {};
}
```
The icon is a [Lucide](https://lucide.dev/icons/) icon name. The surface uses React Native primitives, so the same contribution works in the desktop, browser, iOS, and Android clients.
The icon is a [Lucide](https://lucide.dev/icons/) icon name. The panel uses React Native primitives, so the same contribution works in the desktop, browser, iOS, and Android clients. Panel props contain stable IDs; `useWorkspace` selects the cached fields the component needs without fetching through RPC or re-rendering for unrelated workspace changes.
## Check and install it
```bash
npm run typecheck
paseo plugin install /absolute/path/to/counter-plugin
paseo plugin install /absolute/path/to/workspace-plugin
paseo plugin ls
```
Open **Counter** in the Paseo sidebar. If the item does not appear, confirm that **Enable plugins** is on, the plugin status is `running` in `paseo plugin ls`, and the client is viewing the host where you installed it.
Open a workspace, open the Command Center, and choose **Open workspace overview**. It opens as a normal workspace tab. If the item does not appear, confirm that **Enable plugins** is on, the plugin status is `running` in `paseo plugin ls`, and the client is viewing the host where you installed it.
## Edit and reload
@@ -89,7 +92,7 @@ Source changes are explicit:
```bash
npm run typecheck
paseo plugin reload counter-plugin
paseo plugin reload workspace-plugin
```
A reload stops the old plugin, runs its cleanup, compiles the current source, and starts it again. A failed reload stays failed and reports its load error; fix the source and reload again.
@@ -106,8 +109,8 @@ console.error("Issue refresh failed", error);
Read recent stdout and stderr from **Settings → Plugins → Logs** or the CLI:
```bash
paseo plugin logs counter-plugin
paseo plugin logs counter-plugin --json
paseo plugin logs workspace-plugin
paseo plugin logs workspace-plugin --json
```
The log tail survives reloads and crashes, so inspect it when a plugin fails to start or an RPC
+156 -5
View File
@@ -11,6 +11,8 @@ category: Plugins
Local plugins are directory sources installed into one Paseo daemon. A plugin can contribute:
- React Native surfaces and sidebar items to Paseo clients;
- workspace and agent panels opened as workspace tabs;
- global, workspace, and agent actions in the Command Center;
- schema-validated RPC handlers running beside the daemon;
- normal Paseo operations through the TypeScript SDK;
- searchable external resources in the message composer.
@@ -36,7 +38,7 @@ The manifest contains the default plugin ID:
{ "id": "my-plugin" }
```
Plugin, surface, sidebar-item, and attachment-source IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens.
Plugin, surface, sidebar-item, workspace-panel, Command Center item, and attachment-source IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens.
The generated declaration file supplies `@paseo/plugin` types for local typechecking. Paseo supplies the runtime modules. Regenerate a fresh project with the matching CLI when the plugin contract changes.
@@ -49,7 +51,7 @@ import type { PluginContext } from "@paseo/plugin";
export default function contribute(plugin: PluginContext) {
// Register contributions here.
return () => undefined;
return () => {};
}
```
@@ -75,7 +77,7 @@ export default function contribute(plugin: PluginContext) {
icon: "Blocks",
surface: "main",
});
return () => undefined;
return () => {};
}
```
@@ -91,6 +93,149 @@ Paseo owns the route, header, close action, host picker, error boundary, and que
Client code can import `react`, `react-native`, `@tanstack/react-query`, `zod`, and `@paseo/plugin`. Install them locally for typechecking; Paseo provides the client runtime instances.
## Workspace panels
Register one panel for workspace or agent context:
```tsx
import {
type PluginContext,
type PluginAgentPanelProps,
useAgent,
useWorkspace,
} from "@paseo/plugin";
import { Text } from "react-native";
function ReviewPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name);
const agent = useAgent(agentId, ({ id, title }) => ({ id, title }));
return <Text>{`${workspaceName} · ${agent?.title ?? agent?.id}`}</Text>;
}
export default function contribute(plugin: PluginContext) {
plugin.addWorkspacePanel({
id: "review",
title: "Review",
icon: "Scan",
context: "agent",
Component: ReviewPanel,
});
return () => {};
}
```
`addWorkspacePanel` fields:
| Field | Required | Meaning |
| ----------- | -------- | ------------------------------------------------------------- |
| `id` | Yes | Plugin-local panel ID. |
| `title` | Yes | Workspace-tab title. |
| `icon` | Yes | Lucide icon name. |
| `context` | Yes | `workspace` or `agent`. |
| `Component` | Yes | React Native component matching the selected context's props. |
A workspace panel receives `PluginWorkspacePanelProps`: `context: "workspace"`, `theme`, `host`, `layout`, and `workspaceId`. An agent panel receives `PluginAgentPanelProps`: `context: "agent"`, the same common fields and `workspaceId`, plus `agentId`.
Read cached state with `useWorkspace(workspaceId, selector)` and `useAgent(agentId, selector)`. A selector is required. Paseo compares its result shallowly, so selecting `{ name, status }` does not re-render when unrelated fields change. Select every field the component renders in one call; do not select the whole snapshot.
Both hooks return `null` when the record is unavailable. Otherwise they run synchronously against normalized client state. Snapshot DTOs and their nested values are deeply readonly and frozen at runtime. Do not call plugin RPC to discover the current workspace or agent. Fetch optional or vendor-specific enrichment after the component renders.
Workspace snapshot fields:
| Field | Type |
| -------------------- | ----------------------------------------------------------------- |
| `id` | `string` |
| `projectId` | `string` |
| `projectDisplayName` | `string` |
| `projectRootPath` | `string` |
| `directory` | `string` |
| `projectKind` | `"git" \| "non_git" \| "directory"` |
| `kind` | `"directory" \| "local_checkout" \| "checkout" \| "worktree"` |
| `name` | `string` |
| `title` | `string \| null` |
| `status` | `"needs_input" \| "failed" \| "running" \| "attention" \| "done"` |
| `statusEnteredAt` | ISO timestamp or `null` |
| `archivingAt` | ISO timestamp or `null` |
| `diffStat` | `{ additions: number; deletions: number } \| null` |
Agent snapshot fields:
| Field | Type |
| ------------------- | -------------------------------------------------------------- |
| `id` | `string` |
| `workspaceId` | `string` |
| `provider` | `string` |
| `status` | `"initializing" \| "idle" \| "running" \| "error" \| "closed"` |
| `createdAt` | ISO timestamp |
| `updatedAt` | ISO timestamp |
| `lastActivityAt` | ISO timestamp |
| `title` | `string \| null` |
| `cwd` | `string` |
| `model` | `string \| null` |
| `currentModeId` | `string \| null` |
| `thinkingOptionId` | `string \| null` |
| `requiresAttention` | `boolean` |
| `attentionReason` | `"finished" \| "error" \| "permission" \| null` |
| `parentAgentId` | `string \| null` |
| `labels` | `Record<string, string>` |
Paseo owns tab focus, splitting, closing, persistence, query state, the API/RPC providers, and the render error boundary. A restored tab whose plugin, panel, context, workspace, or agent is unavailable stays open with an unavailable message instead of crashing the workspace.
## Command Center items
Register an action and open a panel from the callback:
```tsx
import { defineRpc } from "@paseo/plugin";
import { z } from "zod";
const refreshReview = defineRpc({
name: "review.refresh",
input: z.object({ agentId: z.string() }),
output: z.object({ refreshed: z.boolean() }),
});
plugin.addCommandCenterItem({
id: "open-review",
title: "Open review",
icon: "Scan",
keywords: ["inspect"],
context: "agent",
async onSelect({ paseo, rpc, workspace, agent, openPanel }) {
await paseo.workspaces.ref(workspace.id).setTitle(`Review ${agent.id}`);
await rpc(refreshReview, { agentId: agent.id });
openPanel("review");
},
});
```
`addCommandCenterItem` fields:
| Field | Required | Meaning |
| ---------- | -------- | ---------------------------------------------- |
| `id` | Yes | Plugin-local item ID. |
| `title` | Yes | Search result title. |
| `icon` | Yes | Lucide icon name. |
| `keywords` | No | Additional Command Center search terms. |
| `context` | Yes | `global`, `workspace`, or `agent`. |
| `onSelect` | Yes | Client-side callback for the matching context. |
Global items appear on the installation's selected host. Workspace items appear only when that host has an active cached workspace. Agent items appear only when the focused workspace tab is an agent or an agent-context plugin panel whose cached record belongs to that workspace. Missing context removes the item rather than calling the plugin to discover it.
Every callback receives:
| Field | Context | Meaning |
| ---------------------- | ------------------- | ------------------------------------------------------------- |
| `context` | All | Matching discriminator. |
| `paseo` | All | Selected host's existing `PaseoApi`. |
| `rpc(contract, input)` | All | Typed call to this installation's daemon-side plugin handler. |
| `openSurface(id)` | All | Opens one of this plugin's registered global surfaces. |
| `workspace` | Workspace and agent | Synchronous workspace snapshot. |
| `agent` | Agent | Synchronous matching agent snapshot. |
| `openPanel(id)` | Workspace and agent | Opens a registered panel in the callback's current context. |
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.
## 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.
@@ -156,7 +301,7 @@ export default function contribute(plugin: PluginContext) {
return { message: `${name}: plugins are ${config.pluginsEnabled ? "on" : "off"}` };
});
plugin.addSurface("main", GreetingButton);
return () => undefined;
return () => {};
}
```
@@ -236,7 +381,7 @@ const issues = defineAttachmentSource({
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
plugin.addAttachmentSource(issues);
return () => undefined;
return () => {};
}
```
@@ -248,6 +393,12 @@ Plugins are installed per daemon. When the same contribution exists on several c
Attachment sources remain scoped to each composer's host.
Workspace panels and Command Center items stay scoped to the active host and exact cached context.
Reload replaces their registrations. Disable, removal, host disconnect, and evaluation failure
remove Command Center items and clear the installation's query state. An already-restored panel tab
remains as unavailable until its matching contribution returns or the user closes it. Panel render
failures stay inside the plugin error boundary.
## CLI reference
```bash
+1 -1
View File
@@ -106,7 +106,7 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o
| `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. |
| `subscribe(handler)` | Unsubscribe function | Listens for connection-local workspace updates. Call `list({ subscribe })` first. |
A workspace handle exposes `id`, `projectId`, `directory`, `name`, `status`, `current()`, `refresh()`, `archive()`, and `subscribe()`. Use `workspace.agents.create(options)` to create an agent without repeating the workspace ID or directory.
A workspace handle exposes `id`, `projectId`, `directory`, `name`, `status`, `current()`, `refresh()`, `setTitle(title)`, `archive()`, and `subscribe()`. Pass `null` to `setTitle` to restore the derived workspace name. Use `workspace.agents.create(options)` to create an agent without repeating the workspace ID or directory.
## `client.providers`
+44 -4
View File
@@ -54,12 +54,52 @@ import type { PluginContext } from "@paseo/plugin";
export default function contribute(plugin: PluginContext) {
// Register contributions here.
return () => undefined;
return () => {};
}
```
Cleanup can be async. Use it for timers, watchers, sockets, and other resources created by plugin code. Paseo removes registrations, unmounts surfaces, rejects pending RPCs, closes the plugin session, and stops the subprocess when the plugin stops.
## Add a workspace panel
Workspace panels live beside agents, terminals, files, and diffs. Their props contain stable IDs;
required-selector hooks read cached client state without subscribing to unrelated fields:
```tsx
import { type PluginContext, type PluginWorkspacePanelProps, useWorkspace } from "@paseo/plugin";
import { Text } from "react-native";
function Overview({ workspaceId }: PluginWorkspacePanelProps) {
const name = useWorkspace(workspaceId, (workspace) => workspace.name);
return <Text>{name}</Text>;
}
export default function contribute(plugin: PluginContext) {
plugin.addWorkspacePanel({
id: "overview",
title: "Workspace overview",
icon: "PanelsTopLeft",
context: "workspace",
Component: Overview,
});
plugin.addCommandCenterItem({
id: "open-overview",
title: "Open workspace overview",
icon: "PanelsTopLeft",
context: "workspace",
onSelect({ openPanel }) {
openPanel("overview");
},
});
return () => {};
}
```
Use `useWorkspace(id, selector)` and `useAgent(id, selector)`. Selectors are required
and their results use shallow equality. Never select the whole snapshot or add an RPC to discover
the active workspace or agent. Command callbacks receive the selected host's `paseo`, typed
`rpc(contract, input)`, `openSurface(id)`, and contextual `openPanel(id)` capabilities.
## Add a sidebar surface
Plugin surfaces use React Native primitives and work across desktop, browser, iOS, and Android. Register the surface before its sidebar item:
@@ -93,7 +133,7 @@ export default function contribute(plugin: PluginContext) {
icon: "ListPlus",
surface: "main",
});
return () => undefined;
return () => {};
}
```
@@ -164,7 +204,7 @@ export default function contribute(plugin: PluginContext) {
return { message: `${name}: plugins are ${config.pluginsEnabled ? "on" : "off"}` };
});
plugin.addSurface("main", Greeting);
return () => undefined;
return () => {};
}
```
@@ -228,7 +268,7 @@ const issues = defineAttachmentSource({
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
plugin.addAttachmentSource(issues);
return () => undefined;
return () => {};
}
```