From 1d4c3a2722376e19d316d51e09b006a6a9104e46 Mon Sep 17 00:00:00 2001
From: Omer Cohen <639682+omercnet@users.noreply.github.com>
Date: Sat, 29 Aug 2026 22:33:09 +0300
Subject: [PATCH] feat(plugin): add host navigation capabilities for plugin
surfaces (#3901)
* feat(plugin): add optional agent navigation capability
* feat(plugin): extend host navigation capability
* fix(plugin): preserve navigation tests after sdk rebase
* fix(plugin): scope host navigation contracts
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
---
docs/plugins.md | 7 ++-
.../browser/plugin-workspace-panels.spec.ts | 51 ++++++++++++++--
.../app/src/plugins/host-navigation.test.tsx | 59 +++++++++++++++++++
packages/app/src/plugins/host-navigation.ts | 16 +++++
.../app/src/plugins/registry-hooks.test.tsx | 23 ++++++++
packages/app/src/plugins/registry.ts | 5 +-
packages/app/src/plugins/surface-screen.tsx | 4 +-
.../src/plugins/workspace-panels/panel.tsx | 4 ++
.../cli/src/commands/plugin/scaffold.test.ts | 5 +-
packages/cli/src/commands/plugin/scaffold.ts | 14 ++++-
packages/plugin/src/contracts.ts | 14 ++++-
public-docs/plugins/reference.md | 17 ++++--
12 files changed, 195 insertions(+), 24 deletions(-)
create mode 100644 packages/app/src/plugins/host-navigation.test.tsx
create mode 100644 packages/app/src/plugins/host-navigation.ts
create mode 100644 packages/app/src/plugins/registry-hooks.test.tsx
diff --git a/docs/plugins.md b/docs/plugins.md
index bca608ba52..cc59c36373 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -201,9 +201,10 @@ when moved between hosts. Explorer configuration can create workspace-context pa
existing agent-context instances, but it cannot create an agent panel without an agent-aware command.
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. Navigation is limited to the
-plugin's registered global surfaces and workspace panels; plugins do not receive Expo Router or
-workspace-layout store access.
+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.
## Contribute composer pills
diff --git a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts
index a33f612c9b..55a7394e47 100644
--- a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts
+++ b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts
@@ -29,9 +29,9 @@ function isSettledWorkspaceUrl(url: URL): boolean {
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
}
-function pluginSource(): string {
+function pluginSource(input: { workspaceId: string; agentId: string }): string {
return `import React, { useRef } from "react";
-import { Text, View } from "react-native";
+import { Pressable, Text, View } from "react-native";
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
import { defineRpc } from "@getpaseo/plugin/server";
import { z } from "zod";
@@ -55,8 +55,14 @@ function AgentPanel({ workspaceId, agentId, host, layout }) {
return Agent bridge {agent?.id}Workspace {workspace?.id}Host {host.id}Layout {layout.compact ? "compact" : "wide"};
}
-function DirectCollisionSurface() {
- return Direct collision surface;
+function DirectCollisionSurface({ navigation }) {
+ return
+ Direct collision surface
+ {navigation ? <>
+ navigation.openWorkspace({ workspaceId: ${JSON.stringify(input.workspaceId)} })}>Open workspace from plugin
+ navigation.openAgent({ agentId: ${JSON.stringify(input.agentId)} })}>Open agent from plugin
+ > : null}
+ ;
}
function SidebarCollisionSurface() {
@@ -178,7 +184,10 @@ test.describe("plugin workspace panels and Command Center", () => {
port: secondaryDaemon.port,
});
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
- await writeFile(path.join(directory, "index.tsx"), pluginSource());
+ await writeFile(
+ path.join(directory, "index.tsx"),
+ pluginSource({ workspaceId: primary.workspaceId, agentId: "missing-agent" }),
+ );
try {
await primaryClient.patchDaemonConfig({ pluginsEnabled: true });
@@ -239,6 +248,38 @@ test.describe("plugin workspace panels and Command Center", () => {
await page.getByTestId("plugin-surface-close").click();
});
+ await test.step("surface navigation opens host-owned workspace and agent routes", async () => {
+ await runCommand(page, "Open direct collision surface");
+ await page.getByRole("button", { name: "Open workspace from plugin", exact: true }).click();
+ await page.waitForURL(isSettledWorkspaceUrl);
+ await expect(page.getByTestId("workspace-header-title")).toBeVisible();
+
+ const agent = await primary.client.createAgent({
+ provider: "mock",
+ cwd: primary.repoPath,
+ workspaceId: primary.workspaceId,
+ title: "Plugin navigation agent",
+ model: "ten-second-stream",
+ modeId: "load-test",
+ });
+ const navigationAgentId = agent.id;
+ await writeFile(
+ path.join(directory, "index.tsx"),
+ pluginSource({ workspaceId: primary.workspaceId, agentId: navigationAgentId }),
+ );
+ await primaryClient.reloadPlugin(PLUGIN_ID);
+
+ await runCommand(page, "Open direct collision surface");
+ await page.getByRole("button", { name: "Open agent from plugin", exact: true }).click();
+ await page.waitForURL(isSettledWorkspaceUrl);
+ await expect(
+ page
+ .getByTestId(`workspace-tab-agent_${navigationAgentId}`)
+ .filter({ visible: true })
+ .first(),
+ ).toBeVisible();
+ });
+
await test.step("switching hosts removes commands from an uninstalled host", async () => {
await switchWorkspaceViaSidebar({
page,
diff --git a/packages/app/src/plugins/host-navigation.test.tsx b/packages/app/src/plugins/host-navigation.test.tsx
new file mode 100644
index 0000000000..ebb4638bea
--- /dev/null
+++ b/packages/app/src/plugins/host-navigation.test.tsx
@@ -0,0 +1,59 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
+import { navigateToAgent } from "@/utils/navigate-to-agent";
+import { usePluginHostNavigation } from "./host-navigation";
+
+vi.mock("@/utils/navigate-to-agent", () => ({
+ navigateToAgent: vi.fn(),
+}));
+vi.mock("@/stores/navigation-active-workspace-store", () => ({
+ navigateToWorkspace: vi.fn(),
+}));
+
+const navigateToAgentMock = vi.mocked(navigateToAgent);
+const navigateToWorkspaceMock = vi.mocked(navigateToWorkspace);
+
+describe("usePluginHostNavigation", () => {
+ beforeEach(() => {
+ navigateToAgentMock.mockReset();
+ navigateToWorkspaceMock.mockReset();
+ });
+
+ it("opens agents and workspaces on the rendering host", () => {
+ const { result } = renderHook(() => usePluginHostNavigation("host-1"));
+
+ act(() => result.current.openAgent({ agentId: "agent-1" }));
+ act(() => result.current.openWorkspace({ workspaceId: "workspace-1" }));
+
+ expect(navigateToAgentMock).toHaveBeenCalledWith({ serverId: "host-1", agentId: "agent-1" });
+ expect(navigateToWorkspaceMock).toHaveBeenCalledWith({
+ serverId: "host-1",
+ workspaceId: "workspace-1",
+ });
+ });
+
+ it("keeps the capability stable until the rendering host changes", () => {
+ const { result, rerender } = renderHook(({ serverId }) => usePluginHostNavigation(serverId), {
+ initialProps: { serverId: "host-1" },
+ });
+ const initialNavigation = result.current;
+
+ rerender({ serverId: "host-1" });
+ expect(result.current).toBe(initialNavigation);
+
+ rerender({ serverId: "host-2" });
+ expect(result.current).not.toBe(initialNavigation);
+
+ act(() => result.current.openAgent({ agentId: "agent-2" }));
+ act(() => result.current.openWorkspace({ workspaceId: "workspace-2" }));
+ expect(navigateToAgentMock).toHaveBeenCalledWith({ serverId: "host-2", agentId: "agent-2" });
+ expect(navigateToWorkspaceMock).toHaveBeenCalledWith({
+ serverId: "host-2",
+ workspaceId: "workspace-2",
+ });
+ });
+});
diff --git a/packages/app/src/plugins/host-navigation.ts b/packages/app/src/plugins/host-navigation.ts
new file mode 100644
index 0000000000..835cc41657
--- /dev/null
+++ b/packages/app/src/plugins/host-navigation.ts
@@ -0,0 +1,16 @@
+import type { PluginSurfaceProps } from "@getpaseo/plugin";
+import { useMemo } from "react";
+import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
+import { navigateToAgent } from "@/utils/navigate-to-agent";
+
+export function usePluginHostNavigation(
+ serverId: string,
+): NonNullable {
+ return useMemo(
+ () => ({
+ openAgent: ({ agentId }) => navigateToAgent({ serverId, agentId }),
+ openWorkspace: ({ workspaceId }) => navigateToWorkspace({ serverId, workspaceId }),
+ }),
+ [serverId],
+ );
+}
diff --git a/packages/app/src/plugins/registry-hooks.test.tsx b/packages/app/src/plugins/registry-hooks.test.tsx
new file mode 100644
index 0000000000..532ef8b029
--- /dev/null
+++ b/packages/app/src/plugins/registry-hooks.test.tsx
@@ -0,0 +1,23 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { renderHook } from "@testing-library/react";
+import { expect, it, vi } from "vitest";
+
+vi.mock("./evaluate", () => ({
+ evaluatePluginClientBundle: vi.fn(),
+}));
+vi.mock("./composer-pills/lifecycle", () => ({
+ startPluginClientSide: vi.fn(),
+}));
+
+import { usePluginInstallations } from "./registry";
+
+it("keeps plugin installation selections stable while the registry is unchanged", () => {
+ const { result, rerender } = renderHook(() => usePluginInstallations("missing-plugin"));
+ const initialInstallations = result.current;
+
+ rerender();
+
+ expect(result.current).toBe(initialInstallations);
+});
diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts
index 33177f7d30..fd925489a8 100644
--- a/packages/app/src/plugins/registry.ts
+++ b/packages/app/src/plugins/registry.ts
@@ -1,4 +1,4 @@
-import { useSyncExternalStore } from "react";
+import { useMemo, useSyncExternalStore } from "react";
import { QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { startPluginClientSide } from "./composer-pills/lifecycle";
@@ -166,5 +166,6 @@ export function useInstalledPlugin(serverId: string, pluginId: string): Installe
}
export function usePluginInstallations(pluginId: string): InstalledPlugin[] {
- return useInstalledPlugins().filter((plugin) => plugin.id === pluginId);
+ const installed = useInstalledPlugins();
+ return useMemo(() => installed.filter((plugin) => plugin.id === pluginId), [installed, pluginId]);
}
diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx
index 2904d8f5d6..849f357ba5 100644
--- a/packages/app/src/plugins/surface-screen.tsx
+++ b/packages/app/src/plugins/surface-screen.tsx
@@ -13,6 +13,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut";
+import { usePluginHostNavigation } from "./host-navigation";
import { resolvePluginIcon } from "./icons";
import { toPluginTheme } from "./theme";
import { useInstalledPlugin, usePluginInstallations } from "./registry";
@@ -66,9 +67,10 @@ function SurfaceRenderer({
host: PluginSurfaceProps["host"];
theme: PluginTheme;
}) {
+ const navigation = usePluginHostNavigation(host.id);
return (
-
+
);
}
diff --git a/packages/app/src/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx
index c9d47a7337..2acca92bd2 100644
--- a/packages/app/src/plugins/workspace-panels/panel.tsx
+++ b/packages/app/src/plugins/workspace-panels/panel.tsx
@@ -18,6 +18,7 @@ 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 { usePluginHostNavigation } from "../host-navigation";
import { createPluginClientStateSource } from "../client-state/source";
import { toPluginTheme } from "../theme";
import { resolvePluginIcon } from "../icons";
@@ -63,6 +64,7 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) {
const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]);
const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]);
const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]);
+ const navigation = usePluginHostNavigation(serverId);
if (!plugin || !contribution || !workspaceExists) {
return ;
@@ -79,6 +81,7 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) {
theme,
host,
layout,
+ navigation,
workspaceId,
};
const Component = contribution.Component;
@@ -90,6 +93,7 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) {
theme,
host,
layout,
+ navigation,
workspaceId,
agentId: target.agentId,
};
diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts
index 86b762712c..4db422c983 100644
--- a/packages/cli/src/commands/plugin/scaffold.test.ts
+++ b/packages/cli/src/commands/plugin/scaffold.test.ts
@@ -83,18 +83,21 @@ import {
type PluginAgentPanelProps,
type PluginClientContext,
type PluginComposerPillProps,
+ type PluginSurfaceProps,
useAgent,
usePaseo,
useWorkspace,
} from "@getpaseo/plugin";
import { inspect } from "./inspect.shared";
-export function Surface() {
+export function Surface({ navigation }: PluginSurfaceProps) {
const paseo = usePaseo();
const toast = useToast();
const createWorkspace = () => paseo.workspaces.create({
source: { kind: "directory", path: "/repo" },
});
+ navigation?.openAgent({ agentId: "agent-1" });
+ navigation?.openWorkspace({ workspaceId: "workspace-1" });
void createWorkspace;
return <> toast.show("Ready")}>Paseo API} open={false} onOpenChange={() => {}}>Modal>;
}
diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts
index e9ead5559f..abca44d90b 100644
--- a/packages/cli/src/commands/plugin/scaffold.ts
+++ b/packages/cli/src/commands/plugin/scaffold.ts
@@ -141,7 +141,15 @@ declare module "@getpaseo/plugin" {
layout: { compact: boolean; platform: "ios" | "android" | "web" };
}
- export interface PluginSurfaceProps extends 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;
+ };
+ }
+
+ export interface PluginSurfaceProps extends PluginNavigableHostProps {}
export interface PluginIconProps {
name: string;
@@ -184,12 +192,12 @@ declare module "@getpaseo/plugin" {
readonly labels: Readonly>;
}
- export interface PluginWorkspacePanelProps extends PluginHostProps {
+ export interface PluginWorkspacePanelProps extends PluginNavigableHostProps {
context: "workspace";
workspaceId: string;
}
- export interface PluginAgentPanelProps extends PluginHostProps {
+ export interface PluginAgentPanelProps extends PluginNavigableHostProps {
context: "agent";
workspaceId: string;
agentId: string;
diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts
index 75a95be881..f25b6ecb41 100644
--- a/packages/plugin/src/contracts.ts
+++ b/packages/plugin/src/contracts.ts
@@ -32,7 +32,15 @@ export interface PluginHostProps {
};
}
-export interface PluginSurfaceProps extends 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;
+ };
+}
+
+export interface PluginSurfaceProps extends PluginNavigableHostProps {}
export interface PluginIconProps {
name: string;
@@ -88,12 +96,12 @@ interface PluginWorkspacePanelBase {
locations?: readonly PluginPanelLocation[];
}
-export interface PluginWorkspacePanelProps extends PluginHostProps {
+export interface PluginWorkspacePanelProps extends PluginNavigableHostProps {
context: "workspace";
workspaceId: string;
}
-export interface PluginAgentPanelProps extends PluginHostProps {
+export interface PluginAgentPanelProps extends PluginNavigableHostProps {
context: "agent";
workspaceId: string;
agentId: string;
diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md
index d18b1634f0..c9b1f402b9 100644
--- a/public-docs/plugins/reference.md
+++ b/public-docs/plugins/reference.md
@@ -161,11 +161,12 @@ export default function contribute(plugin: PluginContext) {
`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. |
+| 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. |
Paseo owns the route, header, close action, host picker, error boundary, and query client. The plugin owns the surface body.
@@ -322,6 +323,10 @@ Renderers receive `agentId`, `item`, `timestamp`, `theme`, `host`, and `layout`.
`item.data` with the registered schema before rendering. Keep transformers synchronous and
deterministic because Paseo reruns them while reconciling projected history.
+Check `navigation` before showing an action that depends on it. Older Paseo clients leave the
+capability undefined. Let Paseo own route construction so the action works without reloading on
+desktop, browser, iOS, and Android.
+
## Theme and layout
Plugin UI runs on desktop, browser, iOS, and Android, across every Paseo theme. `theme` is a typed `PluginTheme` mapped from the active host theme. Color and spacing must come from those props. Hardcoded colors and unstyled `Text` break when the host theme changes.
@@ -346,7 +351,7 @@ Recreate styles when `theme` or `layout.compact` changes.
Do not hardcode `#000`, `#fff`, or React Native's default text color. Primary copy uses `foreground`. Labels use `foregroundMuted`. Tighten padding when `layout.compact` is true.
-Workspace and agent panels receive the same `theme` and `layout` fields.
+Workspace and agent panels receive the same `theme`, `layout`, and optional `navigation` fields.
## Contribute a theme