mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 10:57:07 +00:00
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:
co-authored by
paseo-bot[bot]
parent
6c813d1a91
commit
1d4c3a2722
+4
-3
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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></>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user