Let plugins navigate across hosts (#4942)

* feat(plugin): support cross-host navigation

Keep route construction inside Paseo while letting plugin surfaces target a host other than the currently selected one. Calls without a server ID retain selected-host behavior.

* test(plugin): cover cross-host navigation

Exercise workspace and agent navigation against a second live daemon so the SDK contract is verified through the caller-visible route instead of mocked helper forwarding.

* test(plugin): clarify cross-host navigation journey

Keep the scenario at the caller-visible intent level while shared helpers own route waits and visibility mechanics.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-09-16 20:27:15 +02:00
committed by GitHub
parent 46529146ac
commit 72e3d6957a
6 changed files with 105 additions and 14 deletions
+3 -2
View File
@@ -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
@@ -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 ? <>
<Pressable accessibilityRole="button" onPress={() => navigation.openWorkspace({ workspaceId: ${JSON.stringify(input.workspaceId)} })}><Text>Open workspace from plugin</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => navigation.openAgent({ agentId: ${JSON.stringify(input.agentId)} })}><Text>Open agent from plugin</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => navigation.openWorkspace({ serverId: ${JSON.stringify(input.remoteServerId)}, workspaceId: ${JSON.stringify(input.remoteWorkspaceId)} })}><Text>Open remote workspace from plugin</Text></Pressable>
<Pressable accessibilityRole="button" onPress={() => navigation.openAgent({ serverId: ${JSON.stringify(input.remoteServerId)}, agentId: ${JSON.stringify(input.remoteAgentId)} })}><Text>Open remote agent from plugin</Text></Pressable>
</> : null}
</View>;
}
@@ -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<void> {
await mkdir(path.join(directory, "shared"), { recursive: true });
await Promise.all([
@@ -182,6 +196,44 @@ async function runCommand(page: Page, title: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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,
+4 -2
View File
@@ -8,8 +8,10 @@ export function usePluginHostNavigation(
): NonNullable<PluginSurfaceProps["navigation"]> {
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],
);
@@ -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 <><Icon name="Settings" size={18} color="#123456" /><Text onPress={() => toast.show("Ready")}>Paseo API</Text><Modal title="Example" icon={<Icon name="Settings" />} open={false} onOpenChange={() => {}}><Modal.Content><Text>Modal</Text></Modal.Content></Modal></>;
}
+5 -2
View File
@@ -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;
};
}
+6 -6
View File
@@ -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.