diff --git a/docs/plugins.md b/docs/plugins.md
index 98ae45093f..67ca017cbb 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -280,8 +280,9 @@ existing agent-context instances, but it cannot create an agent panel without an
Command Center callbacks use the selected host's existing `PaseoApi` for normal Paseo operations.
They use typed plugin RPC only for plugin-specific backend work. Surface and panel props expose
optional client-owned agent and workspace navigation; its absence is the compatibility gate for
-older clients. Other navigation remains limited to registered global surfaces and workspace panels.
-Plugins do not receive Expo Router or workspace-layout store access.
+older clients. Navigation targets the selected host unless the call includes another `serverId`.
+Other navigation remains limited to registered global surfaces and workspace panels. Plugins do not
+receive Expo Router or workspace-layout store access.
## Lifecycle hooks
diff --git a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts
index 0d29f0b212..fe818a22d1 100644
--- a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts
+++ b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts
@@ -27,7 +27,13 @@ function isSettledWorkspaceUrl(url: URL): boolean {
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
}
-function pluginClientSource(input: { workspaceId: string; agentId: string }): string {
+function pluginClientSource(input: {
+ workspaceId: string;
+ agentId: string;
+ remoteServerId: string;
+ remoteWorkspaceId: string;
+ remoteAgentId: string;
+}): string {
return `import React, { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { Icon } from "@getpaseo/plugin/client/react-native";
@@ -53,6 +59,8 @@ function DirectCollisionSurface({ navigation }) {
{navigation ? <>
navigation.openWorkspace({ workspaceId: ${JSON.stringify(input.workspaceId)} })}>Open workspace from plugin
navigation.openAgent({ agentId: ${JSON.stringify(input.agentId)} })}>Open agent from plugin
+ navigation.openWorkspace({ serverId: ${JSON.stringify(input.remoteServerId)}, workspaceId: ${JSON.stringify(input.remoteWorkspaceId)} })}>Open remote workspace from plugin
+ navigation.openAgent({ serverId: ${JSON.stringify(input.remoteServerId)}, agentId: ${JSON.stringify(input.remoteAgentId)} })}>Open remote agent from plugin
> : null}
;
}
@@ -148,7 +156,13 @@ export default function contribute(server) {
async function writePluginSources(
directory: string,
- input: { workspaceId: string; agentId: string },
+ input: {
+ workspaceId: string;
+ agentId: string;
+ remoteServerId: string;
+ remoteWorkspaceId: string;
+ remoteAgentId: string;
+ },
): Promise {
await mkdir(path.join(directory, "shared"), { recursive: true });
await Promise.all([
@@ -182,6 +196,44 @@ async function runCommand(page: Page, title: string): Promise {
await expect(panel).not.toBeVisible();
}
+interface RemoteNavigationTarget {
+ serverId: string;
+ workspaceId: string;
+ agentId: string;
+}
+
+function remoteWorkspaceRoute(target: RemoteNavigationTarget): RegExp {
+ return new RegExp(
+ `/h/${encodeURIComponent(target.serverId)}/workspace/${encodeURIComponent(target.workspaceId)}(?:\\?.*)?$`,
+ );
+}
+
+async function openRemoteWorkspaceFromPlugin(
+ page: Page,
+ target: RemoteNavigationTarget,
+): Promise {
+ await page
+ .getByRole("button", { name: "Open remote workspace from plugin", exact: true })
+ .click();
+ await page.waitForURL(remoteWorkspaceRoute(target));
+ await expect(
+ page
+ .getByTestId(`workspace-deck-entry-${target.serverId}:${target.workspaceId}`)
+ .getByTestId("workspace-header-title"),
+ ).toBeVisible();
+}
+
+async function openRemoteAgentFromPlugin(
+ page: Page,
+ target: RemoteNavigationTarget,
+): Promise {
+ await page.getByRole("button", { name: "Open remote agent from plugin", exact: true }).click();
+ await page.waitForURL(remoteWorkspaceRoute(target));
+ await expect(
+ page.getByTestId(`workspace-tab-agent_${target.agentId}`).filter({ visible: true }).first(),
+ ).toBeVisible();
+}
+
async function openCompactSidebar(page: Page): Promise {
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await expect(page.getByTestId("sidebar-search")).toBeVisible();
@@ -208,6 +260,14 @@ test.describe("plugin workspace panels and Command Center", () => {
repoPrefix: "plugin-panel-secondary-",
port: secondaryDaemon.port,
});
+ const secondaryAgent = await secondary.client.createAgent({
+ provider: "mock",
+ cwd: secondary.repoPath,
+ workspaceId: secondary.workspaceId,
+ title: "Remote plugin navigation agent",
+ model: "ten-second-stream",
+ modeId: "load-test",
+ });
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
@@ -215,6 +275,9 @@ test.describe("plugin workspace panels and Command Center", () => {
await writePluginSources(directory, {
workspaceId: primary.workspaceId,
agentId: "missing-agent",
+ remoteServerId: secondaryDaemon.serverId,
+ remoteWorkspaceId: secondary.workspaceId,
+ remoteAgentId: secondaryAgent.id,
});
try {
@@ -294,6 +357,9 @@ test.describe("plugin workspace panels and Command Center", () => {
await writePluginSources(directory, {
workspaceId: primary.workspaceId,
agentId: navigationAgentId,
+ remoteServerId: secondaryDaemon.serverId,
+ remoteWorkspaceId: secondary.workspaceId,
+ remoteAgentId: secondaryAgent.id,
});
await primaryClient.reloadPlugin(PLUGIN_ID);
@@ -308,6 +374,23 @@ test.describe("plugin workspace panels and Command Center", () => {
).toBeVisible();
});
+ await test.step("surface navigation opens a workspace and agent on another host", async () => {
+ const remoteTarget = {
+ serverId: secondaryDaemon.serverId,
+ workspaceId: secondary.workspaceId,
+ agentId: secondaryAgent.id,
+ };
+ await runCommand(page, "Open direct collision surface");
+ await openRemoteWorkspaceFromPlugin(page, remoteTarget);
+ await switchWorkspaceViaSidebar({
+ page,
+ serverId: getServerId(),
+ workspaceId: primary.workspaceId,
+ });
+ await runCommand(page, "Open direct collision surface");
+ await openRemoteAgentFromPlugin(page, remoteTarget);
+ });
+
await test.step("switching hosts removes commands from an uninstalled host", async () => {
await switchWorkspaceViaSidebar({
page,
diff --git a/packages/app/src/plugins/host-navigation.ts b/packages/app/src/plugins/host-navigation.ts
index 78fdef566d..b0bf1536b8 100644
--- a/packages/app/src/plugins/host-navigation.ts
+++ b/packages/app/src/plugins/host-navigation.ts
@@ -8,8 +8,10 @@ export function usePluginHostNavigation(
): NonNullable {
return useMemo(
() => ({
- openAgent: ({ agentId }) => navigateToAgent({ serverId, agentId }),
- openWorkspace: ({ workspaceId }) => navigateToWorkspace({ serverId, workspaceId }),
+ openAgent: ({ agentId, serverId: targetServerId }) =>
+ navigateToAgent({ serverId: targetServerId ?? serverId, agentId }),
+ openWorkspace: ({ workspaceId, serverId: targetServerId }) =>
+ navigateToWorkspace({ serverId: targetServerId ?? serverId, workspaceId }),
}),
[serverId],
);
diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts
index 3e832a5e65..f4186449d2 100644
--- a/packages/cli/src/commands/plugin/scaffold.test.ts
+++ b/packages/cli/src/commands/plugin/scaffold.test.ts
@@ -153,6 +153,8 @@ export function Surface({ navigation }: PluginSurfaceProps) {
});
navigation?.openAgent({ agentId: "agent-1" });
navigation?.openWorkspace({ workspaceId: "workspace-1" });
+ navigation?.openAgent({ serverId: "server-2", agentId: "agent-2" });
+ navigation?.openWorkspace({ serverId: "server-2", workspaceId: "workspace-2" });
void createWorkspace;
return <> toast.show("Ready")}>Paseo API} open={false} onOpenChange={() => {}}>Modal>;
}
diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts
index 3e6294e9b6..8a1e16fe46 100644
--- a/packages/plugin/src/client/contracts.ts
+++ b/packages/plugin/src/client/contracts.ts
@@ -33,8 +33,11 @@ export interface PluginHostProps {
interface PluginNavigableHostProps extends PluginHostProps {
/** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */
readonly navigation?: {
- readonly openAgent: (input: { readonly agentId: string }) => void;
- readonly openWorkspace: (input: { readonly workspaceId: string }) => void;
+ readonly openAgent: (input: { readonly agentId: string; readonly serverId?: string }) => void;
+ readonly openWorkspace: (input: {
+ readonly workspaceId: string;
+ readonly serverId?: string;
+ }) => void;
};
}
diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md
index 62c2f0ed80..b1a5ec44ab 100644
--- a/public-docs/plugins/v0.8/reference.md
+++ b/public-docs/plugins/v0.8/reference.md
@@ -614,12 +614,12 @@ export default function contribute(client: PluginClientContext) {
`PluginSurfaceProps` contains:
-| Field | Meaning |
-| ------------ | ---------------------------------------------------------------------------------------------------------------------------- |
-| `theme` | Typed `PluginTheme` color tokens for the active Paseo theme. |
-| `host` | Selected host `id` and display `label`. |
-| `layout` | `compact` and the `ios`, `android`, or `web` platform. |
-| `navigation` | Optional client navigation. `openAgent({ agentId })` and `openWorkspace({ workspaceId })` open targets on the selected host. |
+| Field | Meaning |
+| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `theme` | Typed `PluginTheme` color tokens for the active Paseo theme. |
+| `host` | Selected host `id` and display `label`. |
+| `layout` | `compact` and the `ios`, `android`, or `web` platform. |
+| `navigation` | Optional client navigation. `openAgent({ agentId, serverId? })` and `openWorkspace({ workspaceId, serverId? })` open targets on `serverId`, or on the selected host when omitted. |
Paseo owns the route, header, close action, host picker, error boundary, and query client. The plugin owns the surface body.