mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
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:
+3
-2
@@ -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,
|
||||
|
||||
@@ -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></>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user