From 6d7606160e37ccab540c3b5a1aecf1e49ec724b0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 17 Aug 2026 19:26:40 +0200 Subject: [PATCH] Let plugins add contextual workspace tools (#3465) * feat(plugins): add workspace panels and command center contributions Give local plugins contextual UI entry points without making the daemon interpret client placement or callbacks. Panels receive stable IDs and read cached client state through required selectors with shallow equality, avoiding unrelated guest rerenders and context-discovery RPCs. * fix(plugins): align shared client state ownership Workspace panels and Command Center callbacks consume the same frozen client snapshots, so their adapter belongs to the plugin boundary rather than either contribution surface. Refresh the Nix dependency hash for the selector-store dependency added by this feature. --- docs/expo-router.md | 4 +- docs/plugins.md | 26 +- nix/npm-deps.hash | 2 +- package-lock.json | 3 + .../browser/plugin-workspace-panels.spec.ts | 247 ++++++++++++++++++ .../app/e2e/support/helpers/new-workspace.ts | 2 + packages/app/src/app/_layout.tsx | 2 + packages/app/src/app/h/[serverId]/_layout.tsx | 1 + .../[contributionKind]/[contributionId].tsx | 5 + .../plugin/[pluginId]/[surfaceId].tsx | 24 +- packages/app/src/panels/register-panels.ts | 2 + .../plugins/client-state/snapshots.test.ts | 119 +++++++++ .../app/src/plugins/client-state/snapshots.ts | 57 ++++ .../app/src/plugins/client-state/source.ts | 21 ++ .../plugins/command-center/context.test.ts | 51 ++++ .../app/src/plugins/command-center/context.ts | 18 ++ .../command-center/contributions.test.ts | 230 ++++++++++++++++ .../plugins/command-center/contributions.ts | 117 +++++++++ .../plugins/command-center/registration.tsx | 111 ++++++++ packages/app/src/plugins/evaluate.test.ts | 65 +++++ packages/app/src/plugins/evaluate.ts | 69 ++++- packages/app/src/plugins/index.ts | 1 + packages/app/src/plugins/registry.test.ts | 4 + packages/app/src/plugins/routes.test.ts | 19 ++ packages/app/src/plugins/routes.ts | 26 +- packages/app/src/plugins/runtime-boundary.tsx | 23 ++ .../app/src/plugins/sidebar-groups.test.ts | 7 +- packages/app/src/plugins/sidebar-groups.ts | 2 +- packages/app/src/plugins/sidebar-items.tsx | 14 +- .../src/plugins/surface-contribution.test.ts | 79 ++++++ .../app/src/plugins/surface-contribution.ts | 39 +++ packages/app/src/plugins/surface-screen.tsx | 81 +++--- packages/app/src/plugins/types.ts | 6 + .../src/plugins/workspace-panels/panel.tsx | 178 +++++++++++++ .../workspace-panels/resolution.test.ts | 61 +++++ .../plugins/workspace-panels/resolution.ts | 14 + .../screens/workspace/workspace-screen.tsx | 3 + .../screens/workspace/workspace-tab-menu.ts | 3 + .../src/stores/workspace-layout-store.test.ts | 51 ++++ .../app/src/stores/workspace-layout-store.ts | 15 ++ .../app/src/workspace-tabs/identity.test.ts | 39 +++ packages/app/src/workspace-tabs/identity.ts | 31 +++ packages/app/src/workspace-tabs/model.ts | 16 ++ .../app/test-stubs/lucide-react-native.ts | 2 + .../cli/src/commands/plugin/scaffold.test.ts | 43 ++- packages/cli/src/commands/plugin/scaffold.ts | 99 ++++++- packages/client/src/index.test.ts | 21 ++ packages/client/src/index.ts | 2 + packages/plugin/package.json | 3 + packages/plugin/src/client-state.tsx | 62 +++++ packages/plugin/src/contracts.ts | 115 +++++++- packages/plugin/src/host.ts | 22 +- packages/plugin/src/index.ts | 12 + packages/plugin/src/shallow.test.ts | 21 ++ packages/plugin/src/shallow.ts | 47 ++++ .../plugin/src/use-sync-external-store.d.ts | 9 + .../server/src/server/plugins/compiler.ts | 8 +- .../src/server/plugins/runtime.posix.test.ts | 8 + plugin-examples/linear/index.tsx | 2 +- plugin-examples/local-plugin/index.tsx | 35 ++- public-docs/plugins/index.md | 69 ++--- public-docs/plugins/reference.md | 161 +++++++++++- public-docs/sdk/reference.md | 2 +- skills/paseo-plugin/SKILL.md | 48 +++- 64 files changed, 2557 insertions(+), 122 deletions(-) create mode 100644 packages/app/e2e/browser/plugin-workspace-panels.spec.ts create mode 100644 packages/app/src/app/h/[serverId]/plugin/[pluginId]/[contributionKind]/[contributionId].tsx create mode 100644 packages/app/src/plugins/client-state/snapshots.test.ts create mode 100644 packages/app/src/plugins/client-state/snapshots.ts create mode 100644 packages/app/src/plugins/client-state/source.ts create mode 100644 packages/app/src/plugins/command-center/context.test.ts create mode 100644 packages/app/src/plugins/command-center/context.ts create mode 100644 packages/app/src/plugins/command-center/contributions.test.ts create mode 100644 packages/app/src/plugins/command-center/contributions.ts create mode 100644 packages/app/src/plugins/command-center/registration.tsx create mode 100644 packages/app/src/plugins/routes.test.ts create mode 100644 packages/app/src/plugins/runtime-boundary.tsx create mode 100644 packages/app/src/plugins/surface-contribution.test.ts create mode 100644 packages/app/src/plugins/surface-contribution.ts create mode 100644 packages/app/src/plugins/workspace-panels/panel.tsx create mode 100644 packages/app/src/plugins/workspace-panels/resolution.test.ts create mode 100644 packages/app/src/plugins/workspace-panels/resolution.ts create mode 100644 packages/plugin/src/client-state.tsx create mode 100644 packages/plugin/src/shallow.test.ts create mode 100644 packages/plugin/src/shallow.ts create mode 100644 packages/plugin/src/use-sync-external-store.d.ts diff --git a/docs/expo-router.md b/docs/expo-router.md index cf85f00c0..2f586fd80 100644 --- a/docs/expo-router.md +++ b/docs/expo-router.md @@ -17,7 +17,9 @@ Each layout owns only the routes directly inside its directory. `h/[serverId]/index`. - `packages/app/src/app/h/[serverId]/_layout.tsx` owns the host leaves with relative screen names: `index`, `workspace/[workspaceId]/index`, - `agent/[agentId]`, `sessions`, `open-project`, and `settings`. + `agent/[agentId]`, `sessions`, `open-project`, `settings`, + `plugin/[pluginId]/[surfaceId]`, and + `plugin/[pluginId]/[contributionKind]/[contributionId]`. Expo Router warns with `[Layout children]: No route named ...` when a layout registers grandchildren. Treat that warning as a route-tree bug. On native, that diff --git a/docs/plugins.md b/docs/plugins.md index f787d7de9..dbd2f2b3d 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,8 +1,9 @@ # Local plugins -Local plugins contribute daemon RPCs, native app surfaces, and composer attachment sources from one -`index.tsx`. Paseo executes the server contribution in a subprocess and evaluates the client -contribution in the app runtime. Plugin code is trusted code; this first slice does not sandbox it. +Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items, +and composer attachment sources from one `index.tsx`. Paseo executes the server contribution in a +subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code; +this first slice does not sandbox it. ## Install a directory source @@ -136,7 +137,7 @@ export default function contribute(plugin: PluginContext) { icon: "MessageCircle", surface: "main", }); - return () => undefined; + return () => {}; } ``` @@ -170,6 +171,21 @@ When the same plugin contribution exists on multiple hosts, Paseo shows it once adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and query cache. Plugin code cannot address another host. +Workspace panels and Command Center items remain client contributions. The daemon transports their +compiled bundle without interpreting placement or callbacks. Panel props contain workspace and agent +IDs. Required-selector hooks read normalized client state synchronously and use shallow equality, so a +panel does not subscribe to fields it does not render. Command callbacks materialize their snapshots +only when invoked. Contribution discovery and panel opening never fetch active context through plugin +RPC. Snapshot DTOs are deeply readonly and frozen at runtime so plugin code cannot mutate normalized +app state or a memoized selection. Panels use one persisted +`plugin` workspace-tab target, so reload, disable, removal, and restoration resolve through the +current installed-plugin catalog. A missing contribution renders unavailable inside the tab. + +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. + ## Contribute composer attachments Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu, @@ -207,7 +223,7 @@ const issues = defineAttachmentSource({ export default function contribute(plugin: PluginContext) { plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query)); plugin.addAttachmentSource(issues); - return () => undefined; + return () => {}; } ``` diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 9dd302c37..566597c12 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-WblXeNn1wUUaxElDGNXHcWdvvHJhqp7LySanhLDv5lQ= +sha256-HNuEVNenfuV4EfbbhPdn/33/1VHteTsH/L634coB7EU= diff --git a/package-lock.json b/package-lock.json index 0cd688576..6415d189d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38687,6 +38687,9 @@ "packages/plugin": { "name": "@paseo/plugin", "version": "0.3.0", + "dependencies": { + "use-sync-external-store": "^1.6.0" + }, "devDependencies": { "@getpaseo/client": "0.4.0", "@types/node": "^20.9.0", diff --git a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts new file mode 100644 index 000000000..708076d22 --- /dev/null +++ b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts @@ -0,0 +1,247 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { TestInfo } from "@playwright/test"; +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { openCommandCenter } from "../support/helpers/command-center"; +import { addConnectedHostAndReload } from "../support/helpers/hosts"; +import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon"; +import { buildAgentRoute } from "../support/helpers/mock-agent"; +import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { + closeMobileAgentSidebar, + expectMobileAgentSidebarHidden, +} from "../support/helpers/sidebar"; +import { + switchWorkspaceViaSidebar, + waitForWorkspaceInSidebar, +} from "../support/helpers/workspace-ui"; + +const PLUGIN_ID = "workspace-panel-e2e"; +const WIDE_VIEWPORT = { width: 1280, height: 900 }; +const COMPACT_VIEWPORT = { width: 390, height: 844 }; + +function isSettledWorkspaceUrl(url: URL): boolean { + return url.pathname.includes("/workspace/") && !url.searchParams.has("open"); +} + +function pluginSource(): string { + return `import React, { useRef } from "react"; +import { Text, View } from "react-native"; +import { useAgent, useWorkspace } from "@paseo/plugin"; + +function WorkspacePanel({ workspaceId, host, layout }) { + const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id })); + const renderCount = useRef(0); + renderCount.current += 1; + return Workspace bridge {workspace?.id}Workspace renders {renderCount.current}Host {host.id}Layout {layout.compact ? "compact" : "wide"}; +} + +function AgentPanel({ workspaceId, agentId, host, layout }) { + const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id })); + const agent = useAgent(agentId, (value) => ({ id: value.id })); + return Agent bridge {agent?.id}Workspace {workspace?.id}Host {host.id}Layout {layout.compact ? "compact" : "wide"}; +} + +function DirectCollisionSurface() { + return Direct collision surface; +} + +function SidebarCollisionSurface() { + return Sidebar collision surface; +} + +export default function contribute(plugin) { + plugin.addSurface("collision", DirectCollisionSurface); + plugin.addSurface("sidebar-destination", SidebarCollisionSurface); + plugin.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" }); + plugin.addWorkspacePanel({ id: "workspace", title: "Workspace inspector", icon: "PanelsTopLeft", context: "workspace", Component: WorkspacePanel }); + plugin.addWorkspacePanel({ id: "agent", title: "Agent inspector", icon: "PanelTop", context: "agent", Component: AgentPanel }); + plugin.addCommandCenterItem({ id: "global", title: "Plugin global action", icon: "Blocks", context: "global", onSelect() {} }); + plugin.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } }); + plugin.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } }); + plugin.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } }); + return () => {}; +}`; +} + +async function searchCommands(page: Page, query: string) { + const panel = await openCommandCenter(page); + await panel.getByTestId("command-center-input").fill(query); + return panel; +} + +async function expectCommandAvailable(page: Page, title: string): Promise { + const panel = await searchCommands(page, title); + await expect(panel.getByRole("button", { name: title, exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); +} + +async function expectCommandUnavailable(page: Page, title: string): Promise { + const panel = await searchCommands(page, title); + await expect(panel.getByRole("button", { name: title, exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); +} + +async function runCommand(page: Page, title: string): Promise { + const panel = await searchCommands(page, title); + await panel.getByRole("button", { name: title, exact: true }).click(); + await expect(panel).not.toBeVisible(); +} + +async function openCompactSidebar(page: Page): Promise { + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible(); +} + +async function capture(page: Page, testInfo: TestInfo, name: string): Promise { + const screenshot = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path: screenshot }); + await testInfo.attach(name, { path: screenshot, contentType: "image/png" }); +} + +test.describe("plugin workspace panels and Command Center", () => { + test.describe.configure({ timeout: 240_000 }); + + test("follows workspace, agent, host, compact, and unavailable state", async ({ + page, + }, testInfo) => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-workspace-panel-e2e-")); + const primaryClient = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const previousConfig = await primaryClient.getDaemonConfig(); + const primary = await seedWorkspace({ repoPrefix: "plugin-panel-primary-" }); + const secondaryDaemon = await startIsolatedHostDaemon("plugin-panel-secondary"); + const secondary = await seedWorkspace({ + repoPrefix: "plugin-panel-secondary-", + port: secondaryDaemon.port, + }); + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID })); + await writeFile(path.join(directory, "index.tsx"), pluginSource()); + + try { + await primaryClient.patchDaemonConfig({ pluginsEnabled: true }); + await primaryClient.installDirectoryPlugin(directory); + await page.setViewportSize(WIDE_VIEWPORT); + await gotoAppShell(page); + await addConnectedHostAndReload(page, { + serverId: secondaryDaemon.serverId, + label: "Secondary plugin host", + port: secondaryDaemon.port, + primaryLabel: "Primary plugin host", + }); + await waitForWorkspaceInSidebar(page, { + serverId: getServerId(), + workspaceId: primary.workspaceId, + }); + await waitForWorkspaceInSidebar(page, { + serverId: secondaryDaemon.serverId, + workspaceId: secondary.workspaceId, + }); + + await test.step("workspace context opens the real wide panel bridge", async () => { + await switchWorkspaceViaSidebar({ + page, + serverId: getServerId(), + workspaceId: primary.workspaceId, + }); + await expectCommandAvailable(page, "Plugin global action"); + await expectCommandAvailable(page, "Open plugin workspace"); + await expectCommandUnavailable(page, "Open plugin agent"); + await runCommand(page, "Open plugin workspace"); + await expect(page.getByText(`Workspace bridge ${primary.workspaceId}`)).toBeVisible(); + const renderCount = Number( + (await page.getByText(/Workspace renders \d+/).textContent())?.split(" ").at(-1), + ); + await primaryClient.setWorkspaceTitle(primary.workspaceId, "Unrelated title update"); + await expect(page.getByTestId("workspace-header-title")).toHaveText( + "Unrelated title update", + ); + expect( + Number((await page.getByText(/Workspace renders \d+/).textContent())?.split(" ").at(-1)), + ).toBe(renderCount); + await expect(page.getByText(`Host ${getServerId()}`, { exact: true })).toBeVisible(); + await expect(page.getByText("Layout wide", { exact: true })).toBeVisible(); + await capture(page, testInfo, "plugin-workspace-panel-wide"); + }); + + await test.step("direct and sidebar routes preserve same-id contribution kind", async () => { + await runCommand(page, "Open direct collision surface"); + await expect(page.getByText("Direct collision surface", { exact: true })).toBeVisible(); + await expect(page.getByText("Sidebar collision surface", { exact: true })).toHaveCount(0); + await page.getByTestId("plugin-surface-close").click(); + + await page.getByTestId(`plugin-sidebar-${PLUGIN_ID}-collision`).click(); + await expect(page.getByText("Sidebar collision surface", { exact: true })).toBeVisible(); + await expect(page.getByText("Direct collision surface", { exact: true })).toHaveCount(0); + await capture(page, testInfo, "plugin-surface-kind-collision"); + await page.getByTestId("plugin-surface-close").click(); + }); + + await test.step("switching hosts removes commands from an uninstalled host", async () => { + await switchWorkspaceViaSidebar({ + page, + serverId: secondaryDaemon.serverId, + workspaceId: secondary.workspaceId, + }); + await expectCommandUnavailable(page, "Plugin global action"); + await expectCommandUnavailable(page, "Open plugin workspace"); + }); + + await test.step("legacy sidebar URLs survive a full browser navigation", async () => { + await page.goto(`/h/${encodeURIComponent(getServerId())}/plugin/${PLUGIN_ID}/collision`); + await page.waitForURL(new RegExp(`/plugin/${PLUGIN_ID}/sidebar/collision(?:\\?.*)?$`)); + await expect(page.getByText("Sidebar collision surface", { exact: true })).toBeVisible(); + }); + + await test.step("agent context opens the compact panel with synchronous snapshots", async () => { + const agent = await primary.client.createAgent({ + provider: "mock", + cwd: primary.repoPath, + workspaceId: primary.workspaceId, + title: "Plugin panel context agent", + model: "ten-second-stream", + modeId: "load-test", + }); + await page.goto(buildAgentRoute(primary.workspaceId, agent.id)); + await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 }); + await page.setViewportSize(COMPACT_VIEWPORT); + await openCompactSidebar(page); + await runCommand(page, "Open plugin agent"); + await closeMobileAgentSidebar(page); + await expectMobileAgentSidebarHidden(page); + await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible(); + await expect( + page.getByText(`Workspace ${primary.workspaceId}`, { exact: true }), + ).toBeVisible(); + await expect(page.getByText(`Host ${getServerId()}`, { exact: true })).toBeVisible(); + await expect(page.getByText("Layout compact", { exact: true })).toBeVisible(); + await capture(page, testInfo, "plugin-agent-panel-compact"); + }); + + await test.step("removing the active plugin renders the panel unavailable", async () => { + await primaryClient.removePlugin(PLUGIN_ID); + await expect( + page.getByText("This plugin panel is unavailable.", { exact: true }), + ).toBeVisible({ + timeout: 30_000, + }); + await capture(page, testInfo, "plugin-panel-unavailable"); + await openCompactSidebar(page); + await expectCommandUnavailable(page, "Open plugin agent"); + }); + } finally { + await primaryClient.removePlugin(PLUGIN_ID).catch(() => undefined); + await primaryClient + .patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false }) + .catch(() => undefined); + await primaryClient.close().catch(() => undefined); + await primary.cleanup().catch(() => undefined); + await secondary.cleanup().catch(() => undefined); + await secondaryDaemon.close().catch(() => undefined); + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index f53cb50c1..6ac5d8987 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -19,6 +19,7 @@ type NewWorkspaceDaemonClient = Pick< | "fetchWorkspaces" | "getPaseoWorktreeList" | "getDaemonConfig" + | "installDirectoryPlugin" | "inspectWorkspaceRecovery" | "listProjects" | "on" @@ -26,6 +27,7 @@ type NewWorkspaceDaemonClient = Pick< | "removeProject" | "removePlugin" | "reloadPlugin" + | "setWorkspaceTitle" >; type CreateWorkspacePayload = Awaited>; diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index d4b6d9b48..222766065 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -25,6 +25,7 @@ import { CommandCenter } from "@/command-center/command-center"; import { CommandCenterRootActions } from "@/command-center/root-registration"; import { CommandCenterProvider } from "@/command-center/provider"; import { CommandCenterWorkspaceActions } from "@/command-center/workspace-registration"; +import { PluginCommandCenterActions } from "@/plugins/command-center/registration"; import { AddProjectFlowHost } from "@/components/add-project-flow-host"; import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source"; @@ -590,6 +591,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon + diff --git a/packages/app/src/app/h/[serverId]/_layout.tsx b/packages/app/src/app/h/[serverId]/_layout.tsx index b40299f5e..1a47093e2 100644 --- a/packages/app/src/app/h/[serverId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/_layout.tsx @@ -42,6 +42,7 @@ function KnownHostRoute() { + ); diff --git a/packages/app/src/app/h/[serverId]/plugin/[pluginId]/[contributionKind]/[contributionId].tsx b/packages/app/src/app/h/[serverId]/plugin/[pluginId]/[contributionKind]/[contributionId].tsx new file mode 100644 index 000000000..c92e97c3a --- /dev/null +++ b/packages/app/src/app/h/[serverId]/plugin/[pluginId]/[contributionKind]/[contributionId].tsx @@ -0,0 +1,5 @@ +import { PluginSurfaceScreen } from "@/plugins"; + +export default function PluginSurfaceRoute() { + return ; +} diff --git a/packages/app/src/app/h/[serverId]/plugin/[pluginId]/[surfaceId].tsx b/packages/app/src/app/h/[serverId]/plugin/[pluginId]/[surfaceId].tsx index c92e97c3a..b80bb3584 100644 --- a/packages/app/src/app/h/[serverId]/plugin/[pluginId]/[surfaceId].tsx +++ b/packages/app/src/app/h/[serverId]/plugin/[pluginId]/[surfaceId].tsx @@ -1,5 +1,23 @@ -import { PluginSurfaceScreen } from "@/plugins"; +import { Redirect, useLocalSearchParams } from "expo-router"; +import { buildLegacyPluginSurfaceRedirectRoute } from "@/plugins"; -export default function PluginSurfaceRoute() { - return ; +function routeParam(value: string | string[] | undefined): string { + return typeof value === "string" ? value : ""; +} + +export default function LegacyPluginSurfaceRoute() { + const params = useLocalSearchParams<{ + serverId?: string | string[]; + pluginId?: string | string[]; + surfaceId?: string | string[]; + }>(); + return ( + + ); } diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index 972210134..cb6c86dad 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -9,6 +9,7 @@ import { setupPanelRegistration } from "@/panels/setup-panel"; import { terminalPanelRegistration } from "@/panels/terminal-panel"; import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel"; import { pullRequestPanelRegistration } from "@/panels/pull-request-panel"; +import { pluginPanelRegistration } from "@/plugins/workspace-panels/panel"; let panelsRegistered = false; @@ -27,5 +28,6 @@ export function ensurePanelsRegistered(): void { registerPanel(pullRequestPanelRegistration); registerPanel(commitDiffPanelRegistration); registerPanel(workingDiffPanelRegistration); + registerPanel(pluginPanelRegistration); panelsRegistered = true; } diff --git a/packages/app/src/plugins/client-state/snapshots.test.ts b/packages/app/src/plugins/client-state/snapshots.test.ts new file mode 100644 index 000000000..26ac4069d --- /dev/null +++ b/packages/app/src/plugins/client-state/snapshots.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; +import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots"; + +const workspace: WorkspaceDescriptor = { + id: "workspace-1", + projectId: "project-1", + projectDisplayName: "Paseo", + projectRootPath: "/repo/paseo", + workspaceDirectory: "/repo/paseo/review", + projectKind: "git", + workspaceKind: "worktree", + name: "Review", + title: "Plugin review", + pinnedAt: null, + status: "running", + statusEnteredAt: new Date("2026-08-16T10:00:00.000Z"), + archivingAt: null, + diffStat: { additions: 12, deletions: 3 }, + scripts: [], +}; + +const agent: Agent = { + serverId: "host-1", + id: "agent-1", + provider: "codex", + status: "running", + activeTurn: null, + createdAt: new Date("2026-08-16T10:01:00.000Z"), + updatedAt: new Date("2026-08-16T10:02:00.000Z"), + lastUserMessageAt: null, + lastActivityAt: new Date("2026-08-16T10:03:00.000Z"), + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: "code", + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Implement plugin panels", + cwd: "/repo/paseo/review", + workspaceId: "workspace-1", + model: "gpt-5.6", + thinkingOptionId: "high", + requiresAttention: true, + attentionReason: "permission", + parentAgentId: null, + labels: { phase: "implementation" }, +}; + +describe("plugin context snapshots", () => { + it("commits a deeply immutable normalized workspace snapshot synchronously", () => { + const snapshot = createPluginWorkspaceSnapshot(workspace); + expect(createPluginWorkspaceSnapshot(workspace)).toBe(snapshot); + + expect(snapshot).toEqual({ + id: "workspace-1", + projectId: "project-1", + projectDisplayName: "Paseo", + projectRootPath: "/repo/paseo", + directory: "/repo/paseo/review", + projectKind: "git", + kind: "worktree", + name: "Review", + title: "Plugin review", + status: "running", + statusEnteredAt: "2026-08-16T10:00:00.000Z", + archivingAt: null, + diffStat: { additions: 12, deletions: 3 }, + }); + expect(snapshot.diffStat).not.toBe(workspace.diffStat); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.diffStat)).toBe(true); + expect(Reflect.set(snapshot, "name", "Hostile rename")).toBe(false); + expect(Reflect.set(snapshot.diffStat!, "additions", 999)).toBe(false); + expect(snapshot.name).toBe("Review"); + expect(snapshot.diffStat).toEqual({ additions: 12, deletions: 3 }); + expect(workspace.name).toBe("Review"); + expect(workspace.diffStat).toEqual({ additions: 12, deletions: 3 }); + }); + + it("commits a deeply immutable matching agent snapshot without exposing store records", () => { + const snapshot = createPluginAgentSnapshot(agent, workspace.id); + expect(createPluginAgentSnapshot(agent, workspace.id)).toBe(snapshot); + + expect(snapshot).toEqual({ + id: "agent-1", + workspaceId: "workspace-1", + provider: "codex", + status: "running", + createdAt: "2026-08-16T10:01:00.000Z", + updatedAt: "2026-08-16T10:02:00.000Z", + lastActivityAt: "2026-08-16T10:03:00.000Z", + title: "Implement plugin panels", + cwd: "/repo/paseo/review", + model: "gpt-5.6", + currentModeId: "code", + thinkingOptionId: "high", + requiresAttention: true, + attentionReason: "permission", + parentAgentId: null, + labels: { phase: "implementation" }, + }); + expect(snapshot.labels).not.toBe(agent.labels); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.labels)).toBe(true); + expect(Reflect.set(snapshot, "title", "Hostile rename")).toBe(false); + expect(Reflect.set(snapshot.labels, "phase", "hostile")).toBe(false); + expect(snapshot.title).toBe("Implement plugin panels"); + expect(snapshot.labels).toEqual({ phase: "implementation" }); + expect(agent.title).toBe("Implement plugin panels"); + expect(agent.labels).toEqual({ phase: "implementation" }); + }); +}); diff --git a/packages/app/src/plugins/client-state/snapshots.ts b/packages/app/src/plugins/client-state/snapshots.ts new file mode 100644 index 000000000..30d0767fd --- /dev/null +++ b/packages/app/src/plugins/client-state/snapshots.ts @@ -0,0 +1,57 @@ +import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "@paseo/plugin"; +import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; + +const workspaceSnapshots = new WeakMap(); +const agentSnapshots = new WeakMap>(); + +export function createPluginWorkspaceSnapshot( + workspace: WorkspaceDescriptor, +): PluginWorkspaceSnapshot { + const cached = workspaceSnapshots.get(workspace); + if (cached) return cached; + const diffStat = workspace.diffStat ? Object.freeze({ ...workspace.diffStat }) : null; + const snapshot = Object.freeze({ + id: workspace.id, + projectId: workspace.projectId, + projectDisplayName: workspace.projectDisplayName, + projectRootPath: workspace.projectRootPath, + directory: workspace.workspaceDirectory, + projectKind: workspace.projectKind, + kind: workspace.workspaceKind, + name: workspace.name, + title: workspace.title ?? null, + status: workspace.status, + statusEnteredAt: workspace.statusEnteredAt?.toISOString() ?? null, + archivingAt: workspace.archivingAt, + diffStat, + }); + workspaceSnapshots.set(workspace, snapshot); + return snapshot; +} + +export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): PluginAgentSnapshot { + const cached = agentSnapshots.get(agent)?.get(workspaceId); + if (cached) return cached; + const snapshot = Object.freeze({ + id: agent.id, + workspaceId, + provider: agent.provider, + status: agent.status, + createdAt: agent.createdAt.toISOString(), + updatedAt: agent.updatedAt.toISOString(), + lastActivityAt: agent.lastActivityAt.toISOString(), + title: agent.title, + cwd: agent.cwd, + model: agent.model, + currentModeId: agent.currentModeId, + thinkingOptionId: agent.thinkingOptionId ?? null, + requiresAttention: agent.requiresAttention ?? false, + attentionReason: agent.attentionReason ?? null, + parentAgentId: agent.parentAgentId, + labels: Object.freeze({ ...agent.labels }), + }); + const byWorkspace = agentSnapshots.get(agent) ?? new Map(); + byWorkspace.set(workspaceId, snapshot); + agentSnapshots.set(agent, byWorkspace); + return snapshot; +} diff --git a/packages/app/src/plugins/client-state/source.ts b/packages/app/src/plugins/client-state/source.ts new file mode 100644 index 000000000..652ae90d4 --- /dev/null +++ b/packages/app/src/plugins/client-state/source.ts @@ -0,0 +1,21 @@ +import type { PluginClientStateSource } from "@paseo/plugin/host"; +import { useSessionStore } from "@/stores/session-store"; +import { selectWorkspace } from "@/stores/session-store-hooks/selectors"; +import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; +import { createPluginAgentSnapshot, createPluginWorkspaceSnapshot } from "./snapshots"; + +export function createPluginClientStateSource(serverId: string): PluginClientStateSource { + return { + subscribe: useSessionStore.subscribe, + getWorkspace(workspaceId) { + const workspace = selectWorkspace(useSessionStore.getState(), serverId, workspaceId); + return workspace ? createPluginWorkspaceSnapshot(workspace) : null; + }, + getAgent(agentId) { + const session = useSessionStore.getState().sessions[serverId]; + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null; + const workspaceId = normalizeWorkspaceOpaqueId(agent?.workspaceId); + return agent && workspaceId ? createPluginAgentSnapshot(agent, workspaceId) : null; + }, + }; +} diff --git a/packages/app/src/plugins/command-center/context.test.ts b/packages/app/src/plugins/command-center/context.test.ts new file mode 100644 index 000000000..204c5293a --- /dev/null +++ b/packages/app/src/plugins/command-center/context.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { normalizeLayout, type WorkspaceLayout } from "@/stores/workspace-layout-store"; +import { getFocusedAgentId } from "./context"; + +function layout(target: import("@/workspace-tabs/model").WorkspaceTabTarget): WorkspaceLayout { + return normalizeLayout({ + focusedPaneId: "main", + root: { + kind: "pane", + pane: { + id: "main", + tabIds: ["focused"], + focusedTabId: "focused", + tabs: [{ tabId: "focused", target, createdAt: 1 }], + }, + }, + }); +} + +describe("plugin Command Center agent context", () => { + it("uses the focused agent tab", () => { + expect(getFocusedAgentId(layout({ kind: "agent", agentId: "agent-1" }))).toBe("agent-1"); + }); + + it("preserves agent context while its plugin panel is focused", () => { + expect( + getFocusedAgentId( + layout({ + kind: "plugin", + pluginId: "review", + panelId: "details", + context: "agent", + agentId: "agent-1", + }), + ), + ).toBe("agent-1"); + }); + + it("does not invent agent context for workspace panels", () => { + expect( + getFocusedAgentId( + layout({ + kind: "plugin", + pluginId: "review", + panelId: "details", + context: "workspace", + }), + ), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/plugins/command-center/context.ts b/packages/app/src/plugins/command-center/context.ts new file mode 100644 index 000000000..879976eea --- /dev/null +++ b/packages/app/src/plugins/command-center/context.ts @@ -0,0 +1,18 @@ +import { + collectAllTabs, + findPaneById, + type WorkspaceLayout, +} from "@/stores/workspace-layout-store"; + +export function getFocusedAgentId(layout: WorkspaceLayout | null): string | null { + if (!layout) return null; + const pane = findPaneById(layout.root, layout.focusedPaneId); + if (!pane?.focusedTabId) return null; + const tab = collectAllTabs(layout.root).find( + (candidate) => candidate.tabId === pane.focusedTabId, + ); + if (tab?.target.kind === "agent") return tab.target.agentId; + return tab?.target.kind === "plugin" && tab.target.context === "agent" + ? tab.target.agentId + : null; +} diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts new file mode 100644 index 000000000..488144e7a --- /dev/null +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -0,0 +1,230 @@ +import { QueryClient } from "@tanstack/react-query"; +import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { + defineRpc, + type PluginAgentSnapshot, + type PluginCommandCenterItemContribution, + type PluginWorkspaceSnapshot, +} from "@paseo/plugin"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import type { InstalledPlugin } from "../types"; +import { buildPluginCommandCenterContributions } from "./contributions"; + +const workspace: PluginWorkspaceSnapshot = { + id: "workspace-1", + projectId: "project-1", + projectDisplayName: "Paseo", + projectRootPath: "/repo/paseo", + directory: "/repo/paseo/review", + projectKind: "git", + kind: "worktree", + name: "Review", + title: null, + status: "running", + statusEnteredAt: null, + archivingAt: null, + diffStat: null, +}; + +const agent: PluginAgentSnapshot = { + id: "agent-1", + workspaceId: workspace.id, + provider: "codex", + status: "running", + createdAt: "2026-08-16T10:00:00.000Z", + updatedAt: "2026-08-16T10:00:00.000Z", + lastActivityAt: "2026-08-16T10:00:00.000Z", + title: "Review", + cwd: workspace.directory, + model: "gpt-5.6", + currentModeId: null, + thinkingOptionId: null, + requiresAttention: false, + attentionReason: null, + parentAgentId: null, + labels: {}, +}; + +const inspect = defineRpc({ + name: "review.inspect", + input: z.object({ value: z.number() }), + output: z.object({ value: z.number() }), +}); + +type AgentCommandItem = Extract; + +function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { + return { + id: "review", + serverId: "host-1", + clientBundle: "bundle", + queryClient: new QueryClient(), + cleanup: () => {}, + surfaces: [{ id: "main", Component: () => null }], + sidebarItems: [], + workspacePanels: [ + { + id: "details", + title: "Details", + icon: "Scan", + context: "agent", + Component: () => null, + }, + ], + commandCenterItems: [ + { + id: "global", + title: "Global review", + icon: "Scan", + context: "global", + onSelect: () => {}, + }, + { + id: "workspace", + title: "Workspace review", + icon: "Scan", + context: "workspace", + onSelect: () => {}, + }, + { + id: "agent", + title: "Agent review", + icon: "Scan", + context: "agent", + onSelect: onAgentSelect, + }, + ], + attachmentSources: [], + }; +} + +function stateSource() { + return { + subscribe() { + return () => {}; + }, + getWorkspace(id: string) { + return id === workspace.id ? workspace : null; + }, + getAgent(id: string) { + return id === agent.id ? agent : null; + }, + }; +} + +function createRuntime(pluginId: string) { + const client = new DaemonClient({ + url: "ws://127.0.0.1:1", + clientId: "plugin-command-test", + clientType: "cli", + }); + return { + paseo: createPaseoApi(client), + invoke: async (method: string, input: unknown) => { + expect(pluginId).toBe("review"); + expect(method).toBe("review.inspect"); + return { value: inspect.input.parse(input).value + 1 }; + }, + }; +} + +describe("plugin Command Center contributions", () => { + it("shows only contributions whose synchronous context exists", () => { + const installed = plugin(() => undefined); + const common = { + plugins: [installed], + runtime: createRuntime, + state: stateSource(), + navigation: { + openSurface() {}, + openWorkspacePanel() {}, + openAgentPanel() {}, + }, + reportError() {}, + }; + + expect( + buildPluginCommandCenterContributions({ + ...common, + workspaceId: null, + agentId: null, + }).map((item) => item.id), + ).toEqual(["review:global"]); + expect( + buildPluginCommandCenterContributions({ + ...common, + workspaceId: workspace.id, + agentId: null, + }).map((item) => item.id), + ).toEqual(["review:global", "review:workspace"]); + expect( + buildPluginCommandCenterContributions({ + ...common, + workspaceId: workspace.id, + agentId: agent.id, + }).map((item) => item.id), + ).toEqual(["review:global", "review:workspace", "review:agent"]); + }); + + it("supplies the direct API, typed RPC, snapshots, and narrow navigation", async () => { + const opened: string[] = []; + let rpcValue = 0; + let receivedPaseo: PaseoApi | null = null; + const installed = plugin(async (context) => { + expect(context.workspace).toBe(workspace); + expect(context.agent).toBe(agent); + receivedPaseo = context.paseo; + rpcValue = (await context.rpc(inspect, { value: 4 })).value; + context.openSurface("main"); + context.openPanel("details"); + }); + const runtime = createRuntime("review"); + const actions = buildPluginCommandCenterContributions({ + plugins: [installed], + runtime: () => runtime, + state: stateSource(), + workspaceId: workspace.id, + agentId: agent.id, + navigation: { + openSurface(pluginId, surfaceId) { + opened.push(`${pluginId}/surface/${surfaceId}`); + }, + openWorkspacePanel(pluginId, panelId) { + opened.push(`${pluginId}/workspace/${panelId}`); + }, + openAgentPanel(pluginId, panelId, agentId) { + opened.push(`${pluginId}/agent/${panelId}/${agentId}`); + }, + }, + reportError(error) { + throw error; + }, + }); + + await actions.find((action) => action.id === "review:agent")?.run(); + + expect(rpcValue).toBe(5); + expect(receivedPaseo).toBe(runtime.paseo); + expect(opened).toEqual(["review/surface/main", "review/agent/details/agent-1"]); + }); + + it("removes every contribution when its installation disappears", () => { + expect( + buildPluginCommandCenterContributions({ + plugins: [], + runtime: createRuntime, + state: stateSource(), + workspaceId: workspace.id, + agentId: agent.id, + navigation: { + openSurface() {}, + openWorkspacePanel() {}, + openAgentPanel() {}, + }, + reportError() {}, + }), + ).toEqual([]); + }); +}); diff --git a/packages/app/src/plugins/command-center/contributions.ts b/packages/app/src/plugins/command-center/contributions.ts new file mode 100644 index 000000000..0a3d72465 --- /dev/null +++ b/packages/app/src/plugins/command-center/contributions.ts @@ -0,0 +1,117 @@ +import { callPluginRpc } from "@paseo/plugin/host"; +import type { PluginCommandCapabilities } from "@paseo/plugin"; +import type { PluginClientStateSource } from "@paseo/plugin/host"; +import type { CommandCenterContribution } from "@/command-center/contributions"; +import { getCommandCenterIcon } from "@/command-center/icon"; +import { resolvePluginIcon } from "../icons"; +import type { PluginSurfaceRuntime } from "../surface-runtime"; +import type { InstalledPlugin } from "../types"; + +export interface PluginCommandCenterNavigation { + openSurface(pluginId: string, surfaceId: string): void; + openWorkspacePanel(pluginId: string, panelId: string): void; + openAgentPanel(pluginId: string, panelId: string, agentId: string): void; +} + +export interface PluginCommandCenterSource { + plugins: readonly InstalledPlugin[]; + runtime(pluginId: string): PluginSurfaceRuntime; + state: PluginClientStateSource; + workspaceId: string | null; + agentId: string | null; + navigation: PluginCommandCenterNavigation; + reportError(error: unknown): void; +} + +function capabilities( + plugin: InstalledPlugin, + runtime: PluginSurfaceRuntime, + navigation: PluginCommandCenterNavigation, +): PluginCommandCapabilities { + return { + paseo: runtime.paseo, + rpc: (contract, input) => callPluginRpc(contract, runtime.invoke, input), + openSurface(surfaceId) { + if (!plugin.surfaces.some((surface) => surface.id === surfaceId)) { + throw new Error(`Plugin surface is unavailable: ${surfaceId}`); + } + navigation.openSurface(plugin.id, surfaceId); + }, + }; +} + +export function buildPluginCommandCenterContributions( + source: PluginCommandCenterSource, +): CommandCenterContribution[] { + const contributions: CommandCenterContribution[] = []; + for (const plugin of source.plugins) { + const runtime = source.runtime(plugin.id); + const common = capabilities(plugin, runtime, source.navigation); + for (const [rank, item] of plugin.commandCenterItems.entries()) { + if (item.context === "workspace" && !source.workspaceId) continue; + if (item.context === "agent" && (!source.workspaceId || !source.agentId)) continue; + const run = async () => { + try { + if (item.context === "global") { + await item.onSelect({ context: "global", ...common }); + return; + } + const workspace = source.workspaceId + ? source.state.getWorkspace(source.workspaceId) + : null; + if (!workspace) return; + if (item.context === "workspace") { + await item.onSelect({ + context: "workspace", + ...common, + workspace, + openPanel(panelId) { + const panel = plugin.workspacePanels.find( + (candidate) => candidate.id === panelId && candidate.context === "workspace", + ); + if (!panel) throw new Error(`Workspace panel is unavailable: ${panelId}`); + source.navigation.openWorkspacePanel(plugin.id, panelId); + }, + }); + return; + } + const agent = source.agentId ? source.state.getAgent(source.agentId) : null; + if (!agent) return; + await item.onSelect({ + context: "agent", + ...common, + workspace, + agent, + openPanel(panelId) { + const panel = plugin.workspacePanels.find((candidate) => candidate.id === panelId); + if (!panel) throw new Error(`Workspace panel is unavailable: ${panelId}`); + if (panel.context === "workspace") { + source.navigation.openWorkspacePanel(plugin.id, panelId); + return; + } + source.navigation.openAgentPanel(plugin.id, panelId, agent.id); + }, + }); + } catch (error) { + source.reportError(error); + } + }; + contributions.push({ + id: `${plugin.id}:${item.id}`, + group: `plugin:${plugin.id}`, + groupRank: 5, + rank, + keywords: item.keywords ?? [], + visibility: "always", + presentation: { + kind: "action", + title: item.title, + sectionTitle: plugin.id, + icon: getCommandCenterIcon(resolvePluginIcon(item.icon)), + }, + run, + }); + } + } + return contributions; +} diff --git a/packages/app/src/plugins/command-center/registration.tsx b/packages/app/src/plugins/command-center/registration.tsx new file mode 100644 index 000000000..8326266ba --- /dev/null +++ b/packages/app/src/plugins/command-center/registration.tsx @@ -0,0 +1,111 @@ +import { router, usePathname } from "expo-router"; +import { useMemo } from "react"; +import { useCommandCenterActions } from "@/command-center/provider"; +import { useToast } from "@/contexts/toast-context"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { + useActiveWorkspaceSelection, + navigateToWorkspace, +} from "@/stores/navigation-active-workspace-store"; +import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceExists } from "@/stores/session-store-hooks"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; +import { createPluginClientStateSource } from "../client-state/source"; +import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes"; +import { useInstalledPlugins } from "../registry"; +import { createPluginSurfaceRuntime } from "../surface-runtime"; +import { buildPluginCommandCenterContributions } from "./contributions"; +import { getFocusedAgentId } from "./context"; + +export function PluginCommandCenterActions() { + const pathname = usePathname(); + const selection = useActiveWorkspaceSelection(); + const serverId = selection?.serverId ?? hostIdFromPathname(pathname); + const workspaceId = selection?.workspaceId ?? null; + const workspaceExists = useWorkspaceExists(serverId, workspaceId); + const workspaceKey = serverId && workspaceId ? `${serverId}:${workspaceId}` : null; + const layout = useWorkspaceLayoutStore((state) => + workspaceKey ? (state.layoutByWorkspace[workspaceKey] ?? null) : null, + ); + const focusedAgentId = getFocusedAgentId(layout); + const agentExists = useSessionStore((state) => { + if (!serverId || !focusedAgentId) return null; + const session = state.sessions[serverId]; + const agent = + session?.agents.get(focusedAgentId) ?? session?.agentDetails.get(focusedAgentId) ?? null; + return ( + Boolean(agent) && + Boolean(workspaceId) && + normalizeWorkspaceOpaqueId(agent?.workspaceId) === normalizeWorkspaceOpaqueId(workspaceId) + ); + }); + const client = useHostRuntimeClient(serverId ?? ""); + const installed = useInstalledPlugins(); + const plugins = useMemo( + () => installed.filter((plugin) => plugin.serverId === serverId), + [installed, serverId], + ); + const stateSource = useMemo( + () => (serverId ? createPluginClientStateSource(serverId) : null), + [serverId], + ); + const toast = useToast(); + const actions = useMemo(() => { + if (!client || !serverId || !stateSource) return []; + return buildPluginCommandCenterContributions({ + plugins, + runtime(pluginId) { + const runtime = createPluginSurfaceRuntime(client, pluginId); + if (!runtime) throw new Error("Plugin host is offline"); + return runtime; + }, + state: stateSource, + workspaceId: workspaceExists ? workspaceId : null, + agentId: agentExists ? focusedAgentId : null, + navigation: { + openSurface(pluginId, surfaceId) { + router.push( + buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId }), + ); + }, + openWorkspacePanel(pluginId, panelId) { + if (!workspaceId) throw new Error("No active workspace"); + navigateToWorkspace({ + serverId, + workspaceId, + target: { kind: "plugin", pluginId, panelId, context: "workspace" }, + }); + }, + openAgentPanel(pluginId, panelId, agentId) { + if (!workspaceId) throw new Error("No active workspace"); + navigateToWorkspace({ + serverId, + workspaceId, + target: { kind: "plugin", pluginId, panelId, context: "agent", agentId }, + }); + }, + }, + reportError(error) { + toast.error(error instanceof Error ? error.message : String(error)); + }, + }); + }, [ + agentExists, + client, + focusedAgentId, + plugins, + serverId, + stateSource, + toast, + workspaceExists, + workspaceId, + ]); + + useCommandCenterActions({ + sourceId: serverId ? `plugins:${serverId}` : "plugins", + enabled: Boolean(client && plugins.length > 0), + actions, + }); + return null; +} diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 48831cc73..d78d7e35e 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -54,6 +54,71 @@ describe("evaluatePluginClientBundle", () => { ]); }); + it("collects contextual workspace panels and Command Center items", () => { + const plugin = evaluatePluginClientBundle( + "review", + bundle(` + function ReviewPanel() { return null; } + plugin.addWorkspacePanel({ + id: "review", + title: "Review", + icon: "Scan", + context: "agent", + Component: ReviewPanel, + }); + plugin.addCommandCenterItem({ + id: "open-review", + title: "Open review", + icon: "Scan", + context: "agent", + onSelect() {}, + }); + `), + ); + + expect( + plugin.workspacePanels.map(({ id, title, icon, context }) => ({ + id, + title, + icon, + context, + })), + ).toEqual([{ id: "review", title: "Review", icon: "Scan", context: "agent" }]); + expect( + plugin.commandCenterItems.map(({ id, title, icon, context }) => ({ + id, + title, + icon, + context, + })), + ).toEqual([{ id: "open-review", title: "Open review", icon: "Scan", context: "agent" }]); + }); + + it("rejects duplicate workspace panel and Command Center ids", () => { + expect(() => + evaluatePluginClientBundle( + "review", + bundle(` + function Panel() { return null; } + const panel = { id: "review", title: "Review", icon: "Scan", context: "workspace", Component: Panel }; + plugin.addWorkspacePanel(panel); + plugin.addWorkspacePanel(panel); + `), + ), + ).toThrow("Duplicate workspace panel: review"); + + expect(() => + evaluatePluginClientBundle( + "review", + bundle(` + const item = { id: "review", title: "Review", icon: "Scan", context: "global", onSelect() {} }; + plugin.addCommandCenterItem(item); + plugin.addCommandCenterItem(item); + `), + ), + ).toThrow("Duplicate Command Center item: review"); + }); + it("rejects duplicate attachment source ids", () => { expect(() => evaluatePluginClientBundle( diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index 6771813a8..59c38157a 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -8,9 +8,13 @@ import { defineAttachmentSource, defineRpc, type PluginAttachmentSourceContribution, + type PluginCommandCenterItemContribution, type PluginSidebarContribution, type PluginSurfaceProps, + type PluginWorkspacePanelContribution, usePaseo, + useAgent, + useWorkspace, useRpc, } from "@paseo/plugin"; import { createPluginContext, type PluginRegistrationCollector } from "@paseo/plugin/host"; @@ -30,10 +34,14 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate const collector: PluginRegistrationCollector = { surfaces: [], sidebarItems: [], + workspacePanels: [], + commandCenterItems: [], attachmentSources: [], }; const surfaceIds = new Set(); const sidebarItemIds = new Set(); + const workspacePanelIds = new Set(); + const commandCenterItemIds = new Set(); const attachmentSourceIds = new Set(); const pluginContext = createPluginContext({ addSurface(surfaceId: string, Component: ComponentType) { @@ -59,6 +67,54 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate surface: requireId(contribution.surface, "sidebar surface id"), }); }, + addWorkspacePanel(contribution: PluginWorkspacePanelContribution) { + const normalizedId = requireId(contribution.id, "workspace panel id"); + if (workspacePanelIds.has(normalizedId)) { + throw new Error(`Duplicate workspace panel: ${normalizedId}`); + } + const title = contribution.title.trim(); + const icon = contribution.icon.trim(); + if (!title) throw new Error(`Workspace panel ${normalizedId} has no title`); + if (!icon) throw new Error(`Workspace panel ${normalizedId} has no icon`); + if (contribution.context !== "workspace" && contribution.context !== "agent") { + throw new Error(`Workspace panel ${normalizedId} has invalid context`); + } + if (typeof contribution.Component !== "function") { + throw new Error(`Workspace panel ${normalizedId} is not a component`); + } + resolvePluginIcon(icon); + workspacePanelIds.add(normalizedId); + collector.workspacePanels.push({ ...contribution, id: normalizedId, title, icon }); + }, + addCommandCenterItem(contribution: PluginCommandCenterItemContribution) { + const normalizedId = requireId(contribution.id, "Command Center item id"); + if (commandCenterItemIds.has(normalizedId)) { + throw new Error(`Duplicate Command Center item: ${normalizedId}`); + } + const title = contribution.title.trim(); + const icon = contribution.icon.trim(); + if (!title) throw new Error(`Command Center item ${normalizedId} has no title`); + if (!icon) throw new Error(`Command Center item ${normalizedId} has no icon`); + if ( + contribution.context !== "global" && + contribution.context !== "workspace" && + contribution.context !== "agent" + ) { + throw new Error(`Command Center item ${normalizedId} has invalid context`); + } + if (typeof contribution.onSelect !== "function") { + throw new Error(`Command Center item ${normalizedId} has no callback`); + } + resolvePluginIcon(icon); + commandCenterItemIds.add(normalizedId); + collector.commandCenterItems.push({ + ...contribution, + id: normalizedId, + title, + icon, + keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean), + }); + }, addAttachmentSource(contribution: PluginAttachmentSourceContribution) { const normalizedId = requireId(contribution.id, "attachment source id"); if (attachmentSourceIds.has(normalizedId)) { @@ -92,7 +148,16 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate if (name === "react") return React; if (name === "react/jsx-runtime") return ReactJsxRuntime; if (name === "react-native") return ReactNative; - if (name === "@paseo/plugin") return { defineAttachmentSource, defineRpc, usePaseo, useRpc }; + if (name === "@paseo/plugin") { + return { + defineAttachmentSource, + defineRpc, + usePaseo, + useAgent, + useWorkspace, + useRpc, + }; + } if (name === "@tanstack/react-query") return ReactQuery; if (name === "zod") return Zod; throw new Error(`Module "${name}" is not available in plugin client code`); @@ -133,6 +198,8 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate cleanup, surfaces: collector.surfaces, sidebarItems: collector.sidebarItems, + workspacePanels: collector.workspacePanels, + commandCenterItems: collector.commandCenterItems, attachmentSources: collector.attachmentSources, }; } diff --git a/packages/app/src/plugins/index.ts b/packages/app/src/plugins/index.ts index dc74f3e07..6a3cf46aa 100644 --- a/packages/app/src/plugins/index.ts +++ b/packages/app/src/plugins/index.ts @@ -1,4 +1,5 @@ export { PluginCatalogSync } from "./catalog-sync"; +export { buildLegacyPluginSurfaceRedirectRoute } from "./routes"; export { PluginSidebarItems } from "./sidebar-items"; export { PluginSurfaceScreen } from "./surface-screen"; export { usePluginAttachmentPicker } from "./attachments/picker"; diff --git a/packages/app/src/plugins/registry.test.ts b/packages/app/src/plugins/registry.test.ts index 1b1a56343..710abbe82 100644 --- a/packages/app/src/plugins/registry.test.ts +++ b/packages/app/src/plugins/registry.test.ts @@ -8,6 +8,8 @@ function bundle(marker: string): string { function Surface() { return ${JSON.stringify(marker)}; } plugin.addSurface("main", Surface); plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "main" }); + plugin.addWorkspacePanel({ id: "review", title: "Review", icon: "Blocks", context: "workspace", Component: Surface }); + plugin.addCommandCenterItem({ id: "open-review", title: "Open review", icon: "Blocks", context: "workspace", onSelect() {} }); return function() { globalThis.__pluginCleanups = (globalThis.__pluginCleanups || 0) + 1; }; }; return module.exports; @@ -85,6 +87,8 @@ describe("PluginRegistry", () => { const second = pluginRegistry.getSnapshot()[0]; expect(second).not.toBe(first); + expect(second?.workspacePanels.map((panel) => panel.id)).toEqual(["review"]); + expect(second?.commandCenterItems.map((item) => item.id)).toEqual(["open-review"]); expect(first?.queryClient.getQueryData(["attachment-search"])).toBeUndefined(); expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1); }); diff --git a/packages/app/src/plugins/routes.test.ts b/packages/app/src/plugins/routes.test.ts new file mode 100644 index 000000000..18c6bfb1a --- /dev/null +++ b/packages/app/src/plugins/routes.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { buildLegacyPluginSurfaceRedirectRoute, buildPluginSurfaceRoute } from "./routes"; + +describe("buildPluginSurfaceRoute", () => { + it("keeps direct surfaces and sidebar contributions in separate route namespaces", () => { + expect(buildPluginSurfaceRoute("host/one", "review", { kind: "surface", id: "overview" })).toBe( + "/h/host%2Fone/plugin/review/surface/overview", + ); + expect(buildPluginSurfaceRoute("host/one", "review", { kind: "sidebar", id: "overview" })).toBe( + "/h/host%2Fone/plugin/review/sidebar/overview", + ); + }); + + it("redirects legacy plugin surface URLs to their sidebar contribution identity", () => { + expect(buildLegacyPluginSurfaceRedirectRoute("host/one", "review", "overview/item")).toBe( + "/h/host%2Fone/plugin/review/sidebar/overview%2Fitem", + ); + }); +}); diff --git a/packages/app/src/plugins/routes.ts b/packages/app/src/plugins/routes.ts index f59ebd602..d4fbf2ebe 100644 --- a/packages/app/src/plugins/routes.ts +++ b/packages/app/src/plugins/routes.ts @@ -1,9 +1,27 @@ -export function buildPluginSurfaceRoute( +import type { PluginSurfaceContributionIdentity } from "./surface-contribution"; + +type PluginSurfaceRoute = + `/h/${string}/plugin/${string}/${Kind}/${string}`; + +export function buildPluginSurfaceRoute( serverId: string, pluginId: string, - contributionId: string, -): `/h/${string}/plugin/${string}/${string}` { - return `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${encodeURIComponent(contributionId)}`; + identity: Identity, +): PluginSurfaceRoute { + return `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${identity.kind}/${encodeURIComponent(identity.id)}` as PluginSurfaceRoute< + Identity["kind"] + >; +} + +export function buildLegacyPluginSurfaceRedirectRoute( + serverId: string, + pluginId: string, + sidebarContributionId: string, +): `/h/${string}/plugin/${string}/sidebar/${string}` { + return buildPluginSurfaceRoute(serverId, pluginId, { + kind: "sidebar", + id: sidebarContributionId, + }); } export function hostIdFromPathname(pathname: string): string | null { diff --git a/packages/app/src/plugins/runtime-boundary.tsx b/packages/app/src/plugins/runtime-boundary.tsx new file mode 100644 index 000000000..9d7e0a782 --- /dev/null +++ b/packages/app/src/plugins/runtime-boundary.tsx @@ -0,0 +1,23 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { PaseoApiProvider, PluginRpcProvider } from "@paseo/plugin/host"; +import type { ReactNode } from "react"; +import type { InstalledPlugin } from "./types"; +import type { PluginSurfaceRuntime } from "./surface-runtime"; + +export function PluginRuntimeBoundary({ + plugin, + runtime, + children, +}: { + plugin: InstalledPlugin; + runtime: PluginSurfaceRuntime; + children: ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index 81998a4f2..ba2caaf2f 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -19,6 +19,8 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin { surface: "surface", }, ], + workspacePanels: [], + commandCenterItems: [], attachmentSources: [], }; } @@ -40,6 +42,9 @@ describe("groupPluginSidebarContributions", () => { installed("host-b", "settings"), ]); - expect(groups.map((group) => group.key)).toEqual(["example/main", "example/settings"]); + expect(groups.map((group) => group.key)).toEqual([ + "example/sidebar/main", + "example/sidebar/settings", + ]); }); }); diff --git a/packages/app/src/plugins/sidebar-groups.ts b/packages/app/src/plugins/sidebar-groups.ts index e61d37c8f..04fe2eccc 100644 --- a/packages/app/src/plugins/sidebar-groups.ts +++ b/packages/app/src/plugins/sidebar-groups.ts @@ -20,7 +20,7 @@ export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): Plu const groups = new Map(); for (const plugin of plugins) { for (const item of plugin.sidebarItems) { - const key = `${plugin.id}/${item.id}`; + const key = `${plugin.id}/sidebar/${item.id}`; const existing = groups.get(key); if (existing) { existing.targets.push({ plugin, item }); diff --git a/packages/app/src/plugins/sidebar-items.tsx b/packages/app/src/plugins/sidebar-items.tsx index f2cc614cc..f78c49913 100644 --- a/packages/app/src/plugins/sidebar-items.tsx +++ b/packages/app/src/plugins/sidebar-items.tsx @@ -52,15 +52,17 @@ function PluginSidebarItemRow({ onBeforeNavigate?: () => void; }) { const target = selectTarget(group, currentHostId); - const route = buildPluginSurfaceRoute( - target.plugin.serverId, - group.pluginId, - group.contributionId, - ); + const route = buildPluginSurfaceRoute(target.plugin.serverId, group.pluginId, { + kind: "sidebar", + id: group.contributionId, + }); const isActive = group.targets.some( (candidate) => pathname === - buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, group.contributionId), + buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, { + kind: "sidebar", + id: group.contributionId, + }), ); const navigate = useCallback(() => { rememberPluginContributionHost(group.key, target.plugin.serverId); diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts new file mode 100644 index 000000000..ac4cadd85 --- /dev/null +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -0,0 +1,79 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import type { InstalledPlugin } from "./types"; +import { + getPluginSurfaceContributionServerIds, + resolvePluginSurfaceContribution, +} from "./surface-contribution"; + +function installation( + serverId: string, + surfaces: string[], + sidebarItems: Array<{ id: string; surface: string }> = [], + pluginId = "review", +): InstalledPlugin { + return { + id: pluginId, + serverId, + clientBundle: serverId, + queryClient: new QueryClient(), + cleanup: () => undefined, + surfaces: surfaces.map((id) => ({ id, Component: () => null })), + sidebarItems: sidebarItems.map((item) => ({ + ...item, + title: item.id, + icon: "Blocks", + })), + workspacePanels: [], + commandCenterItems: [], + attachmentSources: [], + }; +} + +describe("plugin surface contribution identity", () => { + const installations = [ + installation( + "host-v1", + ["overview", "surface-v1"], + [{ id: "overview", surface: "surface-v1" }], + ), + installation( + "host-v2", + ["overview", "surface-v2"], + [{ id: "overview", surface: "surface-v2" }], + ), + installation("same-surface", ["overview"]), + installation("other-contribution", ["surface-v1"], [{ id: "other", surface: "surface-v1" }]), + installation( + "other-plugin", + ["overview", "surface-v1"], + [{ id: "overview", surface: "surface-v1" }], + "other", + ), + ]; + + it("resolves a sidebar contribution by its explicit identity across host version drift", () => { + const identity = { kind: "sidebar", id: "overview" } as const; + const resolved = resolvePluginSurfaceContribution(installations[0] ?? null, identity); + + expect(resolved.sidebarItem?.id).toBe("overview"); + expect(resolved.surface?.id).toBe("surface-v1"); + expect(getPluginSurfaceContributionServerIds(installations, "review", identity)).toEqual([ + "host-v1", + "host-v2", + ]); + }); + + it("does not let a same-id sidebar contribution capture a direct surface", () => { + const identity = { kind: "surface", id: "overview" } as const; + const resolved = resolvePluginSurfaceContribution(installations[0] ?? null, identity); + + expect(resolved.sidebarItem).toBeNull(); + expect(resolved.surface?.id).toBe("overview"); + expect(getPluginSurfaceContributionServerIds(installations, "review", identity)).toEqual([ + "host-v1", + "host-v2", + "same-surface", + ]); + }); +}); diff --git a/packages/app/src/plugins/surface-contribution.ts b/packages/app/src/plugins/surface-contribution.ts new file mode 100644 index 000000000..743ebbc1a --- /dev/null +++ b/packages/app/src/plugins/surface-contribution.ts @@ -0,0 +1,39 @@ +import type { InstalledPlugin } from "./types"; + +export type PluginSurfaceContributionIdentity = + | { kind: "sidebar"; id: string } + | { kind: "surface"; id: string }; + +export function resolvePluginSurfaceContribution( + plugin: InstalledPlugin | null, + identity: PluginSurfaceContributionIdentity | null, +): { + sidebarItem: InstalledPlugin["sidebarItems"][number] | null; + surface: InstalledPlugin["surfaces"][number] | null; +} { + if (!identity) return { sidebarItem: null, surface: null }; + const sidebarItem = + identity.kind === "sidebar" + ? (plugin?.sidebarItems.find((contribution) => contribution.id === identity.id) ?? null) + : null; + const surfaceId = identity.kind === "sidebar" ? sidebarItem?.surface : identity.id; + const surface = surfaceId + ? (plugin?.surfaces.find((contribution) => contribution.id === surfaceId) ?? null) + : null; + return { sidebarItem, surface }; +} + +export function getPluginSurfaceContributionServerIds( + installations: readonly InstalledPlugin[], + pluginId: string, + identity: PluginSurfaceContributionIdentity, +): string[] { + return installations + .filter((installation) => { + if (installation.id !== pluginId) return false; + return identity.kind === "sidebar" + ? installation.sidebarItems.some((contribution) => contribution.id === identity.id) + : installation.surfaces.some((contribution) => contribution.id === identity.id); + }) + .map((installation) => installation.serverId); +} diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx index 5ed3d0dcd..b77abf31f 100644 --- a/packages/app/src/plugins/surface-screen.tsx +++ b/packages/app/src/plugins/surface-screen.tsx @@ -1,9 +1,5 @@ import { router, useLocalSearchParams } from "expo-router"; -import { QueryClientProvider } from "@tanstack/react-query"; import type { PluginSurfaceProps } from "@paseo/plugin"; -import { PluginRpcProvider } from "@paseo/plugin/host"; -import { PaseoApiProvider } from "@paseo/plugin/host"; -import type { PaseoApi } from "@getpaseo/client"; import { ChevronDown, X } from "lucide-react-native"; import { useCallback, useMemo, useRef, useState, type ComponentType } from "react"; import { Platform, Pressable, Text, View } from "react-native"; @@ -23,6 +19,12 @@ import { buildPluginSurfaceRoute } from "./routes"; import { rememberPluginContributionHost } from "./sidebar-groups"; import { SurfaceErrorBoundary } from "./surface-error-boundary"; import { createPluginSurfaceRuntime } from "./surface-runtime"; +import { PluginRuntimeBoundary } from "./runtime-boundary"; +import { + getPluginSurfaceContributionServerIds, + resolvePluginSurfaceContribution, + type PluginSurfaceContributionIdentity, +} from "./surface-contribution"; const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = []; const EMPTY_THEME_DTO: Record = {}; @@ -51,29 +53,23 @@ const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon); function SurfaceRenderer({ Surface, - invoke, - paseo, + runtime, plugin, layout, host, themeDto = EMPTY_THEME_DTO, }: { Surface: ComponentType; - invoke(method: string, input: unknown): Promise; - paseo: PaseoApi; + runtime: NonNullable>; plugin: NonNullable>; layout: PluginSurfaceProps["layout"]; host: PluginSurfaceProps["host"]; themeDto?: Record; }) { return ( - - - - - - - + + + ); } @@ -88,12 +84,12 @@ function resolvePlatform(): PluginSurfaceProps["layout"]["platform"] { function PluginHostSwitcher({ serverId, pluginId, - contributionId, + identity, serverIds, }: { serverId: string; pluginId: string; - contributionId: string; + identity: PluginSurfaceContributionIdentity; serverIds: string[]; }) { const allHosts = useHosts(); @@ -106,10 +102,10 @@ function PluginHostSwitcher({ const selectedLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; const selectHost = useCallback( (nextServerId: string) => { - rememberPluginContributionHost(`${pluginId}/${contributionId}`, nextServerId); - router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, contributionId)); + rememberPluginContributionHost(`${pluginId}/${identity.kind}/${identity.id}`, nextServerId); + router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, identity)); }, - [contributionId, pluginId], + [identity, pluginId], ); const openPicker = useCallback(() => setOpen(true), []); const show = serverIds.length > 1 && hosts.length > 1; @@ -148,28 +144,34 @@ export function PluginSurfaceScreen() { const params = useLocalSearchParams<{ serverId?: string | string[]; pluginId?: string | string[]; - surfaceId?: string | string[]; + contributionKind?: string | string[]; + contributionId?: string | string[]; }>(); const serverId = routeParam(params.serverId); const pluginId = routeParam(params.pluginId); - const contributionId = routeParam(params.surfaceId); + const contributionKind = routeParam(params.contributionKind); + const contributionId = routeParam(params.contributionId); + const identity = useMemo(() => { + if (contributionKind !== "sidebar" && contributionKind !== "surface") return null; + return { kind: contributionKind, id: contributionId }; + }, [contributionId, contributionKind]); const plugin = useInstalledPlugin(serverId, pluginId); const installations = usePluginInstallations(pluginId); const hosts = useHosts(); const client = useHostRuntimeClient(serverId); const runtime = useMemo(() => createPluginSurfaceRuntime(client, pluginId), [client, pluginId]); const compact = useIsCompactFormFactor(); - const sidebarItem = plugin?.sidebarItems.find((entry) => entry.id === contributionId) ?? null; - const surface = plugin?.surfaces.find((entry) => entry.id === sidebarItem?.surface) ?? null; + const { sidebarItem, surface } = useMemo( + () => resolvePluginSurfaceContribution(plugin, identity), + [identity, plugin], + ); const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; const contributionServerIds = useMemo( () => - installations - .filter((entry) => entry.sidebarItems.some((item) => item.id === contributionId)) - .map((entry) => entry.serverId), - [contributionId, installations], + identity ? getPluginSurfaceContributionServerIds(installations, pluginId, identity) : [], + [identity, installations, pluginId], ); - const title = sidebarItem?.title ?? (pluginId || "Plugin"); + const title = sidebarItem?.title ?? surface?.id ?? (pluginId || "Plugin"); const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null; const close = useCallback(() => { if (router.canGoBack()) router.back(); @@ -193,12 +195,14 @@ export function PluginSurfaceScreen() { const headerRight = useMemo( () => ( <> - + {identity ? ( + + ) : null} ), - [close, contributionId, contributionServerIds, pluginId, serverId], + [close, contributionServerIds, identity, pluginId, serverId], ); return ( @@ -220,14 +224,13 @@ export function PluginSurfaceScreen() { {plugin && surface && runtime ? ( void; surfaces: PluginSurfaceContribution[]; sidebarItems: PluginSidebarContribution[]; + workspacePanels: PluginWorkspacePanelContribution[]; + commandCenterItems: PluginCommandCenterItemContribution[]; attachmentSources: PluginAttachmentSourceContribution[]; } @@ -21,6 +25,8 @@ export interface InstalledPlugin extends EvaluatedPlugin { export type { PluginAttachmentSourceContribution, + PluginCommandCenterItemContribution, PluginSidebarContribution, PluginSurfaceContribution, + PluginWorkspacePanelContribution, }; diff --git a/packages/app/src/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx new file mode 100644 index 000000000..0ddcfbf92 --- /dev/null +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -0,0 +1,178 @@ +import type { + PluginAgentPanelProps, + PluginHostProps, + PluginWorkspacePanelProps, +} from "@paseo/plugin"; +import { PluginClientStateProvider } from "@paseo/plugin/host"; +import { CircleAlert } from "lucide-react-native"; +import { useMemo } from "react"; +import { Platform, Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import invariant from "tiny-invariant"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; +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 { createPluginClientStateSource } from "../client-state/source"; +import { resolvePluginIcon } from "../icons"; +import { useInstalledPlugin } from "../registry"; +import { PluginRuntimeBoundary } from "../runtime-boundary"; +import { createPluginSurfaceRuntime } from "../surface-runtime"; +import { SurfaceErrorBoundary } from "../surface-error-boundary"; +import { resolvePluginWorkspacePanel } from "./resolution"; + +const EMPTY_THEME_DTO: Record = {}; +const pluginThemeMapping = (theme: Theme) => ({ + themeDto: JSON.parse(JSON.stringify(theme)) as Record, +}); + +function resolvePlatform(): PluginHostProps["layout"]["platform"] { + if (Platform.OS === "ios") return "ios"; + if (Platform.OS === "android") return "android"; + return "web"; +} + +function PluginPanelBody({ themeDto = EMPTY_THEME_DTO }: { themeDto?: Record }) { + const { serverId, workspaceId, target } = usePaneContext(); + invariant(target.kind === "plugin", "PluginPanel requires plugin target"); + const plugin = useInstalledPlugin(serverId, target.pluginId); + const contribution = resolvePluginWorkspacePanel(plugin, target); + const workspaceExists = useWorkspaceExists(serverId, workspaceId); + const agentExists = useSessionStore((state) => { + if (target.context !== "agent") return null; + const session = state.sessions[serverId]; + const agent = session?.agents.get(target.agentId) ?? session?.agentDetails.get(target.agentId); + return ( + Boolean(agent) && + normalizeWorkspaceOpaqueId(agent?.workspaceId) === normalizeWorkspaceOpaqueId(workspaceId) + ); + }); + const client = useHostRuntimeClient(serverId); + const runtime = useMemo( + () => createPluginSurfaceRuntime(client, target.pluginId), + [client, target.pluginId], + ); + const compact = useIsCompactFormFactor(); + const hosts = useHosts(); + const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; + const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]); + const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]); + const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]); + + if (!plugin || !contribution || !workspaceExists) { + return ; + } + if (!runtime) { + return ; + } + + let panel; + let Surface: unknown; + if (contribution.context === "workspace") { + const props: PluginWorkspacePanelProps = { + context: "workspace", + theme: themeDto, + host, + layout, + workspaceId, + }; + const Component = contribution.Component; + Surface = Component; + panel = ; + } else if (agentExists && target.context === "agent") { + const props: PluginAgentPanelProps = { + context: "agent", + theme: themeDto, + host, + layout, + workspaceId, + agentId: target.agentId, + }; + const Component = contribution.Component; + Surface = Component; + panel = ; + } else { + return ; + } + + return ( + + + {panel} + + + ); +} + +const ThemedPluginPanelBody = withUnistyles(PluginPanelBody); + +function PluginPanel() { + return ; +} + +function PluginPanelUnavailable({ + message = "This plugin panel is unavailable.", +}: { + message?: string; +}) { + return ( + + {message} + + ); +} + +function usePluginPanelDescriptor( + target: Extract, + context: { serverId: string }, +): PanelDescriptor { + const plugin = useInstalledPlugin(context.serverId, target.pluginId); + const panel = plugin?.workspacePanels.find( + (contribution) => contribution.id === target.panelId && contribution.context === target.context, + ); + if (!panel) { + return { + label: "Plugin unavailable", + subtitle: target.pluginId, + tooltip: "This plugin panel is unavailable", + titleState: "ready", + icon: CircleAlert, + statusBucket: null, + }; + } + return { + label: panel.title, + subtitle: target.pluginId, + tooltip: panel.title, + titleState: "ready", + icon: resolvePluginIcon(panel.icon), + statusBucket: null, + }; +} + +export const pluginPanelRegistration: PanelRegistration<"plugin"> = { + kind: "plugin", + component: PluginPanel, + useDescriptor: usePluginPanelDescriptor, +}; + +const styles = StyleSheet.create((theme) => ({ + unavailable: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[4], + backgroundColor: theme.colors.surface0, + }, + unavailableText: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts new file mode 100644 index 000000000..fc2453752 --- /dev/null +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -0,0 +1,61 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import type { InstalledPlugin } from "../types"; +import { resolvePluginWorkspacePanel } from "./resolution"; + +function installed(): InstalledPlugin { + return { + id: "review", + serverId: "host-1", + clientBundle: "bundle", + queryClient: new QueryClient(), + cleanup: () => {}, + surfaces: [], + sidebarItems: [], + workspacePanels: [ + { + id: "details", + title: "Details", + icon: "Scan", + context: "workspace", + Component: () => null, + }, + ], + commandCenterItems: [], + attachmentSources: [], + }; +} + +describe("plugin workspace panel resolution", () => { + it("resolves only the exact plugin, panel, and context", () => { + const plugin = installed(); + expect( + resolvePluginWorkspacePanel(plugin, { + kind: "plugin", + pluginId: "review", + panelId: "details", + context: "workspace", + })?.title, + ).toBe("Details"); + expect( + resolvePluginWorkspacePanel(plugin, { + kind: "plugin", + pluginId: "review", + panelId: "details", + context: "agent", + agentId: "agent-1", + }), + ).toBeNull(); + }); + + it("returns unavailable after the installation disappears", () => { + expect( + resolvePluginWorkspacePanel(null, { + kind: "plugin", + pluginId: "review", + panelId: "details", + context: "workspace", + }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/plugins/workspace-panels/resolution.ts b/packages/app/src/plugins/workspace-panels/resolution.ts new file mode 100644 index 000000000..8e7119e02 --- /dev/null +++ b/packages/app/src/plugins/workspace-panels/resolution.ts @@ -0,0 +1,14 @@ +import type { PluginWorkspacePanelContribution } from "@paseo/plugin"; +import type { InstalledPlugin } from "../types"; +import type { PluginWorkspaceTabTarget } from "@/workspace-tabs/model"; + +export function resolvePluginWorkspacePanel( + plugin: InstalledPlugin | null, + target: PluginWorkspaceTabTarget, +): PluginWorkspacePanelContribution | null { + return ( + plugin?.workspacePanels.find( + (panel) => panel.id === target.panelId && panel.context === target.context, + ) ?? null + ); +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 01519b9e5..deecd7874 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -421,6 +421,9 @@ function getFallbackTabOptionDescription( if (tab.target.kind === "pull_request") { return labels.pullRequest; } + if (tab.target.kind === "plugin") { + return tab.target.panelId; + } return tab.target.path; } diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index 90ff3dfc3..8b57dc29a 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -154,6 +154,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "files" || tab.target.kind === "pull_request") { return `workspace-${tab.target.kind}-close`; } + if (tab.target.kind === "plugin") { + return `workspace-plugin-close-${encodeFilePathForPathSegment(buildDeterministicWorkspaceTabId(tab.target))}`; + } return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; } diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index f38ed31d4..d49b47f53 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -483,6 +483,57 @@ describe("workspace-layout-store actions", () => { expect(findPaneById(layout.root, explorerPane?.paneId)?.focusedTabId).toBe(tabId); }); + it("restores workspace and agent plugin panel targets", async () => { + const workspaceTarget = { + kind: "plugin", + pluginId: "review", + panelId: "summary", + context: "workspace", + }; + const agentTarget = { + kind: "plugin", + pluginId: "review", + panelId: "details", + context: "agent", + agentId: "agent-1", + }; + await AsyncStorage.setItem( + "workspace-layout-state", + JSON.stringify({ + state: { + layoutByWorkspace: { + workspace: { + root: { + kind: "pane", + pane: { + id: "main", + tabIds: ["workspace-panel", "agent-panel"], + focusedTabId: "agent-panel", + tabs: [ + { tabId: "workspace-panel", target: workspaceTarget, createdAt: 1 }, + { tabId: "agent-panel", target: agentTarget, createdAt: 2 }, + ], + }, + }, + focusedPaneId: "main", + }, + }, + splitSizesByWorkspace: {}, + }, + version: 1, + }), + ); + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + + await restored.persist.rehydrate(); + + const layout = restored.getState().layoutByWorkspace.workspace; + expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([ + workspaceTarget, + agentTarget, + ]); + }); + it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => { useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); const workspaceKey = createWorkspaceKey(); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 2f443c811..fdadc5ac1 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -183,6 +183,21 @@ const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [ }), z.strictObject({ kind: z.literal("setup"), workspaceId: z.string() }), z.strictObject({ kind: z.literal("commit_diff"), sha: z.string() }), + z.discriminatedUnion("context", [ + z.strictObject({ + kind: z.literal("plugin"), + pluginId: z.string(), + panelId: z.string(), + context: z.literal("workspace"), + }), + z.strictObject({ + kind: z.literal("plugin"), + pluginId: z.string(), + panelId: z.string(), + context: z.literal("agent"), + agentId: z.string(), + }), + ]), ]); const WorkspaceTabStorageSchema = z.strictObject({ tabId: z.string(), diff --git a/packages/app/src/workspace-tabs/identity.test.ts b/packages/app/src/workspace-tabs/identity.test.ts index 4eed50d56..b2859000a 100644 --- a/packages/app/src/workspace-tabs/identity.test.ts +++ b/packages/app/src/workspace-tabs/identity.test.ts @@ -145,3 +145,42 @@ describe("commit diff tab identity", () => { ).toBeNull(); }); }); + +describe("plugin panel tab identity", () => { + it("normalizes exact workspace and agent context", () => { + expect( + normalizeWorkspaceTabTarget({ + kind: "plugin", + pluginId: " review ", + panelId: " details ", + context: "agent", + agentId: " agent-1 ", + }), + ).toEqual({ + kind: "plugin", + pluginId: "review", + panelId: "details", + context: "agent", + agentId: "agent-1", + }); + }); + + it("gives workspace and agent instances distinct stable ids", () => { + const workspace = buildDeterministicWorkspaceTabId({ + kind: "plugin", + pluginId: "review", + panelId: "details", + context: "workspace", + }); + const agent = buildDeterministicWorkspaceTabId({ + kind: "plugin", + pluginId: "review", + panelId: "details", + context: "agent", + agentId: "agent-1", + }); + + expect(workspace).toBe("plugin_workspace_6_review_7_details"); + expect(agent).toBe("plugin_agent_6_review_7_details_7_agent-1"); + }); +}); diff --git a/packages/app/src/workspace-tabs/identity.ts b/packages/app/src/workspace-tabs/identity.ts index cf857343d..fc10fc105 100644 --- a/packages/app/src/workspace-tabs/identity.ts +++ b/packages/app/src/workspace-tabs/identity.ts @@ -32,6 +32,9 @@ export function normalizeWorkspaceTabTarget( if (value.kind === "working_diff") { return normalizeWorkingDiffTabTarget(value); } + if (value.kind === "plugin") { + return normalizePluginTabTarget(value); + } return normalizeSimpleWorkspaceTabTarget(value); } @@ -108,6 +111,15 @@ export function workspaceTabTargetsEqual( if (left.kind === "terminal" && right.kind === "terminal") { return left.terminalId === right.terminalId; } + if (left.kind === "plugin" && right.kind === "plugin") { + return ( + left.pluginId === right.pluginId && + left.panelId === right.panelId && + left.context === right.context && + (left.context === "workspace" || + (right.context === "agent" && left.agentId === right.agentId)) + ); + } return secondaryWorkspaceTabTargetsEqual(left, right); } @@ -200,9 +212,28 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st if (target.kind === "files" || target.kind === "pull_request") { return target.kind; } + if (target.kind === "plugin") { + const identity = `${target.pluginId.length}_${target.pluginId}_${target.panelId.length}_${target.panelId}`; + return target.context === "workspace" + ? `plugin_workspace_${identity}` + : `plugin_agent_${identity}_${target.agentId.length}_${target.agentId}`; + } return `file_${target.path}`; } +function normalizePluginTabTarget( + value: Extract, +): WorkspaceTabTarget | null { + const pluginId = trimNonEmpty(value.pluginId); + const panelId = trimNonEmpty(value.panelId); + if (!pluginId || !panelId) return null; + if (value.context === "workspace") { + return { kind: "plugin", pluginId, panelId, context: "workspace" }; + } + const agentId = trimNonEmpty(value.agentId); + return agentId ? { kind: "plugin", pluginId, panelId, context: "agent", agentId } : null; +} + function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; diff --git a/packages/app/src/workspace-tabs/model.ts b/packages/app/src/workspace-tabs/model.ts index 56af53df9..5cee9d821 100644 --- a/packages/app/src/workspace-tabs/model.ts +++ b/packages/app/src/workspace-tabs/model.ts @@ -16,6 +16,21 @@ export interface WorkspaceWorkingDiffTabTarget { focusRequestId?: number; } +export type PluginWorkspaceTabTarget = + | { + kind: "plugin"; + pluginId: string; + panelId: string; + context: "workspace"; + } + | { + kind: "plugin"; + pluginId: string; + panelId: string; + context: "agent"; + agentId: string; + }; + export type WorkspaceTabTarget = | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup } | { kind: "agent"; agentId: string } @@ -26,6 +41,7 @@ export type WorkspaceTabTarget = | { kind: "pull_request" } | WorkspaceFileTabTarget | WorkspaceWorkingDiffTabTarget + | PluginWorkspaceTabTarget | { kind: "setup"; workspaceId: string } | { kind: "commit_diff"; sha: string }; diff --git a/packages/app/test-stubs/lucide-react-native.ts b/packages/app/test-stubs/lucide-react-native.ts index a397a3dfc..a7f4f76b1 100644 --- a/packages/app/test-stubs/lucide-react-native.ts +++ b/packages/app/test-stubs/lucide-react-native.ts @@ -71,6 +71,7 @@ export const MoreVertical = StubIcon; export const MousePointer2 = StubIcon; export const PackagePlus = StubIcon; export const PanelLeft = StubIcon; +export const PanelsTopLeft = StubIcon; export const Paperclip = StubIcon; export const Pencil = StubIcon; export const PencilRuler = StubIcon; @@ -83,6 +84,7 @@ export const QrCode = StubIcon; export const RefreshCcw = StubIcon; export const RefreshCw = StubIcon; export const RotateCw = StubIcon; +export const Scan = StubIcon; export const Search = StubIcon; export const Server = StubIcon; export const Settings = StubIcon; diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index 8a26a19d6..2cc466c7f 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -46,7 +46,14 @@ describe("plugin scaffold", () => { `import React from "react"; import { Text } from "react-native"; import { z } from "zod"; -import { defineRpc, type PluginContext, usePaseo } from "@paseo/plugin"; +import { + defineRpc, + type PluginContext, + type PluginAgentPanelProps, + usePaseo, + useAgent, + useWorkspace, +} from "@paseo/plugin"; const inspect = defineRpc({ name: "inspect", @@ -63,12 +70,44 @@ function Surface() { return Paseo API; } +function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) { + const workspaceName = useWorkspace(workspaceId, (workspace) => { + // @ts-expect-error Plugin snapshots are readonly. + workspace.name = "mutated"; + return workspace.name; + }); + const agentTitle = useAgent(agentId, (agent) => { + // @ts-expect-error Nested plugin snapshot values are readonly. + agent.labels.phase = "mutated"; + return agent.title; + }); + return {workspaceName}: {agentTitle}; +} + export default function contribute(plugin: PluginContext) { plugin.handle(inspect, async (_input, { paseo }) => ({ configured: Boolean((await paseo.config.get()).config), })); plugin.addSurface("main", Surface); - return () => undefined; + plugin.addWorkspacePanel({ + id: "review", + title: "Review", + icon: "Scan", + context: "agent", + Component: AgentPanel, + }); + plugin.addCommandCenterItem({ + id: "open-review", + title: "Open review", + icon: "Scan", + context: "agent", + async onSelect({ paseo, rpc, workspace, openPanel }) { + await paseo.workspaces.ref(workspace.id).setTitle("Review"); + await rpc(inspect, {}); + openPanel("review"); + }, + }); + return () => {}; } `, ); diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index e8ed0a863..f7166c88c 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -7,12 +7,64 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" { import type { PaseoApi } from "@getpaseo/client"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; - export interface PluginSurfaceProps { + export interface PluginHostProps { theme: Record; host: { id: string; label: string }; layout: { compact: boolean; platform: "ios" | "android" | "web" }; } + export interface PluginSurfaceProps extends PluginHostProps {} + + export interface PluginWorkspaceSnapshot { + readonly id: string; + readonly projectId: string; + readonly projectDisplayName: string; + readonly projectRootPath: string; + readonly directory: string; + readonly projectKind: "git" | "non_git" | "directory"; + readonly kind: "directory" | "local_checkout" | "checkout" | "worktree"; + readonly name: string; + readonly title: string | null; + readonly status: "needs_input" | "failed" | "running" | "attention" | "done"; + readonly statusEnteredAt: string | null; + readonly archivingAt: string | null; + readonly diffStat: { readonly additions: number; readonly deletions: number } | null; + } + + export interface PluginAgentSnapshot { + readonly id: string; + readonly workspaceId: string; + readonly provider: string; + readonly status: "initializing" | "idle" | "running" | "error" | "closed"; + readonly createdAt: string; + readonly updatedAt: string; + readonly lastActivityAt: string; + readonly title: string | null; + readonly cwd: string; + readonly model: string | null; + readonly currentModeId: string | null; + readonly thinkingOptionId: string | null; + readonly requiresAttention: boolean; + readonly attentionReason: "finished" | "error" | "permission" | null; + readonly parentAgentId: string | null; + readonly labels: Readonly>; + } + + export interface PluginWorkspacePanelProps extends PluginHostProps { + context: "workspace"; + workspaceId: string; + } + + export interface PluginAgentPanelProps extends PluginHostProps { + context: "agent"; + workspaceId: string; + agentId: string; + } + + export type PluginWorkspacePanelContribution = + | { id: string; title: string; icon: string; context: "workspace"; Component: ComponentType } + | { id: string; title: string; icon: string; context: "agent"; Component: ComponentType }; + export interface PluginRpcContract< InputSchema extends ZodType = ZodType, OutputSchema extends ZodType = ZodType, @@ -57,6 +109,37 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" { Component: ComponentType; } + export interface PluginCommandCapabilities { + paseo: PaseoApi; + rpc( + contract: PluginRpcContract, + input: ZodInput, + ): Promise>; + openSurface(id: string): void; + } + + export interface PluginGlobalCommandContext extends PluginCommandCapabilities { + context: "global"; + } + + export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities { + context: "workspace"; + workspace: PluginWorkspaceSnapshot; + openPanel(id: string): void; + } + + export interface PluginAgentCommandContext extends PluginCommandCapabilities { + context: "agent"; + workspace: PluginWorkspaceSnapshot; + agent: PluginAgentSnapshot; + openPanel(id: string): void; + } + + export type PluginCommandCenterItemContribution = + | { id: string; title: string; icon: string; keywords?: readonly string[]; context: "global"; onSelect(context: PluginGlobalCommandContext): void | Promise } + | { id: string; title: string; icon: string; keywords?: readonly string[]; context: "workspace"; onSelect(context: PluginWorkspaceCommandContext): void | Promise } + | { id: string; title: string; icon: string; keywords?: readonly string[]; context: "agent"; onSelect(context: PluginAgentCommandContext): void | Promise }; + export interface PluginHandlerContext { paseo: PaseoApi; } @@ -71,6 +154,8 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" { ): void; addSurface(id: string, Component: ComponentType): void; addSidebarItem(contribution: PluginSidebarContribution): void; + addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void; + addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void; addAttachmentSource(contribution: PluginAttachmentSourceContribution): void; } @@ -89,6 +174,16 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" { export function usePaseo(): PaseoApi; + export function useWorkspace( + workspaceId: string, + selector: (workspace: PluginWorkspaceSnapshot) => Selection, + ): Selection | null; + + export function useAgent( + agentId: string, + selector: (agent: PluginAgentSnapshot) => Selection, + ): Selection | null; + export function defineAttachmentSource( definition: Definition, ): Definition; @@ -124,7 +219,7 @@ function MainSurface() { export default function contribute(plugin: PluginContext) { plugin.addSurface("main", MainSurface); - return () => undefined; + return () => {}; } `; diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 35d695016..f298fd612 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -375,6 +375,27 @@ test("workspace handles keep identity and refresh snapshots through existing dri expect(updates).toEqual(["sdk pushed"]); expect(workspace.current()).toEqual(pushedWorkspace); + const titlePromise = workspace.setTitle("SDK review", "workspace-title-request"); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "workspace.title.set.request", + requestId: "workspace-title-request", + workspaceId: "workspace_sdk", + title: "SDK review", + }); + ws.message( + sessionMessage({ + type: "workspace.title.set.response", + payload: { + requestId: "workspace-title-request", + workspaceId: "workspace_sdk", + accepted: true, + title: "SDK review", + error: null, + }, + }), + ); + await expect(titlePromise).resolves.toEqual({ title: "SDK review" }); + unsubscribe(); ws.message( sessionMessage({ diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index bca39e3e6..6a308cd29 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -132,6 +132,7 @@ export interface PaseoWorkspaceHandle { }; current(): PaseoWorkspace | null; refresh(options?: { requestId?: string }): Promise; + setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>; archive(requestId?: string): Promise; /** * Subscribes to already-emitted daemon workspace_update events for this id. @@ -526,6 +527,7 @@ function createWorkspaceHandleFactory( }, current: () => current, refresh, + setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId), archive: async (requestId) => { const result = await daemonClient.archiveWorkspace(id, requestId); if (current) { diff --git a/packages/plugin/package.json b/packages/plugin/package.json index cf7dd22d1..1a3a0cb65 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -27,6 +27,9 @@ "typecheck": "tsgo --noEmit && tsgo -p tsconfig.examples.json --noEmit", "test": "vitest run" }, + "dependencies": { + "use-sync-external-store": "^1.6.0" + }, "devDependencies": { "@getpaseo/client": "0.4.0", "@types/node": "^20.9.0", diff --git a/packages/plugin/src/client-state.tsx b/packages/plugin/src/client-state.tsx new file mode 100644 index 000000000..d479acd06 --- /dev/null +++ b/packages/plugin/src/client-state.tsx @@ -0,0 +1,62 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector"; +import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "./contracts.js"; +import { shallowEqual } from "./shallow.js"; + +export interface PluginClientStateSource { + subscribe(listener: () => void): () => void; + getWorkspace(id: string): PluginWorkspaceSnapshot | null; + getAgent(id: string): PluginAgentSnapshot | null; +} + +const PluginClientStateContext = createContext(null); + +function usePluginEntity(input: { + id: string; + read(source: PluginClientStateSource, id: string): Entity | null; + selector(entity: Entity): Selection; +}): Selection | null { + const source = useContext(PluginClientStateContext); + if (!source) throw new Error("Plugin state hooks must run inside a workspace panel"); + return useSyncExternalStoreWithSelector( + source.subscribe, + () => input.read(source, input.id), + () => input.read(source, input.id), + (entity) => (entity ? input.selector(entity) : null), + shallowEqual, + ); +} + +export function useWorkspace( + workspaceId: string, + selector: (workspace: PluginWorkspaceSnapshot) => Selection, +): Selection | null { + return usePluginEntity({ + id: workspaceId, + read: (source, id) => source.getWorkspace(id), + selector, + }); +} + +export function useAgent( + agentId: string, + selector: (agent: PluginAgentSnapshot) => Selection, +): Selection | null { + return usePluginEntity({ + id: agentId, + read: (source, id) => source.getAgent(id), + selector, + }); +} + +export function PluginClientStateProvider({ + children, + source, +}: { + children: ReactNode; + source: PluginClientStateSource; +}) { + return ( + {children} + ); +} diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index b33f1d399..8f1d87e29 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -3,7 +3,7 @@ import type { PaseoApi } from "@getpaseo/client"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; import type { PluginRpcContract } from "./rpc.js"; -export interface PluginSurfaceProps { +export interface PluginHostProps { theme: Record; host: { id: string; @@ -15,6 +15,70 @@ export interface PluginSurfaceProps { }; } +export interface PluginSurfaceProps extends PluginHostProps {} + +export interface PluginWorkspaceSnapshot { + readonly id: string; + readonly projectId: string; + readonly projectDisplayName: string; + readonly projectRootPath: string; + readonly directory: string; + readonly projectKind: "git" | "non_git" | "directory"; + readonly kind: "directory" | "local_checkout" | "checkout" | "worktree"; + readonly name: string; + readonly title: string | null; + readonly status: "needs_input" | "failed" | "running" | "attention" | "done"; + readonly statusEnteredAt: string | null; + readonly archivingAt: string | null; + readonly diffStat: { readonly additions: number; readonly deletions: number } | null; +} + +export interface PluginAgentSnapshot { + readonly id: string; + readonly workspaceId: string; + readonly provider: string; + readonly status: "initializing" | "idle" | "running" | "error" | "closed"; + readonly createdAt: string; + readonly updatedAt: string; + readonly lastActivityAt: string; + readonly title: string | null; + readonly cwd: string; + readonly model: string | null; + readonly currentModeId: string | null; + readonly thinkingOptionId: string | null; + readonly requiresAttention: boolean; + readonly attentionReason: "finished" | "error" | "permission" | null; + readonly parentAgentId: string | null; + readonly labels: Readonly>; +} + +interface PluginWorkspacePanelBase { + id: string; + title: string; + icon: string; +} + +export interface PluginWorkspacePanelProps extends PluginHostProps { + context: "workspace"; + workspaceId: string; +} + +export interface PluginAgentPanelProps extends PluginHostProps { + context: "agent"; + workspaceId: string; + agentId: string; +} + +export type PluginWorkspacePanelContribution = + | (PluginWorkspacePanelBase & { + context: "workspace"; + Component: ComponentType; + }) + | (PluginWorkspacePanelBase & { + context: "agent"; + Component: ComponentType; + }); + export interface PluginSurfaceContribution { id: string; Component: ComponentType; @@ -36,6 +100,53 @@ export interface PluginAttachmentSourceContribution { search: PluginRpcContract; } +export interface PluginCommandCapabilities { + paseo: PaseoApi; + rpc( + contract: PluginRpcContract, + input: ZodInput, + ): Promise>; + openSurface(id: string): void; +} + +export interface PluginGlobalCommandContext extends PluginCommandCapabilities { + context: "global"; +} + +export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities { + context: "workspace"; + workspace: PluginWorkspaceSnapshot; + openPanel(id: string): void; +} + +export interface PluginAgentCommandContext extends PluginCommandCapabilities { + context: "agent"; + workspace: PluginWorkspaceSnapshot; + agent: PluginAgentSnapshot; + openPanel(id: string): void; +} + +interface PluginCommandCenterItemBase { + id: string; + title: string; + icon: string; + keywords?: readonly string[]; +} + +export type PluginCommandCenterItemContribution = + | (PluginCommandCenterItemBase & { + context: "global"; + onSelect(context: PluginGlobalCommandContext): void | Promise; + }) + | (PluginCommandCenterItemBase & { + context: "workspace"; + onSelect(context: PluginWorkspaceCommandContext): void | Promise; + }) + | (PluginCommandCenterItemBase & { + context: "agent"; + onSelect(context: PluginAgentCommandContext): void | Promise; + }); + export interface PluginHandlerContext { paseo: PaseoApi; } @@ -50,6 +161,8 @@ export interface PluginContext { ): void; addSurface(id: string, Component: ComponentType): void; addSidebarItem(contribution: PluginSidebarContribution): void; + addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void; + addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void; addAttachmentSource(contribution: PluginAttachmentSourceContribution): void; } diff --git a/packages/plugin/src/host.ts b/packages/plugin/src/host.ts index a0edae6df..54443e658 100644 --- a/packages/plugin/src/host.ts +++ b/packages/plugin/src/host.ts @@ -1,10 +1,13 @@ import { PluginAttachmentSearchPayloadSchema } from "./attachments.js"; +export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js"; import type { PluginAttachmentSourceContribution, + PluginCommandCenterItemContribution, PluginContext, PluginSidebarContribution, PluginSurfaceContribution, PluginSurfaceProps, + PluginWorkspacePanelContribution, } from "./contracts.js"; import { PluginRpcProvider } from "./rpc-context.js"; import { PaseoApiProvider } from "./paseo-context.js"; @@ -14,18 +17,29 @@ import type { ComponentType } from "react"; interface PluginCollector { addSurface(id: string, Component: ComponentType): void; addSidebarItem(contribution: PluginSidebarContribution): void; + addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void; + addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void; addAttachmentSource(contribution: PluginAttachmentSourceContribution): void; } export interface PluginRegistrationCollector { surfaces: PluginSurfaceContribution[]; sidebarItems: PluginSidebarContribution[]; + workspacePanels: PluginWorkspacePanelContribution[]; + commandCenterItems: PluginCommandCenterItemContribution[]; attachmentSources: PluginAttachmentSourceContribution[]; } export function createPluginContext( collector: PluginCollector, -): Pick { +): Pick< + PluginContext, + | "addSurface" + | "addSidebarItem" + | "addWorkspacePanel" + | "addCommandCenterItem" + | "addAttachmentSource" +> { return { addSurface(id, Component) { collector.addSurface(id, Component); @@ -33,6 +47,12 @@ export function createPluginContext( addSidebarItem(contribution) { collector.addSidebarItem(contribution); }, + addWorkspacePanel(contribution) { + collector.addWorkspacePanel(contribution); + }, + addCommandCenterItem(contribution) { + collector.addCommandCenterItem(contribution); + }, addAttachmentSource(contribution) { collector.addAttachmentSource(contribution); }, diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 5e6e251fd..e28ac318a 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -6,15 +6,27 @@ export { } from "./attachments.js"; export type { PluginAttachmentSourceContribution, + PluginAgentCommandContext, + PluginAgentPanelProps, + PluginAgentSnapshot, PluginCleanup, + PluginCommandCapabilities, + PluginCommandCenterItemContribution, PluginContribution, PluginContext, + PluginGlobalCommandContext, PluginHandlerContext, + PluginHostProps, PluginSidebarContribution, PluginSurfaceContribution, PluginSurfaceProps, + PluginWorkspaceCommandContext, + PluginWorkspacePanelContribution, + PluginWorkspacePanelProps, + PluginWorkspaceSnapshot, } from "./contracts.js"; export { usePaseo } from "./paseo-context.js"; +export { useAgent, useWorkspace } from "./client-state.js"; import type { PluginAttachmentSourceContribution } from "./contracts.js"; export { useRpc } from "./rpc-context.js"; export { defineRpc, type PluginRpcContract } from "./rpc.js"; diff --git a/packages/plugin/src/shallow.test.ts b/packages/plugin/src/shallow.test.ts new file mode 100644 index 000000000..89ab58322 --- /dev/null +++ b/packages/plugin/src/shallow.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { shallowEqual } from "./shallow.js"; + +describe("plugin selector shallow equality", () => { + it("keeps equivalent selector objects and arrays stable", () => { + const shared = { id: "shared" }; + expect(shallowEqual({ name: "Paseo", shared }, { name: "Paseo", shared })).toBe(true); + expect(shallowEqual(["Paseo", shared], ["Paseo", shared])).toBe(true); + expect(shallowEqual({ name: "Paseo" }, { name: "Changed" })).toBe(false); + }); + + it("compares map and set selections by their shallow entries", () => { + expect(shallowEqual(new Map([["status", "running"]]), new Map([["status", "running"]]))).toBe( + true, + ); + expect(shallowEqual(new Set(["workspace", "agent"]), new Set(["workspace", "agent"]))).toBe( + true, + ); + expect(shallowEqual(new Set(["workspace"]), new Set(["agent"]))).toBe(false); + }); +}); diff --git a/packages/plugin/src/shallow.ts b/packages/plugin/src/shallow.ts new file mode 100644 index 000000000..fbfcfb67b --- /dev/null +++ b/packages/plugin/src/shallow.ts @@ -0,0 +1,47 @@ +function isIterable(value: object): value is Iterable { + return Symbol.iterator in value; +} + +function hasEntries(value: object): value is { entries(): Iterable<[unknown, unknown]> } { + return "entries" in value; +} + +function compareEntries( + left: { entries(): Iterable<[unknown, unknown]> }, + right: { entries(): Iterable<[unknown, unknown]> }, +): boolean { + const leftEntries = left instanceof Map ? left : new Map(left.entries()); + const rightEntries = right instanceof Map ? right : new Map(right.entries()); + if (leftEntries.size !== rightEntries.size) return false; + for (const [key, value] of leftEntries) { + if (!rightEntries.has(key) || !Object.is(value, rightEntries.get(key))) return false; + } + return true; +} + +function compareIterables(left: Iterable, right: Iterable): boolean { + const leftIterator = left[Symbol.iterator](); + const rightIterator = right[Symbol.iterator](); + let leftValue = leftIterator.next(); + let rightValue = rightIterator.next(); + while (!leftValue.done && !rightValue.done) { + if (!Object.is(leftValue.value, rightValue.value)) return false; + leftValue = leftIterator.next(); + rightValue = rightIterator.next(); + } + return Boolean(leftValue.done && rightValue.done); +} + +export function shallowEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (!left || !right || typeof left !== "object" || typeof right !== "object") return false; + if (Object.getPrototypeOf(left) !== Object.getPrototypeOf(right)) return false; + if (isIterable(left) && isIterable(right)) { + if (hasEntries(left) && hasEntries(right)) return compareEntries(left, right); + return compareIterables(left, right); + } + return compareEntries( + { entries: () => Object.entries(left) }, + { entries: () => Object.entries(right) }, + ); +} diff --git a/packages/plugin/src/use-sync-external-store.d.ts b/packages/plugin/src/use-sync-external-store.d.ts new file mode 100644 index 000000000..cdf961a36 --- /dev/null +++ b/packages/plugin/src/use-sync-external-store.d.ts @@ -0,0 +1,9 @@ +declare module "use-sync-external-store/shim/with-selector" { + export function useSyncExternalStoreWithSelector( + subscribe: (onStoreChange: () => void) => () => void, + getSnapshot: () => Snapshot, + getServerSnapshot: () => Snapshot, + selector: (snapshot: Snapshot) => Selection, + isEqual?: (left: Selection, right: Selection) => boolean, + ): Selection; +} diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index 1726f10bf..bd2bb0f56 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -7,7 +7,13 @@ type PluginBuildTarget = "client" | "server"; const REGISTRATIONS_REMOVED_BY_TARGET: Record> = { client: new Set(["handle"]), - server: new Set(["addSurface", "addSidebarItem", "addAttachmentSource"]), + server: new Set([ + "addSurface", + "addSidebarItem", + "addWorkspacePanel", + "addCommandCenterItem", + "addAttachmentSource", + ]), }; function registrationName(statement: unknown, contextName: string): string | null { diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 9d6136a75..afafa1f28 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -467,6 +467,10 @@ function HelloSurface() { return Hello from native UI; } +function ReviewPanel() { + return Workspace review panel; +} + export default function contribute(plugin: any) { plugin.handle(greetRpc, async (input: { name: string }) => ({ message: "Hello, " + input.name, @@ -474,6 +478,8 @@ export default function contribute(plugin: any) { })); plugin.addSurface("main", HelloSurface); plugin.addSidebarItem({ id: "hello", title: "Hello", icon: "Sparkles", surface: "main" }); + plugin.addWorkspacePanel({ id: "review", title: "Review", icon: "Scan", context: "workspace", Component: ReviewPanel }); + plugin.addCommandCenterItem({ id: "open-review", title: "Open review", icon: "Scan", context: "workspace", onSelect() {} }); plugin.addAttachmentSource(attachments); return () => undefined; }`, @@ -487,6 +493,8 @@ export default function contribute(plugin: any) { expect(catalog[0]?.id).toBe("hello"); expect(catalog[0]?.clientBundle).toContain("Hello from native UI"); expect(catalog[0]?.clientBundle).toContain("Attach example issue"); + expect(catalog[0]?.clientBundle).toContain("Workspace review panel"); + expect(catalog[0]?.clientBundle).toContain("Open review"); expect(catalog[0]?.clientBundle).not.toContain("node:os"); expect(catalog[0]?.clientBundle).not.toContain("get: () => from[key]"); await expect(runtime.invoke("hello", "greet", { name: "Paseo" })).resolves.toMatchObject({ diff --git a/plugin-examples/linear/index.tsx b/plugin-examples/linear/index.tsx index 25b8ae335..dcb8ab89e 100644 --- a/plugin-examples/linear/index.tsx +++ b/plugin-examples/linear/index.tsx @@ -42,5 +42,5 @@ export default function contribute(plugin: PluginContext) { return linear.search(query); }); plugin.addAttachmentSource(issueAttachments); - return () => undefined; + return () => {}; } diff --git a/plugin-examples/local-plugin/index.tsx b/plugin-examples/local-plugin/index.tsx index 5f6ca6a48..345aac172 100644 --- a/plugin-examples/local-plugin/index.tsx +++ b/plugin-examples/local-plugin/index.tsx @@ -2,7 +2,13 @@ import React, { useCallback, useMemo } from "react"; import { Pressable, Text, View } from "react-native"; import { useMutation } from "@tanstack/react-query"; import { z } from "zod"; -import { defineRpc, type PluginContext, type PluginSurfaceProps, useRpc } from "@paseo/plugin"; +import { + defineRpc, + type PluginContext, + type PluginWorkspacePanelProps, + useWorkspace, + useRpc, +} from "@paseo/plugin"; const incrementRpc = defineRpc({ name: "increment", @@ -21,8 +27,9 @@ const ExampleThemeSchema = z.object({ }), }); -function ExampleSurface({ theme }: PluginSurfaceProps) { +function ExamplePanel({ theme, workspaceId }: PluginWorkspacePanelProps) { const { colors } = ExampleThemeSchema.parse(theme); + const workspace = useWorkspace(workspaceId, ({ name }) => ({ name })); const callIncrement = useRpc(incrementRpc); const { data, error, isPending, mutate } = useMutation({ mutationFn: callIncrement }); const value = data?.value ?? 0; @@ -43,7 +50,8 @@ function ExampleSurface({ theme }: PluginSurfaceProps) { return ( - Native plugin surface + Workspace plugin panel + {workspace?.name} {data?.handledBy ?? "The RPC has not run yet."} undefined; + plugin.addCommandCenterItem({ + id: "open-counter", + title: "Open plugin counter", + icon: "Blocks", + context: "workspace", + onSelect({ openPanel }) { + openPanel("counter"); + }, + }); + return () => {}; } diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index d562d3040..3c5898a74 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -1,6 +1,6 @@ --- title: Plugin quickstart -description: Build, install, and reload a trusted local Paseo plugin with a native sidebar surface. +description: Build, install, and reload a trusted local Paseo plugin with a workspace panel. nav: Quickstart order: 45 category: Plugins @@ -8,7 +8,7 @@ category: Plugins # Plugin quickstart -Paseo plugins add native surfaces, sidebar items, daemon behavior, and composer attachment sources. They run on every Paseo client connected to the host, including mobile. +Paseo plugins add native workspace panels, Command Center items, global surfaces, daemon behavior, and composer attachment sources. They run on every Paseo client connected to the host, including mobile. Plugins are trusted local code. Install only code you trust: backend code runs unsandboxed with access to the daemon machine, and client contributions run inside the Paseo app. @@ -27,8 +27,8 @@ Enabling starts configured plugins; disabling tears them down. Automation must i Use an absolute path on the daemon machine: ```bash -paseo plugin init /absolute/path/to/counter-plugin -cd /absolute/path/to/counter-plugin +paseo plugin init /absolute/path/to/workspace-plugin +cd /absolute/path/to/workspace-plugin npm install ``` @@ -37,51 +37,54 @@ npm install Replace `index.tsx` with: ```tsx -import type { PluginContext } from "@paseo/plugin"; -import React, { useState } from "react"; -import { Pressable, Text, View } from "react-native"; - -function Counter() { - const [count, setCount] = useState(0); +import { type PluginContext, type PluginWorkspacePanelProps, useWorkspace } from "@paseo/plugin"; +import { Text, View } from "react-native"; +function WorkspaceOverview({ workspaceId }: PluginWorkspacePanelProps) { + const workspace = useWorkspace(workspaceId, ({ name, directory }) => ({ + name, + directory, + })); return ( - - {count} - setCount((value) => value + 1)} - style={{ padding: 16, borderRadius: 12, backgroundColor: "#238253" }} - > - Count me in - + + {workspace?.name} + {workspace?.directory} ); } export default function contribute(plugin: PluginContext) { - plugin.addSurface("main", Counter); - plugin.addSidebarItem({ - id: "main", - title: "Counter", - icon: "ListPlus", - surface: "main", + plugin.addWorkspacePanel({ + id: "overview", + title: "Workspace overview", + icon: "PanelsTopLeft", + context: "workspace", + Component: WorkspaceOverview, }); - return () => undefined; + plugin.addCommandCenterItem({ + id: "open-overview", + title: "Open workspace overview", + icon: "PanelsTopLeft", + context: "workspace", + onSelect({ openPanel }) { + openPanel("overview"); + }, + }); + return () => {}; } ``` -The icon is a [Lucide](https://lucide.dev/icons/) icon name. The surface uses React Native primitives, so the same contribution works in the desktop, browser, iOS, and Android clients. +The icon is a [Lucide](https://lucide.dev/icons/) icon name. The panel uses React Native primitives, so the same contribution works in the desktop, browser, iOS, and Android clients. Panel props contain stable IDs; `useWorkspace` selects the cached fields the component needs without fetching through RPC or re-rendering for unrelated workspace changes. ## Check and install it ```bash npm run typecheck -paseo plugin install /absolute/path/to/counter-plugin +paseo plugin install /absolute/path/to/workspace-plugin paseo plugin ls ``` -Open **Counter** in the Paseo sidebar. If the item does not appear, confirm that **Enable plugins** is on, the plugin status is `running` in `paseo plugin ls`, and the client is viewing the host where you installed it. +Open a workspace, open the Command Center, and choose **Open workspace overview**. It opens as a normal workspace tab. If the item does not appear, confirm that **Enable plugins** is on, the plugin status is `running` in `paseo plugin ls`, and the client is viewing the host where you installed it. ## Edit and reload @@ -89,7 +92,7 @@ Source changes are explicit: ```bash npm run typecheck -paseo plugin reload counter-plugin +paseo plugin reload workspace-plugin ``` A reload stops the old plugin, runs its cleanup, compiles the current source, and starts it again. A failed reload stays failed and reports its load error; fix the source and reload again. @@ -106,8 +109,8 @@ console.error("Issue refresh failed", error); Read recent stdout and stderr from **Settings → Plugins → Logs** or the CLI: ```bash -paseo plugin logs counter-plugin -paseo plugin logs counter-plugin --json +paseo plugin logs workspace-plugin +paseo plugin logs workspace-plugin --json ``` The log tail survives reloads and crashes, so inspect it when a plugin fails to start or an RPC diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 3f0ebd52e..9cf194b76 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -11,6 +11,8 @@ category: Plugins Local plugins are directory sources installed into one Paseo daemon. A plugin can contribute: - React Native surfaces and sidebar items to Paseo clients; +- workspace and agent panels opened as workspace tabs; +- global, workspace, and agent actions in the Command Center; - schema-validated RPC handlers running beside the daemon; - normal Paseo operations through the TypeScript SDK; - searchable external resources in the message composer. @@ -36,7 +38,7 @@ The manifest contains the default plugin ID: { "id": "my-plugin" } ``` -Plugin, surface, sidebar-item, and attachment-source IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens. +Plugin, surface, sidebar-item, workspace-panel, Command Center item, and attachment-source IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens. The generated declaration file supplies `@paseo/plugin` types for local typechecking. Paseo supplies the runtime modules. Regenerate a fresh project with the matching CLI when the plugin contract changes. @@ -49,7 +51,7 @@ import type { PluginContext } from "@paseo/plugin"; export default function contribute(plugin: PluginContext) { // Register contributions here. - return () => undefined; + return () => {}; } ``` @@ -75,7 +77,7 @@ export default function contribute(plugin: PluginContext) { icon: "Blocks", surface: "main", }); - return () => undefined; + return () => {}; } ``` @@ -91,6 +93,149 @@ Paseo owns the route, header, close action, host picker, error boundary, and que Client code can import `react`, `react-native`, `@tanstack/react-query`, `zod`, and `@paseo/plugin`. Install them locally for typechecking; Paseo provides the client runtime instances. +## Workspace panels + +Register one panel for workspace or agent context: + +```tsx +import { + type PluginContext, + type PluginAgentPanelProps, + useAgent, + useWorkspace, +} from "@paseo/plugin"; +import { Text } from "react-native"; + +function ReviewPanel({ workspaceId, agentId }: PluginAgentPanelProps) { + const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name); + const agent = useAgent(agentId, ({ id, title }) => ({ id, title })); + return {`${workspaceName} · ${agent?.title ?? agent?.id}`}; +} + +export default function contribute(plugin: PluginContext) { + plugin.addWorkspacePanel({ + id: "review", + title: "Review", + icon: "Scan", + context: "agent", + Component: ReviewPanel, + }); + return () => {}; +} +``` + +`addWorkspacePanel` fields: + +| Field | Required | Meaning | +| ----------- | -------- | ------------------------------------------------------------- | +| `id` | Yes | Plugin-local panel ID. | +| `title` | Yes | Workspace-tab title. | +| `icon` | Yes | Lucide icon name. | +| `context` | Yes | `workspace` or `agent`. | +| `Component` | Yes | React Native component matching the selected context's props. | + +A workspace panel receives `PluginWorkspacePanelProps`: `context: "workspace"`, `theme`, `host`, `layout`, and `workspaceId`. An agent panel receives `PluginAgentPanelProps`: `context: "agent"`, the same common fields and `workspaceId`, plus `agentId`. + +Read cached state with `useWorkspace(workspaceId, selector)` and `useAgent(agentId, selector)`. A selector is required. Paseo compares its result shallowly, so selecting `{ name, status }` does not re-render when unrelated fields change. Select every field the component renders in one call; do not select the whole snapshot. + +Both hooks return `null` when the record is unavailable. Otherwise they run synchronously against normalized client state. Snapshot DTOs and their nested values are deeply readonly and frozen at runtime. Do not call plugin RPC to discover the current workspace or agent. Fetch optional or vendor-specific enrichment after the component renders. + +Workspace snapshot fields: + +| Field | Type | +| -------------------- | ----------------------------------------------------------------- | +| `id` | `string` | +| `projectId` | `string` | +| `projectDisplayName` | `string` | +| `projectRootPath` | `string` | +| `directory` | `string` | +| `projectKind` | `"git" \| "non_git" \| "directory"` | +| `kind` | `"directory" \| "local_checkout" \| "checkout" \| "worktree"` | +| `name` | `string` | +| `title` | `string \| null` | +| `status` | `"needs_input" \| "failed" \| "running" \| "attention" \| "done"` | +| `statusEnteredAt` | ISO timestamp or `null` | +| `archivingAt` | ISO timestamp or `null` | +| `diffStat` | `{ additions: number; deletions: number } \| null` | + +Agent snapshot fields: + +| Field | Type | +| ------------------- | -------------------------------------------------------------- | +| `id` | `string` | +| `workspaceId` | `string` | +| `provider` | `string` | +| `status` | `"initializing" \| "idle" \| "running" \| "error" \| "closed"` | +| `createdAt` | ISO timestamp | +| `updatedAt` | ISO timestamp | +| `lastActivityAt` | ISO timestamp | +| `title` | `string \| null` | +| `cwd` | `string` | +| `model` | `string \| null` | +| `currentModeId` | `string \| null` | +| `thinkingOptionId` | `string \| null` | +| `requiresAttention` | `boolean` | +| `attentionReason` | `"finished" \| "error" \| "permission" \| null` | +| `parentAgentId` | `string \| null` | +| `labels` | `Record` | + +Paseo owns tab focus, splitting, closing, persistence, query state, the API/RPC providers, and the render error boundary. A restored tab whose plugin, panel, context, workspace, or agent is unavailable stays open with an unavailable message instead of crashing the workspace. + +## Command Center items + +Register an action and open a panel from the callback: + +```tsx +import { defineRpc } from "@paseo/plugin"; +import { z } from "zod"; + +const refreshReview = defineRpc({ + name: "review.refresh", + input: z.object({ agentId: z.string() }), + output: z.object({ refreshed: z.boolean() }), +}); + +plugin.addCommandCenterItem({ + id: "open-review", + title: "Open review", + icon: "Scan", + keywords: ["inspect"], + context: "agent", + async onSelect({ paseo, rpc, workspace, agent, openPanel }) { + await paseo.workspaces.ref(workspace.id).setTitle(`Review ${agent.id}`); + await rpc(refreshReview, { agentId: agent.id }); + openPanel("review"); + }, +}); +``` + +`addCommandCenterItem` fields: + +| Field | Required | Meaning | +| ---------- | -------- | ---------------------------------------------- | +| `id` | Yes | Plugin-local item ID. | +| `title` | Yes | Search result title. | +| `icon` | Yes | Lucide icon name. | +| `keywords` | No | Additional Command Center search terms. | +| `context` | Yes | `global`, `workspace`, or `agent`. | +| `onSelect` | Yes | Client-side callback for the matching context. | + +Global items appear on the installation's selected host. Workspace items appear only when that host has an active cached workspace. Agent items appear only when the focused workspace tab is an agent or an agent-context plugin panel whose cached record belongs to that workspace. Missing context removes the item rather than calling the plugin to discover it. + +Every callback receives: + +| Field | Context | Meaning | +| ---------------------- | ------------------- | ------------------------------------------------------------- | +| `context` | All | Matching discriminator. | +| `paseo` | All | Selected host's existing `PaseoApi`. | +| `rpc(contract, input)` | All | Typed call to this installation's daemon-side plugin handler. | +| `openSurface(id)` | All | Opens one of this plugin's registered global surfaces. | +| `workspace` | Workspace and agent | Synchronous workspace snapshot. | +| `agent` | Agent | Synchronous matching agent snapshot. | +| `openPanel(id)` | Workspace and agent | Opens a registered panel in the callback's current context. | + +An agent callback may open either an agent panel or a workspace panel. A workspace callback may open only a workspace panel. Unknown surface and panel IDs fail visibly. Use `paseo` for normal workspace, agent, provider, and daemon-config operations. Use `rpc` for plugin-specific filesystem, credential, vendor, or daemon-local work. + ## Use the Paseo SDK Use `usePaseo()` for ordinary Paseo operations from a surface. It borrows the selected host's existing connection; do not create another client. @@ -156,7 +301,7 @@ export default function contribute(plugin: PluginContext) { return { message: `${name}: plugins are ${config.pluginsEnabled ? "on" : "off"}` }; }); plugin.addSurface("main", GreetingButton); - return () => undefined; + return () => {}; } ``` @@ -236,7 +381,7 @@ const issues = defineAttachmentSource({ export default function contribute(plugin: PluginContext) { plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query)); plugin.addAttachmentSource(issues); - return () => undefined; + return () => {}; } ``` @@ -248,6 +393,12 @@ Plugins are installed per daemon. When the same contribution exists on several c Attachment sources remain scoped to each composer's host. +Workspace panels and Command Center items stay scoped to the active host and exact cached context. +Reload replaces their registrations. Disable, removal, host disconnect, and evaluation failure +remove Command Center items and clear the installation's query state. An already-restored panel tab +remains as unavailable until its matching contribution returns or the user closes it. Panel render +failures stay inside the plugin error boundary. + ## CLI reference ```bash diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index c7fe6397f..3f69041e3 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -106,7 +106,7 @@ Creation options include `config`, `cwd`, `parent`, `title`, `prompt`, `env`, `o | `archive(workspaceOrId)` | `PaseoWorkspaceArchiveResult` | Archives without first creating a handle. | | `subscribe(handler)` | Unsubscribe function | Listens for connection-local workspace updates. Call `list({ subscribe })` first. | -A workspace handle exposes `id`, `projectId`, `directory`, `name`, `status`, `current()`, `refresh()`, `archive()`, and `subscribe()`. Use `workspace.agents.create(options)` to create an agent without repeating the workspace ID or directory. +A workspace handle exposes `id`, `projectId`, `directory`, `name`, `status`, `current()`, `refresh()`, `setTitle(title)`, `archive()`, and `subscribe()`. Pass `null` to `setTitle` to restore the derived workspace name. Use `workspace.agents.create(options)` to create an agent without repeating the workspace ID or directory. ## `client.providers` diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index 2a7ed3c1c..fb1792588 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -54,12 +54,52 @@ import type { PluginContext } from "@paseo/plugin"; export default function contribute(plugin: PluginContext) { // Register contributions here. - return () => undefined; + return () => {}; } ``` Cleanup can be async. Use it for timers, watchers, sockets, and other resources created by plugin code. Paseo removes registrations, unmounts surfaces, rejects pending RPCs, closes the plugin session, and stops the subprocess when the plugin stops. +## Add a workspace panel + +Workspace panels live beside agents, terminals, files, and diffs. Their props contain stable IDs; +required-selector hooks read cached client state without subscribing to unrelated fields: + +```tsx +import { type PluginContext, type PluginWorkspacePanelProps, useWorkspace } from "@paseo/plugin"; +import { Text } from "react-native"; + +function Overview({ workspaceId }: PluginWorkspacePanelProps) { + const name = useWorkspace(workspaceId, (workspace) => workspace.name); + return {name}; +} + +export default function contribute(plugin: PluginContext) { + plugin.addWorkspacePanel({ + id: "overview", + title: "Workspace overview", + icon: "PanelsTopLeft", + context: "workspace", + Component: Overview, + }); + plugin.addCommandCenterItem({ + id: "open-overview", + title: "Open workspace overview", + icon: "PanelsTopLeft", + context: "workspace", + onSelect({ openPanel }) { + openPanel("overview"); + }, + }); + return () => {}; +} +``` + +Use `useWorkspace(id, selector)` and `useAgent(id, selector)`. Selectors are required +and their results use shallow equality. Never select the whole snapshot or add an RPC to discover +the active workspace or agent. Command callbacks receive the selected host's `paseo`, typed +`rpc(contract, input)`, `openSurface(id)`, and contextual `openPanel(id)` capabilities. + ## Add a sidebar surface Plugin surfaces use React Native primitives and work across desktop, browser, iOS, and Android. Register the surface before its sidebar item: @@ -93,7 +133,7 @@ export default function contribute(plugin: PluginContext) { icon: "ListPlus", surface: "main", }); - return () => undefined; + return () => {}; } ``` @@ -164,7 +204,7 @@ export default function contribute(plugin: PluginContext) { return { message: `${name}: plugins are ${config.pluginsEnabled ? "on" : "off"}` }; }); plugin.addSurface("main", Greeting); - return () => undefined; + return () => {}; } ``` @@ -228,7 +268,7 @@ const issues = defineAttachmentSource({ export default function contribute(plugin: PluginContext) { plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query)); plugin.addAttachmentSource(issues); - return () => undefined; + return () => {}; } ```