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>
This commit is contained in:
Omer Cohen
2026-08-29 21:33:09 +02:00
committed by GitHub
co-authored by paseo-bot[bot]
parent 6c813d1a91
commit 1d4c3a2722
12 changed files with 195 additions and 24 deletions
+4 -3
View File
@@ -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
@@ -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 <View><Text>Agent bridge {agent?.id}</Text><Text>Workspace {workspace?.id}</Text><Text>Host {host.id}</Text><Text>Layout {layout.compact ? "compact" : "wide"}</Text></View>;
}
function DirectCollisionSurface() {
return <View><Text>Direct collision surface</Text></View>;
function DirectCollisionSurface({ navigation }) {
return <View>
<Text>Direct collision surface</Text>
{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>
</> : null}
</View>;
}
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,
@@ -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",
});
});
});
@@ -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<PluginSurfaceProps["navigation"]> {
return useMemo(
() => ({
openAgent: ({ agentId }) => navigateToAgent({ serverId, agentId }),
openWorkspace: ({ workspaceId }) => navigateToWorkspace({ serverId, workspaceId }),
}),
[serverId],
);
}
@@ -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);
});
+3 -2
View File
@@ -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]);
}
+3 -1
View File
@@ -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 (
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
<Surface theme={theme} host={host} layout={layout} />
<Surface theme={theme} host={host} layout={layout} navigation={navigation} />
</PluginRuntimeBoundary>
);
}
@@ -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 <PluginPanelUnavailable />;
@@ -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,
};
@@ -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 <><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></>;
}
+11 -3
View File
@@ -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<Record<string, string>>;
}
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;
+11 -3
View File
@@ -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;
+11 -6
View File
@@ -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