From 72e3d6957a841b941d63c4a486b181928a302b3a Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:27:15 +0200 Subject: [PATCH] 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> --- docs/plugins.md | 5 +- .../browser/plugin-workspace-panels.spec.ts | 87 ++++++++++++++++++- packages/app/src/plugins/host-navigation.ts | 6 +- .../cli/src/commands/plugin/scaffold.test.ts | 2 + packages/plugin/src/client/contracts.ts | 7 +- public-docs/plugins/v0.8/reference.md | 12 +-- 6 files changed, 105 insertions(+), 14 deletions(-) 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.