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