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
@@ -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
);
}