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,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({