From 62bd21214bfdedc5b0cf1c5c077f8116fe7fc4c6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 05:04:36 +0100 Subject: [PATCH] feat(plugin): add sidebar view examples and ref actions Keep sidebar layout and gestures inside the plugin while routing entity handles to their owning hosts. Examples cover empty projects, pinned workspaces, task sections, nested agents and device-local label folders. --- docs/plugins.md | 7 + .../app/e2e/browser/sidebar-views.spec.ts | 1 + .../app/e2e/support/helpers/new-workspace.ts | 1 + .../app/e2e/support/helpers/sidebar-views.ts | 64 +++++- packages/app/src/plugins/actions.ts | 3 +- .../src/plugins/catalog-sync-model.test.ts | 3 +- packages/app/src/plugins/client-runtime.ts | 5 +- packages/app/src/plugins/client-state/api.ts | 68 +++++++ .../client-state/source.browser.test.tsx | 59 ++++++ .../plugins/installation-provider.test.tsx | 13 +- .../app/src/plugins/installation-provider.tsx | 3 +- .../src/plugins/registry-lifecycle.test.ts | 3 +- .../src/plugins/registry-requirements.test.ts | 3 +- .../app/src/plugins/settings/device.test.ts | 36 +++- packages/app/src/plugins/settings/device.ts | 13 +- packages/client/src/index.ts | 36 ++++ packages/plugin/src/boundaries.test.ts | 9 +- packages/plugin/src/client/contracts.ts | 4 +- packages/plugin/src/client/host.ts | 2 + packages/plugin/src/client/index.ts | 2 + packages/plugin/src/client/paseo-context.tsx | 16 +- packages/plugin/src/client/reference-api.ts | 88 ++++++++ .../src/server/plugins/runtime.posix.test.ts | 2 +- .../sidebar-views/client/agents.tsx | 34 ++++ .../sidebar-views/client/labels.tsx | 191 ++++++++++++++++++ .../sidebar-views/client/nested.tsx | 109 ++++++++++ plugin-examples/sidebar-views/client/rows.tsx | 139 +++++++++++++ .../sidebar-views/client/workspaces.tsx | 61 ++---- .../sidebar-views/index.client.tsx | 18 +- .../sidebar-views/shared/preferences.ts | 8 + public-docs/plugins/reference.md | 21 +- public-docs/plugins/reference.zh-CN.md | 18 +- public-docs/plugins/sidebar-views.md | 164 +++++++++++++++ public-docs/plugins/sidebar-views.zh-CN.md | 135 +++++++++++++ 34 files changed, 1254 insertions(+), 85 deletions(-) create mode 100644 packages/app/src/plugins/client-state/api.ts create mode 100644 packages/plugin/src/client/reference-api.ts create mode 100644 plugin-examples/sidebar-views/client/agents.tsx create mode 100644 plugin-examples/sidebar-views/client/labels.tsx create mode 100644 plugin-examples/sidebar-views/client/nested.tsx create mode 100644 plugin-examples/sidebar-views/client/rows.tsx create mode 100644 plugin-examples/sidebar-views/shared/preferences.ts create mode 100644 public-docs/plugins/sidebar-views.md create mode 100644 public-docs/plugins/sidebar-views.zh-CN.md diff --git a/docs/plugins.md b/docs/plugins.md index db6151f9de..2da369f4a5 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -570,6 +570,13 @@ Register a usage source from `index.server.ts` with `server.registerUsageSource( When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. +## Replace sidebar content + +The sidebar view slot is global across hosts, while header and footer contributions retain their +host selection. The slot owns selection loading, fallback and the error boundary; the plugin owns +its layout. This keeps plugin failures from removing navigation or recovery controls. The +[sidebar view guide](../public-docs/plugins/sidebar-views.md) owns the author API. + ## Contribute sidebar items Sidebar header and footer items are plugin components, not descriptors. The diff --git a/packages/app/e2e/browser/sidebar-views.spec.ts b/packages/app/e2e/browser/sidebar-views.spec.ts index 85d0365cff..8aabef5a93 100644 --- a/packages/app/e2e/browser/sidebar-views.spec.ts +++ b/packages/app/e2e/browser/sidebar-views.spec.ts @@ -8,6 +8,7 @@ test("sidebar views replace the content, preserve chrome, and recover to the def await withSidebarViews(page, async (views) => { await views.expectDefault(); await views.chooseWorkspaces(); + await views.exerciseExamples(); await views.restoreDefault(); await views.recoverFromFailure(); await views.chooseWorkspaces(); diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 63034d52a3..00726ecfa5 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -34,6 +34,7 @@ type NewWorkspaceDaemonClient = Pick< | "removePlugin" | "reloadPlugin" | "setWorkspaceTitle" + | "setWorkspaceLabel" >; type CreateWorkspacePayload = Awaited>; diff --git a/packages/app/e2e/support/helpers/sidebar-views.ts b/packages/app/e2e/support/helpers/sidebar-views.ts index 08c946a9c5..73ad97c295 100644 --- a/packages/app/e2e/support/helpers/sidebar-views.ts +++ b/packages/app/e2e/support/helpers/sidebar-views.ts @@ -19,8 +19,8 @@ export async function withSidebarViews( await writeFile( entry, original.replace( - "return client.addSidebarView", - 'client.addSidebarView({ id: "broken", title: "Broken example", Component() { throw new Error("View failed"); } });\n return client.addSidebarView', + "const cleanups = [", + 'client.addSidebarView({ id: "broken", title: "Broken example", Component() { throw new Error("View failed"); } });\n const cleanups = [', ), ); const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); @@ -74,6 +74,66 @@ function sidebarViews( await choose("Example workspaces"); await expectPlugin(); }, + async exerciseExamples() { + await choose("Example agents"); + await expect( + visibleTestId(page, "plugin-sidebar-view").getByText("Respond", { exact: true }), + ).toBeVisible(); + await choose("Example nested agents"); + await expectPlugin(); + await choose("Example label folders"); + const slot = visibleTestId(page, "plugin-sidebar-view"); + await slot + .getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true }) + .click(); + await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click(); + await expect + .poll( + async () => + (await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId) + ?.labels, + ) + .toContain("Inbox"); + await slot + .getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true }) + .click(); + await slot.getByRole("button", { name: "Move to Later", exact: true }).click(); + await expect + .poll( + async () => + (await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId) + ?.labels, + ) + .toEqual(["Later"]); + await client.setWorkspaceLabel({ + workspaceId, + label: { name: "Unlabelled", color: "red" }, + assigned: true, + }); + await client.setWorkspaceLabel({ + workspaceId, + label: { name: "Later", color: "red" }, + assigned: false, + }); + await expect + .poll( + async () => + (await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId) + ?.labels, + ) + .toEqual(["Unlabelled"]); + await slot + .getByRole("button", { name: `Move workspace ${workspaceId}`, exact: true }) + .click(); + await slot.getByRole("button", { name: "Move to Inbox", exact: true }).click(); + await expect + .poll( + async () => + (await client.fetchWorkspaces()).entries.find((item) => item.id === workspaceId) + ?.labels, + ) + .toEqual(["Inbox"]); + }, async restoreDefault() { await choose("Default view"); await expectDefault(); diff --git a/packages/app/src/plugins/actions.ts b/packages/app/src/plugins/actions.ts index b61eae7c1d..257d198d24 100644 --- a/packages/app/src/plugins/actions.ts +++ b/packages/app/src/plugins/actions.ts @@ -1,3 +1,4 @@ +import { getPluginClientApi } from "./client-state/api"; import { callPluginRpc } from "@getpaseo/plugin/client/host"; import type { PluginAgentCommandContext, @@ -32,7 +33,7 @@ export function createPluginCapabilities( navigation.openSurface(plugin.id, screenId, params); } return { - paseo: plugin.paseo, + paseo: getPluginClientApi(plugin).paseo, rpc: (contract, input) => callPluginRpc(contract, plugin.invoke, input), openSettings(screenId) { if (!plugin.settingsScreens.some((screen) => screen.id === screenId)) diff --git a/packages/app/src/plugins/catalog-sync-model.test.ts b/packages/app/src/plugins/catalog-sync-model.test.ts index 55cae64e5d..7d9ad09e41 100644 --- a/packages/app/src/plugins/catalog-sync-model.test.ts +++ b/packages/app/src/plugins/catalog-sync-model.test.ts @@ -1,3 +1,4 @@ +import { getPluginClientApi } from "./client-state/api"; import appPackage from "../../package.json"; import { afterEach, expect, it } from "vitest"; import { QueryObserver } from "@tanstack/react-query"; @@ -139,7 +140,7 @@ function harness() { }, installation.lifetime.signal, ), - paseo: installation.paseo, + paseo: getPluginClientApi(installation).paseo, i18n: installation.i18n, rpc: async () => { throw new Error("Unexpected runtime RPC"); diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index d9439ff8ad..184d24a8f6 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -1,6 +1,5 @@ import { createPlayAudio, type AudioEngine } from "@/audio"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { createPluginHosts } from "./hosts"; +import { getPluginClientApi } from "./client-state/api"; import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client"; import { subscribePluginRpc } from "@getpaseo/plugin/client/host"; import { @@ -27,7 +26,7 @@ export function createPluginClientRuntime( ...capabilities, i18n: installation.i18n, playAudio: createPlayAudio(audio, installation.lifetime.signal), - hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal), + hosts: getPluginClientApi(installation).hosts, addComposerPill(contribution) { return pluginButtonStore.addComposerPill(installation, contribution); }, diff --git a/packages/app/src/plugins/client-state/api.ts b/packages/app/src/plugins/client-state/api.ts new file mode 100644 index 0000000000..d0f9e45faa --- /dev/null +++ b/packages/app/src/plugins/client-state/api.ts @@ -0,0 +1,68 @@ +import { createPluginReferenceApi } from "@getpaseo/plugin/client/host"; +import { router } from "expo-router"; +import { i18n } from "@/i18n/i18next"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; +import { usePanelStore } from "@/stores/panel-store"; +import { buildNewWorkspaceRoute } from "@/utils/host-routes"; +import { createPluginHosts } from "../hosts"; +import type { InstalledPlugin } from "../types"; +import { createPluginClientStateSource } from "./source"; + +interface InstallationApi { + paseo: ReturnType; + hosts: ReturnType; +} +// Registry presentation clones share the installation lifetime and its client pool. +const apis = new WeakMap(); +const state = createPluginClientStateSource(); + +export function getPluginClientApi(plugin: InstalledPlugin): InstallationApi { + const cached = apis.get(plugin.lifetime.signal); + if (cached) return cached; + const hosts = createPluginHosts(getHostRuntimeStore(), plugin.lifetime.signal); + const paseo = createPluginReferenceApi({ + paseo: plugin.paseo, + resolveHost: hosts.getPaseoClient, + readProject: state.getProject, + navigation: { + openWorkspace(ref) { + navigateToWorkspace({ serverId: ref.hostId, workspaceId: ref.id }); + }, + openAgent(ref) { + const agent = state.getAgent(ref); + if (!agent) throw new Error(i18n.t("sidebar.view.agentUnavailable")); + navigateToWorkspace({ + serverId: ref.hostId, + workspaceId: agent.workspace.id, + target: { kind: "agent", agentId: ref.id }, + }); + }, + openTerminal(ref) { + const terminal = state.getTerminal(ref); + if (!terminal) throw new Error(i18n.t("sidebar.view.terminalUnavailable")); + navigateToWorkspace({ + serverId: ref.hostId, + workspaceId: terminal.workspace.id, + target: { kind: "terminal", terminalId: ref.id }, + }); + }, + openProject(ref) { + const project = state.getProject(ref); + if (!project) throw new Error(i18n.t("sidebar.view.projectUnavailable")); + usePanelStore.getState().showMobileAgent(); + router.push( + buildNewWorkspaceRoute({ + serverId: ref.hostId, + projectId: ref.id, + sourceDirectory: project.rootPath, + displayName: project.displayName, + }), + ); + }, + }, + }); + const api = { paseo, hosts }; + apis.set(plugin.lifetime.signal, api); + return api; +} diff --git a/packages/app/src/plugins/client-state/source.browser.test.tsx b/packages/app/src/plugins/client-state/source.browser.test.tsx index 5a0b3f3811..acf3dc537b 100644 --- a/packages/app/src/plugins/client-state/source.browser.test.tsx +++ b/packages/app/src/plugins/client-state/source.browser.test.tsx @@ -1,3 +1,6 @@ +import { Nested } from "../../../../../plugin-examples/sidebar-views/client/nested"; +import { lightTheme } from "@/styles/theme"; +import { toPluginTheme } from "../theme"; import { createConnectedPluginHosts } from "./connected-hosts"; import { i18n } from "@/i18n/i18next"; import { fr } from "@/i18n/resources/fr"; @@ -254,6 +257,62 @@ afterEach(() => { renders.clear(); }); +it("shows a restored child in the nested example while its cached parent remains archived", () => { + seedHost("host-1"); + const parent = { + ...agent, + id: "archived-parent", + title: "Archived parent", + archivedAt: new Date("2026-11-01T00:00:00Z"), + }; + const child = { + ...agent, + id: "restored-child", + title: "Restored child", + parentAgentId: parent.id, + labels: { "paseo.parent-agent-id": parent.id }, + archivedAt: parent.archivedAt, + }; + const store = useSessionStore.getState(); + store.setProjects("host-1", [ + { + projectId: workspace.projectId, + projectDisplayName: "Project", + projectCustomName: null, + projectRootPath: workspace.projectRootPath, + projectKind: "git", + }, + ]); + store.setAgents( + "host-1", + new Map([ + [parent.id, parent], + [child.id, child], + ]), + ); + const view = { + theme: toPluginTheme(lightTheme), + layout: { compact: false, platform: "web" as const }, + active: { workspace: null, agent: null }, + navigation: { openWorkspace() {}, openAgent() {}, openProject() {}, openTerminal() {} }, + dismiss() {}, + }; + const source = mount(); + expect(source.getAgent(parent.id)?.title).toBe(parent.title); + expect(container?.textContent).not.toContain(child.title); + act(() => + store.setAgents( + "host-1", + new Map([ + [parent.id, parent], + [child.id, { ...child, archivedAt: null }], + ]), + ), + ); + expect(container?.querySelector('[aria-label="Open agent Restored child"]')).not.toBeNull(); + expect(container?.querySelector('[aria-label="Open agent Archived parent"]')).toBeNull(); +}); + describe("sidebar data hooks through the app store and plugin provider", () => { it("renders only one workspace row when its root agent starts waiting", () => { seedHost("host-1"); diff --git a/packages/app/src/plugins/installation-provider.test.tsx b/packages/app/src/plugins/installation-provider.test.tsx index 36fa2a5c0c..737c614c8c 100644 --- a/packages/app/src/plugins/installation-provider.test.tsx +++ b/packages/app/src/plugins/installation-provider.test.tsx @@ -1,7 +1,8 @@ import { PluginTranslationRuntime } from "@getpaseo/plugin/client/host"; import { usePaseo, useRpc } from "@getpaseo/plugin/client"; import { defineRpc } from "@getpaseo/plugin"; -import type { PaseoApi } from "@getpaseo/client"; +import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { QueryClient } from "@tanstack/react-query"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; @@ -24,7 +25,13 @@ function installation(invoked: string[]): InstalledPlugin { lifetime: new AbortController(), locale: "en", i18n: new PluginTranslationRuntime({ getLocale: () => "en", subscribe: () => () => {} }), - paseo: { dispose: async () => undefined } as PaseoApi, + paseo: createPaseoApi( + new DaemonClient({ + url: "ws://127.0.0.1:1", + clientId: "installation-provider", + reconnect: { enabled: false }, + }), + ), invoke: async (method) => { invoked.push(method); return { state: "green" }; @@ -76,4 +83,6 @@ it("gives every surface the installation's client and RPCs on its first frame", expect(received.every((paseo) => paseo === plugin.paseo)).toBe(true); await expect(rpc!({})).resolves.toEqual({ state: "green" }); expect(invoked).toEqual(["deploys.status"]); + plugin.lifetime.abort(); + await plugin.paseo.dispose(); }); diff --git a/packages/app/src/plugins/installation-provider.tsx b/packages/app/src/plugins/installation-provider.tsx index ba3b159f99..a51875fa88 100644 --- a/packages/app/src/plugins/installation-provider.tsx +++ b/packages/app/src/plugins/installation-provider.tsx @@ -1,3 +1,4 @@ +import { getPluginClientApi } from "./client-state/api"; import { QueryClientProvider } from "@tanstack/react-query"; import { PaseoApiProvider, @@ -17,7 +18,7 @@ export function PluginInstallationProvider({ }) { return ( - + {children} diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts index a1a766e5c8..ad85e7aec2 100644 --- a/packages/app/src/plugins/registry-lifecycle.test.ts +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -1,3 +1,4 @@ +import { getPluginClientApi } from "./client-state/api"; import { filePanelTitle } from "@/file-panels/choice/presentation"; import { i18n } from "@/i18n/i18next"; import { createPluginHosts } from "./hosts"; @@ -50,7 +51,7 @@ function registry( }, installation.lifetime.signal, ), - paseo: installation.paseo, + paseo: getPluginClientApi(installation).paseo, i18n: installation.i18n, rpc: async () => { throw new Error("Unexpected plugin RPC"); diff --git a/packages/app/src/plugins/registry-requirements.test.ts b/packages/app/src/plugins/registry-requirements.test.ts index 8628c0ebfb..6add4ee6d6 100644 --- a/packages/app/src/plugins/registry-requirements.test.ts +++ b/packages/app/src/plugins/registry-requirements.test.ts @@ -1,3 +1,4 @@ +import { getPluginClientApi } from "./client-state/api"; import { createPluginHosts } from "./hosts"; import { afterEach, expect, it } from "vitest"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -25,7 +26,7 @@ function registry(version: string) { }, installation.lifetime.signal, ), - paseo: installation.paseo, + paseo: getPluginClientApi(installation).paseo, i18n: installation.i18n, rpc: async () => { throw new Error("No RPC in this plugin"); diff --git a/packages/app/src/plugins/settings/device.test.ts b/packages/app/src/plugins/settings/device.test.ts index b3d9f14b99..607e66976b 100644 --- a/packages/app/src/plugins/settings/device.test.ts +++ b/packages/app/src/plugins/settings/device.test.ts @@ -3,8 +3,7 @@ import { defineSettings } from "@getpaseo/plugin"; import { z } from "zod"; import { createDeviceSettings } from "./device"; -function device() { - const records = new Map(); +function device(records = new Map()) { return createDeviceSettings({ getItem: async (key) => records.get(key) ?? null, setItem: async (key, value) => { @@ -63,3 +62,36 @@ describe("device plugin settings", () => { expect(await upgraded.read()).toMatchObject({ status: "ready", values: { collapsed: ["v1"] } }); }); }); + +it("persists a validated migration once and returns the persisted revision across restarts", async () => { + const records = new Map(); + const settings = device(records); + await settings("plugin", definition).write({ revision: "missing", values: { collapsed: ["a"] } }); + let migrations = 0; + const upgraded = defineSettings({ + ...definition, + version: 2, + migrate: () => { + migrations++; + return { collapsed: ["migrated"] }; + }, + }); + const first = await settings("plugin", upgraded).read(); + const restarted = device(records)("plugin", upgraded); + expect(await restarted.read()).toEqual(first); + expect(migrations).toBe(1); + expect(first).toEqual({ + status: "ready", + revision: JSON.stringify({ version: 2, values: { collapsed: ["migrated"] } }), + values: { collapsed: ["migrated"] }, + }); + expect( + await restarted.write({ + revision: JSON.stringify({ version: 1, values: { collapsed: ["a"] } }), + values: { collapsed: [] }, + }), + ).toMatchObject({ status: "conflict" }); + expect( + await restarted.write({ revision: first.revision, values: { collapsed: [] } }), + ).toMatchObject({ status: "saved" }); +}); diff --git a/packages/app/src/plugins/settings/device.ts b/packages/app/src/plugins/settings/device.ts index 7c348c302f..523eed599f 100644 --- a/packages/app/src/plugins/settings/device.ts +++ b/packages/app/src/plugins/settings/device.ts @@ -27,9 +27,10 @@ export function createDeviceSettings(storage: DeviceStorage) { const key = `@paseo:plugin-device-settings:${installationId}/${definition.id}`; async function read(): Promise { const raw = await storage.getItem(key); - const revision = raw ?? "missing"; + let revision = raw ?? "missing"; try { let values: unknown = {}; + let migrated = false; if (raw !== null) { const stored = envelope.parse(JSON.parse(raw)); if (stored.version > definition.version) @@ -39,10 +40,16 @@ export function createDeviceSettings(storage: DeviceStorage) { if (!definition.migrate) throw new Error("This plugin has no migration for its saved settings"); values = await definition.migrate(values, stored.version); + migrated = true; } } - const parsed = await definition.schema.parseAsync(values); - return { status: "ready", revision, values: z.json().parse(parsed) }; + const parsed = z.json().parse(await definition.schema.parseAsync(values)); + if (migrated) { + const serialized = JSON.stringify({ version: definition.version, values: parsed }); + await storage.setItem(key, serialized); + revision = serialized; + } + return { status: "ready", revision, values: parsed }; } catch (error) { return { status: "invalid", diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ead91e719c..ad00bc767b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,4 @@ +import { workspaceLabelKey } from "@getpaseo/protocol/workspace-labels"; export type { TerminalReplayEvent, TerminalStreamEvent } from "./terminal-stream-router.js"; import type { AgentAddress } from "@getpaseo/protocol/addresses"; import { parentAddressLabels } from "@getpaseo/protocol/agent-labels"; @@ -189,6 +190,8 @@ export interface PaseoWorkspaceHandle { current(): PaseoWorkspace | null; refresh(options?: { requestId?: string }): Promise; setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>; + setPinned(pinned: boolean): Promise<{ pinnedAt: string | null }>; + setLabel(name: string, options: { assigned: boolean }): Promise<{ labels: string[] }>; setBaseBranch(options: { baseRef: string; requestId?: string; @@ -866,6 +869,14 @@ function createWorkspaceHandleFactory( current: () => current, refresh, setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId), + setPinned: (pinned) => daemonClient.setWorkspacePinned(id, pinned), + setLabel: (name, options) => + assignWorkspaceLabel({ + client: daemonClient, + workspaceId: id, + name, + assigned: options.assigned, + }), setBaseBranch: (options) => daemonClient.setWorkspaceBaseBranch({ ...options, workspaceId: id }), archive: async (requestId) => { @@ -1212,3 +1223,28 @@ function createGeneratedClientId(): string { : Math.random().toString(36).slice(2); return `paseo-sdk-${randomId}`; } + +interface WorkspaceLabelAssignment { + client: DaemonClient; + workspaceId: string; + name: string; + assigned: boolean; +} +async function assignWorkspaceLabel( + input: WorkspaceLabelAssignment, +): Promise<{ labels: string[] }> { + input.client.ensureConnected(); + // COMPAT(workspaceLabels): added in v0.5.0, remove gate after 2027-08-14. + if (input.client.getLastServerInfoMessage()?.features?.workspaceLabels !== true) + throw new Error("Update the host to assign workspace labels."); + const catalog = await input.client.listWorkspaceLabels(); + const key = workspaceLabelKey(input.name); + const existing = catalog.labels.find((label) => workspaceLabelKey(label.name) === key); + const label = existing ?? { name: input.name, color: "violet" as const }; + const result = await input.client.setWorkspaceLabel({ + workspaceId: input.workspaceId, + label, + assigned: input.assigned, + }); + return { labels: result.workspaceLabels }; +} diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index 7496511bc9..24385a3a16 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -393,7 +393,7 @@ describe("sidebar data SDK boundary", () => { it("accepts references and preserves string-id panel consumers", () => { expect( typeErrors(` - import { useWorkspace, useAgent, type PluginClientContext, type PluginSidebarViewProps, type WorkspaceRef, type AgentRef, useProject, useTerminal, useWorkspaces, useAgents, useProjects, useTerminals, type ProjectRef, type TerminalRef, type Query, type Group } from "@getpaseo/plugin/client"; + import { useWorkspace, useAgent, usePaseo, type PluginClientContext, type PluginSidebarViewProps, type WorkspaceRef, type AgentRef, useProject, useTerminal, useWorkspaces, useAgents, useProjects, useTerminals, type ProjectRef, type TerminalRef, type Query, type Group } from "@getpaseo/plugin/client"; import type { PluginWorkspaceSnapshot } from "@getpaseo/plugin"; function Panel({ workspace, agent }: { workspace: WorkspaceRef; agent: AgentRef }) { const snapshot: PluginWorkspaceSnapshot | null = useWorkspace(workspace); @@ -405,6 +405,13 @@ describe("sidebar data SDK boundary", () => { const groups: readonly Group[] = useAgents({ groupBy: item => item.status }); const projects: readonly ProjectRef[] = useProjects({ hosts: [workspace.hostId] }); const terminals: readonly TerminalRef[] = useTerminals(); + const paseo = usePaseo(); + void paseo.workspaces.ref(workspace).setPinned(true); + void paseo.workspaces.ref(workspace).setLabel("Later", { assigned: true }); + void paseo.workspaces.ref("old-id").setTitle("Title"); + if (projects[0]) { paseo.projects.ref(projects[0]).open(); void paseo.projects.ref(projects[0]).createWorkspace(); } + if (terminals[0]) { paseo.terminals.ref(terminals[0]).focus(); void paseo.terminals.ref(terminals[0]).kill(); } + void paseo.agents.ref(agent); if (projects[0]) useProject(projects[0], item => item.displayName); if (terminals[0]) useTerminal(terminals[0]); function View(props: PluginSidebarViewProps) { diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts index d6fd17ebcb..229e8ec478 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -1,7 +1,7 @@ import type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "../refs.js"; import type { PluginText } from "../text.js"; import type { ComponentType } from "react"; -import type { PaseoApi } from "@getpaseo/client"; +import type { PluginPaseoApi } from "./reference-api.js"; import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; import type { PluginFile } from "./file.js"; @@ -328,7 +328,7 @@ export interface PluginTimelineRendererContribution( contract: PluginRpcContract, input: PluginWireInput>, diff --git a/packages/plugin/src/client/host.ts b/packages/plugin/src/client/host.ts index 724cfab437..19cf91f46d 100644 --- a/packages/plugin/src/client/host.ts +++ b/packages/plugin/src/client/host.ts @@ -38,3 +38,5 @@ export { export type { PluginLocaleSource } from "./translations.js"; export { createEntityRefs } from "./refs.js"; + +export { createPluginReferenceApi } from "./reference-api.js"; diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index 8d6ea59084..1be686d922 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -116,3 +116,5 @@ export { useTabState } from "./tab.js"; export type { WorkspaceRef, AgentRef, ProjectRef, TerminalRef } from "../refs.js"; export type { Query, Group, GroupKey } from "./query.js"; + +export type { PluginPaseoApi, PluginProjectHandle, PluginTerminalHandle } from "./reference-api.js"; diff --git a/packages/plugin/src/client/paseo-context.tsx b/packages/plugin/src/client/paseo-context.tsx index fe1a0b9c5c..8ec6910bb2 100644 --- a/packages/plugin/src/client/paseo-context.tsx +++ b/packages/plugin/src/client/paseo-context.tsx @@ -1,17 +1,23 @@ -import type { PaseoApi } from "@getpaseo/client"; +import type { PluginPaseoApi } from "./reference-api.js"; import { createContext, useContext, type ReactNode } from "react"; -const PaseoApiContext = createContext(null); +const PaseoApiContext = createContext(null); -export function usePaseoContextValue(): PaseoApi | null { +export function usePaseoContextValue(): PluginPaseoApi | null { return useContext(PaseoApiContext); } -export function PaseoApiProvider({ children, paseo }: { children: ReactNode; paseo: PaseoApi }) { +export function PaseoApiProvider({ + children, + paseo, +}: { + children: ReactNode; + paseo: PluginPaseoApi; +}) { return {children}; } -export function usePaseo(): PaseoApi { +export function usePaseo(): PluginPaseoApi { const paseo = usePaseoContextValue(); if (!paseo) throw new Error("usePaseo must run inside a contributed plugin surface"); return paseo; diff --git a/packages/plugin/src/client/reference-api.ts b/packages/plugin/src/client/reference-api.ts new file mode 100644 index 0000000000..b782e26438 --- /dev/null +++ b/packages/plugin/src/client/reference-api.ts @@ -0,0 +1,88 @@ +import type { + PaseoApi, + PaseoAgent, + PaseoAgentHandle, + PaseoWorkspace, + PaseoWorkspaceHandle, + PaseoWorkspaceCreateOptions, +} from "@getpaseo/client"; +import type { PaseoTerminal, PaseoTerminalHandle } from "@getpaseo/client"; +import type { PluginProjectSnapshot } from "../contracts.js"; +import type { AgentRef, ProjectRef, TerminalRef, WorkspaceRef } from "../refs.js"; +import type { PluginSidebarViewProps } from "./contracts.js"; + +export interface PluginProjectHandle { + readonly ref: ProjectRef; + open(): void; + createWorkspace( + input?: Omit & { + source?: PaseoWorkspaceCreateOptions["source"]; + }, + ): Promise; +} +export interface PluginTerminalHandle extends PaseoTerminalHandle { + focus(): void; +} +export interface PluginPaseoApi extends PaseoApi { + workspaces: Omit & { + ref(workspace: string | PaseoWorkspace | WorkspaceRef): PaseoWorkspaceHandle; + }; + agents: Omit & { + ref(agent: string | PaseoAgent | AgentRef): PaseoAgentHandle; + }; + projects: PaseoApi["projects"] & { ref(project: ProjectRef): PluginProjectHandle }; + terminals: Omit & { + ref(terminal: TerminalRef): PluginTerminalHandle; + ref(terminal: string | PaseoTerminal): PaseoTerminalHandle; + }; +} + +/** Host adapter; the installation retains its single API identity across every surface. */ +export function createPluginReferenceApi(input: { + paseo: PaseoApi; + resolveHost(hostId: string): PaseoApi; + readProject(ref: ProjectRef): PluginProjectSnapshot | null; + navigation: PluginSidebarViewProps["navigation"]; +}): PluginPaseoApi { + const { paseo } = input; + const workspaceRef = paseo.workspaces.ref; + const agentRef = paseo.agents.ref; + const terminalRef = paseo.terminals.ref; + function refWorkspace(workspace: string | PaseoWorkspace | WorkspaceRef) { + if (typeof workspace !== "string" && "hostId" in workspace) + return input.resolveHost(workspace.hostId).workspaces.ref(workspace.id); + return workspaceRef(workspace); + } + function refAgent(agent: string | PaseoAgent | AgentRef) { + if (typeof agent !== "string" && "hostId" in agent) + return input.resolveHost(agent.hostId).agents.ref(agent.id); + return agentRef(agent); + } + function terminal(ref: TerminalRef): PluginTerminalHandle; + function terminal(ref: string | PaseoTerminal): PaseoTerminalHandle; + function terminal(ref: TerminalRef | string | PaseoTerminal): PaseoTerminalHandle { + if (typeof ref !== "string" && "hostId" in ref) { + const handle = input.resolveHost(ref.hostId).terminals.ref(ref.id); + return Object.assign(handle, { focus: () => input.navigation.openTerminal(ref) }); + } + return terminalRef(ref); + } + function project(ref: ProjectRef): PluginProjectHandle { + return { + ref, + open: () => input.navigation.openProject(ref), + async createWorkspace(options = {}) { + const snapshot = input.readProject(ref); + if (!snapshot) throw new Error("Project is no longer available"); + const source = options.source ?? { kind: "directory", path: snapshot.rootPath }; + return input.resolveHost(ref.hostId).workspaces.create({ ...options, source }); + }, + }; + } + return Object.assign(paseo, { + workspaces: Object.assign(paseo.workspaces, { ref: refWorkspace }), + agents: Object.assign(paseo.agents, { ref: refAgent }), + projects: Object.assign(paseo.projects, { ref: project }), + terminals: Object.assign(paseo.terminals, { ref: terminal }), + }); +} diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 1b2e7192c7..1792a9d7ff 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -2122,7 +2122,7 @@ it.each(examples)("builds and loads the %s plugin example", async (example) => { const runtime = createTestRuntime( { settingsDirectory }, undefined, - resolveDaemonVersion(import.meta.url), + example === "sidebar-views" ? "0.16.0" : resolveDaemonVersion(import.meta.url), ); try { await runtime.startPlugin(example, path.join(examplesDirectory, example)); diff --git a/plugin-examples/sidebar-views/client/agents.tsx b/plugin-examples/sidebar-views/client/agents.tsx new file mode 100644 index 0000000000..8d0d469121 --- /dev/null +++ b/plugin-examples/sidebar-views/client/agents.tsx @@ -0,0 +1,34 @@ +import type { PluginAgentSnapshot } from "@getpaseo/plugin"; +import { ScrollView, View } from "react-native"; +import { useAgents, type PluginSidebarViewProps } from "@getpaseo/plugin/client"; +import { AgentRow, Heading } from "./rows"; +export function agentTask(agent: PluginAgentSnapshot) { + if (agent.attentionReason === "permission") return "Respond"; + if (agent.requiresAttention || agent.status === "error") return "Review"; + if (agent.status === "running" || agent.status === "initializing") return "Wait"; + return "Start"; +} +function Task({ title, view }: { title: string; view: PluginSidebarViewProps }) { + const agents = useAgents({ + where: (item) => agentTask(item) === title, + orderBy: ["lastActivityAt", "desc"], + }); + return ( + + + {agents.map((reference) => ( + + ))} + + ); +} +export function Agents(view: PluginSidebarViewProps) { + return ( + + + + + + + ); +} diff --git a/plugin-examples/sidebar-views/client/labels.tsx b/plugin-examples/sidebar-views/client/labels.tsx new file mode 100644 index 0000000000..da2e1ae7ab --- /dev/null +++ b/plugin-examples/sidebar-views/client/labels.tsx @@ -0,0 +1,191 @@ +import { useMutation } from "@tanstack/react-query"; +import { useCallback, useMemo, useState } from "react"; +import { Pressable, ScrollView, Text, View } from "react-native"; +import { + usePaseo, + useSettings, + useWorkspaces, + type WorkspaceRef, + type PluginSidebarViewProps, +} from "@getpaseo/plugin/client"; +import { folders } from "../shared/preferences"; +import { Heading, WorkspaceRow } from "./rows"; +interface Move { + reference: WorkspaceRef; + from: string | null; +} +function folderLabel(key: string): string | null { + return key === "unlabelled" ? null : key.slice("label:".length); +} +function Movable({ + reference, + folder, + view, + select, +}: { + reference: WorkspaceRef; + folder: string | null; + view: PluginSidebarViewProps; + select: (move: Move) => void; +}) { + const pick = useCallback(() => select({ reference, from: folder }), [select, reference, folder]); + return ( + + + + + + + ); +} +export function Labels(view: PluginSidebarViewProps) { + const paseo = usePaseo(); + const settings = useSettings(folders); + const [selected, select] = useState(null); + const groups = useWorkspaces({ + orderBy: "name", + groupBy: (item) => + item.labels.length ? item.labels.map((label) => `label:${label}`) : ["unlabelled"], + }); + const style = useMemo( + () => ({ color: view.theme.colors.foreground, paddingHorizontal: 12 }), + [view.theme], + ); + const move = useMutation({ + mutationFn: async (destination: string | null) => { + if (!selected) return; + const workspace = paseo.workspaces.ref(selected.reference); + // Add first: if adding fails the workspace keeps its original folder. + if (destination && destination !== selected.from) + await workspace.setLabel(destination, { assigned: true }); + if (selected.from && selected.from !== destination) + await workspace.setLabel(selected.from, { assigned: false }); + select(null); + }, + }); + const toggle = useCallback( + (folder: string) => { + if (settings.status !== "ready") return; + void settings.save( + { + collapsed: settings.values.collapsed.includes(folder) + ? settings.values.collapsed.filter((item) => item !== folder) + : [...settings.values.collapsed, folder], + }, + settings.revision, + ); + }, + [settings], + ); + const cancel = useCallback(() => select(null), []); + if (settings.status === "loading") return Loading folders…; + if (settings.status !== "ready") + return ( + + {settings.error} + + + + {settings.status === "invalid" ? ( + + + + ) : null} + + ); + return ( + + {selected ? ( + + Choose a destination folder + + + + + ) : ( + Long press a workspace or choose Move… + )} + {move.error ? {move.error.message} : null} + {Array.from( + new Set(["label:Inbox", "label:Later", "unlabelled", ...groups.map((group) => group.key)]), + ).map((folder) => ( + + {groups + .find((group) => group.key === folder) + ?.items.map((reference) => ( + + ))} + + ))} + {settings.saveError ? {settings.saveError} : null} + + ); +} +function Folder({ + folder, + view, + selected, + pending, + move, + collapsed, + toggle, + children, +}: { + folder: string; + view: PluginSidebarViewProps; + selected: boolean; + pending: boolean; + move: (folder: string | null) => void; + collapsed: boolean; + toggle: (folder: string) => void; + children: import("react").ReactNode; +}) { + const toggleFolder = useCallback(() => toggle(folder), [toggle, folder]); + const label = folderLabel(folder); + const title = label ?? "Unlabelled"; + const destination = useCallback(() => move(label), [move, label]); + return ( + + + + + {selected ? ( + + + + ) : null} + {collapsed ? null : children} + + ); +} diff --git a/plugin-examples/sidebar-views/client/nested.tsx b/plugin-examples/sidebar-views/client/nested.tsx new file mode 100644 index 0000000000..b6f4b84eb3 --- /dev/null +++ b/plugin-examples/sidebar-views/client/nested.tsx @@ -0,0 +1,109 @@ +import { ScrollView, View } from "react-native"; +import { + useProjects, + useWorkspaces, + useAgents, + useAgent, + type AgentRef, + type WorkspaceRef, + type ProjectRef, + type PluginSidebarViewProps, +} from "@getpaseo/plugin/client"; +import { AgentRow, ProjectHeading, WorkspaceRow } from "./rows"; +const indent = { paddingLeft: 12 }; +function AgentTree({ reference, view }: { reference: AgentRef; view: PluginSidebarViewProps }) { + const agent = useAgent(reference); + const children = useAgents({ + where: (item) => + item.parentAgent?.key === reference.key && item.workspace.key === agent?.workspace.key, + orderBy: "createdAt", + }); + return ( + + + + {children.map((child) => ( + + ))} + + + ); +} +function RootWithParent({ + reference, + parent, + workspace, + view, +}: { + reference: AgentRef; + parent: AgentRef; + workspace: WorkspaceRef; + view: PluginSidebarViewProps; +}) { + const parents = useAgents({ + where: (item) => item.ref.key === parent.key && item.workspace.key === workspace.key, + }); + return parents.length > 0 ? null : ; +} +function Root({ + reference, + workspace, + view, +}: { + reference: AgentRef; + workspace: WorkspaceRef; + view: PluginSidebarViewProps; +}) { + const agent = useAgent(reference); + if (!agent) return null; + return agent.parentAgent ? ( + + ) : ( + + ); +} +function Workspace({ reference, view }: { reference: WorkspaceRef; view: PluginSidebarViewProps }) { + const agents = useAgents({ + where: (item) => item.workspace.key === reference.key, + orderBy: "createdAt", + }); + return ( + + + + {agents.map((agent) => ( + + ))} + + + ); +} +function Project({ reference, view }: { reference: ProjectRef; view: PluginSidebarViewProps }) { + const workspaces = useWorkspaces({ + where: (item) => item.project.key === reference.key, + orderBy: "name", + }); + return ( + + + {workspaces.map((workspace) => ( + + ))} + + ); +} +export function Nested(view: PluginSidebarViewProps) { + const projects = useProjects({ orderBy: "displayName" }); + return ( + + {projects.map((reference) => ( + + ))} + + ); +} diff --git a/plugin-examples/sidebar-views/client/rows.tsx b/plugin-examples/sidebar-views/client/rows.tsx new file mode 100644 index 0000000000..ef55333f15 --- /dev/null +++ b/plugin-examples/sidebar-views/client/rows.tsx @@ -0,0 +1,139 @@ +import { useCallback, useMemo } from "react"; +import { Pressable, Text, View } from "react-native"; +import { + useWorkspace, + useAgent, + useProject, + type WorkspaceRef, + type AgentRef, + type ProjectRef, + type PluginSidebarViewProps, +} from "@getpaseo/plugin/client"; + +export function Heading({ title, view }: { title: string; view: PluginSidebarViewProps }) { + const style = useMemo( + () => ({ + color: view.theme.colors.foregroundMuted, + fontSize: 11, + paddingHorizontal: 12, + paddingTop: 12, + paddingBottom: 4, + }), + [view.theme], + ); + return ( + + {title} + + ); +} +export function WorkspaceRow({ + reference, + view, + onLongPress, +}: { + reference: WorkspaceRef; + view: PluginSidebarViewProps; + onLongPress?: () => void; +}) { + const workspace = useWorkspace(reference); + const open = useCallback( + () => view.navigation.openWorkspace(reference), + [view.navigation, reference], + ); + const style = useMemo( + () => ({ color: view.theme.colors.foreground, fontSize: 12, lineHeight: 16 }), + [view.theme], + ); + const detail = useMemo( + () => ({ color: view.theme.colors.foregroundMuted, fontSize: 11 }), + [view.theme], + ); + const background = useMemo( + () => ({ + ...rowStyle, + backgroundColor: + view.active.workspace?.key === reference.key ? view.theme.colors.surface2 : undefined, + }), + [view.active.workspace, view.theme, reference], + ); + if (!workspace) return null; + return ( + + + {workspace.title ?? workspace.name} + + + {workspace.indicatorLabel} + + + ); +} +export function AgentRow({ + reference, + view, +}: { + reference: AgentRef; + view: PluginSidebarViewProps; +}) { + const agent = useAgent(reference); + const open = useCallback( + () => view.navigation.openAgent(reference), + [view.navigation, reference], + ); + const style = useMemo( + () => ({ color: view.theme.colors.foreground, fontSize: 12 }), + [view.theme], + ); + if (!agent) return null; + return ( + + + {agent.title ?? agent.provider} + + + ); +} +export function ProjectHeading({ + reference, + view, +}: { + reference: ProjectRef; + view: PluginSidebarViewProps; +}) { + const project = useProject(reference); + const open = useCallback( + () => view.navigation.openProject(reference), + [view.navigation, reference], + ); + if (!project) return null; + return ( + + + + + + + ); +} +const rowStyle = { + minHeight: 28, + paddingHorizontal: 12, + paddingVertical: 4, + justifyContent: "center", +} as const; diff --git a/plugin-examples/sidebar-views/client/workspaces.tsx b/plugin-examples/sidebar-views/client/workspaces.tsx index a6f2d6d4d0..5c41eb4242 100644 --- a/plugin-examples/sidebar-views/client/workspaces.tsx +++ b/plugin-examples/sidebar-views/client/workspaces.tsx @@ -1,54 +1,37 @@ -import { useCallback, useMemo } from "react"; +import { ScrollView, View } from "react-native"; import { - useWorkspace, + useProjects, useWorkspaces, - type WorkspaceRef, + type ProjectRef, type PluginSidebarViewProps, } from "@getpaseo/plugin/client"; -import { Pressable, ScrollView, Text } from "react-native"; - -function WorkspaceRow({ - reference, - view, -}: { - reference: WorkspaceRef; - view: PluginSidebarViewProps; -}) { - const workspace = useWorkspace(reference); - const open = useCallback( - () => view.navigation.openWorkspace(reference), - [view.navigation, reference], - ); - const textStyle = useMemo( - () => ({ color: view.theme.colors.foreground, fontSize: 12, lineHeight: 16 }), - [view.theme], - ); - const labelStyle = useMemo( - () => ({ color: view.theme.colors.foregroundMuted, fontSize: 11 }), - [view.theme], - ); - if (!workspace) return null; +import { Heading, ProjectHeading, WorkspaceRow } from "./rows"; +function Project({ reference, view }: { reference: ProjectRef; view: PluginSidebarViewProps }) { + const workspaces = useWorkspaces({ + where: (item) => item.project.key === reference.key && !item.pinnedAt, + orderBy: "name", + }); return ( - - {workspace.title ?? workspace.name} - {workspace.indicatorLabel} - + + + {workspaces.map((workspace) => ( + + ))} + ); } export function Workspaces(view: PluginSidebarViewProps) { - const workspaces = useWorkspaces({ orderBy: "name" }); + const projects = useProjects({ orderBy: "displayName" }); + const pinned = useWorkspaces({ where: (item) => !!item.pinnedAt, orderBy: "name" }); return ( - {workspaces.map((reference) => ( + {pinned.length ? : null} + {pinned.map((reference) => ( ))} + {projects.map((reference) => ( + + ))} ); } - -const rowStyle = { minHeight: 28, paddingHorizontal: 12, justifyContent: "center" } as const; diff --git a/plugin-examples/sidebar-views/index.client.tsx b/plugin-examples/sidebar-views/index.client.tsx index 2a820ca0c1..e1f43db8eb 100644 --- a/plugin-examples/sidebar-views/index.client.tsx +++ b/plugin-examples/sidebar-views/index.client.tsx @@ -1,10 +1,16 @@ import type { PluginClientContext } from "@getpaseo/plugin/client"; import { Workspaces } from "./client/workspaces"; - +import { Agents } from "./client/agents"; +import { Nested } from "./client/nested"; +import { Labels } from "./client/labels"; export default function contribute(client: PluginClientContext) { - return client.addSidebarView({ - id: "workspaces", - title: "Example workspaces", - Component: Workspaces, - }); + const cleanups = [ + client.addSidebarView({ id: "workspaces", title: "Example workspaces", Component: Workspaces }), + client.addSidebarView({ id: "agents", title: "Example agents", Component: Agents }), + client.addSidebarView({ id: "nested", title: "Example nested agents", Component: Nested }), + client.addSidebarView({ id: "labels", title: "Example label folders", Component: Labels }), + ]; + return () => { + for (const cleanup of cleanups) void cleanup(); + }; } diff --git a/plugin-examples/sidebar-views/shared/preferences.ts b/plugin-examples/sidebar-views/shared/preferences.ts new file mode 100644 index 0000000000..69b65eb397 --- /dev/null +++ b/plugin-examples/sidebar-views/shared/preferences.ts @@ -0,0 +1,8 @@ +import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; +export const folders = defineSettings({ + id: "folders", + scope: "device", + version: 1, + schema: z.object({ collapsed: z.array(z.string()).default([]) }), +}); diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 5e88cf9bf5..1489372e8b 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -634,6 +634,13 @@ Cleanup can be async. Release timers, watchers, sockets, and other resources cre Register async handlers with `server.addHook(name, handler)`. See [Hooks](/docs/plugins/hooks) for names, payloads, ordering, errors, and complete examples. +## Sidebar views + +`addSidebarView({ id, title, description?, Component })` registers an optional replacement for the +sidebar content region. It returns cleanup. The user selects one view in Settings > Sidebar; +registration does not activate it. See [Build a sidebar view](sidebar-views.md) for the props, +ref-based queries and actions, device state and theme tokens. + ## Screens and sidebar items A screen is a full page of plugin UI. A sidebar item is a component Paseo renders in the sidebar @@ -1387,13 +1394,13 @@ export default function contribute(server: PluginServerContext) { } ``` -| Definition field | Contract | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | -| `id` | Lowercase identifier matching `[a-z][a-z0-9_-]*`; unique within the installation. | -| `scope` | Required `"host"`. All authorized clients of that host share the document. No per-user, device-local, or cross-host synchronization. | -| `version` | Required positive integer describing the schema, independent of the write revision. | -| `schema` | Zod schema for JSON values. Supply defaults so parsing `{}` produces a complete document. | -| `migrate(values, fromVersion)` | Optional synchronous or asynchronous conversion from an older stored version. Its output must pass the current schema. | +| Definition field | Contract | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | Lowercase identifier matching `[a-z][a-z0-9_-]*`; unique within the installation. | +| `scope` | Required `"host"` or `"device"`. Host documents are shared by authorized clients. Device documents stay on this device, keyed by installation and settings ID; use them for layout preferences. | +| `version` | Required positive integer describing the schema, independent of the write revision. | +| `schema` | Zod schema for JSON values. Supply defaults so parsing `{}` produces a complete document. | +| `migrate(values, fromVersion)` | Optional synchronous or asynchronous conversion from an older stored version. Its output must pass the current schema. | Call `useSettings(preferences)` in any contributed component. It returns a discriminated state: diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index bb979f52e2..de3e486908 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -510,6 +510,10 @@ export default function contribute(client: PluginClientContext) { 使用 `server.addHook(name, handler)` 注册异步处理函数。请参阅[钩子](/docs/plugins/hooks),了解命名、载荷、顺序、错误处理和完整示例。 +## 侧边栏视图 {#sidebar-views} + +`addSidebarView({ id, title, description?, Component })` 注册可替换侧边栏内容区域的视图,并返回清理函数。用户在设置的侧边栏页面中选择视图;注册不会激活它。视图属性、基于引用的查询与操作、设备状态和主题令牌见[构建侧边栏视图](sidebar-views.md)。 + ## 界面和侧边栏项 {#screens-and-sidebar-items} 界面是一整页的插件 UI。侧边栏项是 Paseo 在侧边栏顶部或底部渲染的组件;按下它时做什么由它决定。先注册界面,再注册一个打开它的项: @@ -1157,13 +1161,13 @@ export default function contribute(server: PluginServerContext) { } ``` -| 定义字段 | 契约 | -| ------------------------------ | --------------------------------------------------------------------------------------------- | -| `id` | 匹配 `[a-z][a-z0-9_-]*` 的小写标识符;在同一次安装中唯一。 | -| `scope` | 必须为 `"host"`。该主机所有获得授权的客户端共享这份文档。没有按用户、设备本地或跨主机的同步。 | -| `version` | 必填的正整数,描述 schema 的版本,与写入修订号无关。 | -| `schema` | JSON 值的 Zod schema。请提供默认值,使解析 `{}` 能得到一份完整的文档。 | -| `migrate(values, fromVersion)` | 可选的同步或异步函数,从较旧的存储版本转换。它的输出必须通过当前的 schema。 | +| 定义字段 | 契约 | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | +| `id` | 匹配 `[a-z][a-z0-9_-]*` 的小写标识符;在同一次安装中唯一。 | +| `scope` | 必填 `"host"` 或 `"device"`。主机文档由授权客户端共享。设备文档以插件安装和设置 ID 为键保存在当前设备上,用于布局偏好。 | +| `version` | 必填的正整数,描述 schema 的版本,与写入修订号无关。 | +| `schema` | JSON 值的 Zod schema。请提供默认值,使解析 `{}` 能得到一份完整的文档。 | +| `migrate(values, fromVersion)` | 可选的同步或异步函数,从较旧的存储版本转换。它的输出必须通过当前的 schema。 | 在任何贡献的组件中调用 `useSettings(preferences)`。它返回一个可区分的状态: diff --git a/public-docs/plugins/sidebar-views.md b/public-docs/plugins/sidebar-views.md new file mode 100644 index 0000000000..25c29f86a3 --- /dev/null +++ b/public-docs/plugins/sidebar-views.md @@ -0,0 +1,164 @@ +# Build a sidebar view + +A sidebar view replaces the content between Paseo's header and footer. You receive theme tokens, +active entity refs and navigation methods, then render your own React Native UI. Install the +[sidebar-views example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/sidebar-views) +to try projects, task-based agents, nested agents and label folders. + +## Register a view + +```tsx +import type { + PluginClientContext, + PluginSidebarViewProps, + WorkspaceRef, +} from "@getpaseo/plugin/client"; +import { useWorkspaces, useWorkspace } from "@getpaseo/plugin/client"; +import { Pressable, ScrollView, Text } from "react-native"; + +function WorkspaceRow({ + reference, + navigation, + theme, +}: { + reference: WorkspaceRef; + navigation: PluginSidebarViewProps["navigation"]; + theme: PluginSidebarViewProps["theme"]; +}) { + const workspace = useWorkspace(reference); + if (!workspace) return null; + return ( + navigation.openWorkspace(reference)}> + {workspace.title ?? workspace.name} + + ); +} +function Workspaces({ navigation, theme }: PluginSidebarViewProps) { + const workspaces = useWorkspaces({ orderBy: "name" }); + return ( + + {workspaces.map((reference) => ( + + ))} + + ); +} +export default function contribute(client: PluginClientContext) { + return client.addSidebarView({ id: "workspaces", title: "My workspaces", Component: Workspaces }); +} +``` + +Registration adds an option under **Settings > Sidebar > Sidebar view**. The user selects it; +installing the plugin leaves the current view selected. Header navigation, plugin header items, +the callout and footer stay visible. A missing or disabled view falls back to Default view. +A view that throws shows a placeholder with **Use default view**. + +The view renders once across connected hosts. `active.workspace` and `active.agent` are refs or +`null`. Use `navigation.openWorkspace`, `openAgent`, `openTerminal` or `openProject` with a ref; +`openProject` starts the new workspace flow. Navigation closes the compact sidebar. Call +`dismiss()` after navigation you implement yourself. + +## Query refs, read each row + +`useWorkspaces`, `useAgents`, `useProjects` and `useTerminals` return immutable arrays of refs. +A ref has `kind`, `hostId`, `id` and a cross-host `key`. The same entity keeps the same frozen +ref for the app session. Pass it to `useWorkspace`, `useAgent`, `useProject` or `useTerminal` +inside the row. A deleted entity returns `null`. + +```tsx +const projects = useProjects({ orderBy: "displayName" }); // Includes empty projects. +const agents = useAgents({ + hosts: [hostId], + where: (agent) => agent.workspace.key === workspaceRef.key, + orderBy: ["lastActivityAt", "desc"], +}); +const folders = useWorkspaces({ + groupBy: (workspace) => workspace.labels, // A workspace can appear in several folders. +}); +``` + +Every query accepts `hosts` (server IDs), `where`, `orderBy` and `groupBy`. Omit `hosts` to query +every connected host. `orderBy` accepts a snapshot field, `[field, "asc" | "desc"]`, or a +comparator. Null fields sort last. Keep comparators cheap; they run when data changes. +`groupBy` returns a string, `{ key, title?, color? }`, or an array of these. Grouped results have +`key`, `title`, `color` and `items`. Groups follow first appearance after sorting; duplicate keys +use the first title. An empty array puts an item in no group. + +An unchanged ref sequence keeps its array identity. Groups keep their identity when their +metadata and item sequence stay the same. A workspace status change updates that row without +rendering the list again. A grouping change updates the affected groups. Inline query objects +and functions work without managing subscriptions yourself. + +Entity hooks accept a selector and optional equality function: + +```tsx +const workspace = useWorkspace(ref); // Interned snapshot, compared by identity. +const name = useWorkspace(ref, (item) => ({ title: item.title })); // Shallow equality. +const selected = useWorkspace(ref, selector, equals); +``` + +Without a selector, custom equality receives snapshots. With a selector, it receives selections. +Existing workspace panels can continue passing a host-local string ID to `useWorkspace` and +`useAgent`. Prefer refs for a view spanning hosts. `useHosts()` retains its existing +`serverId`, `label` and connection `status` fields. + +Workspace snapshots include `project`, `pinnedAt`, `labels`, `background`, `branch`, +`lastPromptAt`, `parentAgent` and `createdAt` (null on older hosts). Use `indicator` and the +localized `indicatorLabel` to display the status Paseo shows. `status` remains the raw daemon +status. Agent snapshots include their workspace and parent refs. Project snapshots provide +`displayName`, `rootPath`, `kind` and `iconRevision`. + +## Act on a ref + +`usePaseo()` routes ref-based actions to the ref's host: + +```tsx +const paseo = usePaseo(); +await paseo.workspaces.ref(workspaceRef).setPinned(true); +await paseo.workspaces.ref(workspaceRef).setLabel("Later", { assigned: true }); +await paseo.workspaces.ref(workspaceRef).setLabel("Inbox", { assigned: false }); +paseo.projects.ref(projectRef).open(); +await paseo.projects.ref(projectRef).createWorkspace(); +paseo.terminals.ref(terminalRef).focus(); +await paseo.terminals.ref(terminalRef).kill(); +``` + +Workspace and agent handles retain their existing methods. Assigning a new label creates it; +assigning an existing label keeps its color. The label-folder example adds the destination +before removing the source, so a failed assignment preserves the original folder. It owns the +move gesture and error display. + +## Keep layout choices on the device + +Define a [settings document](reference.md#settings-screens) with `scope: "device"` for collapsed +folders or manual order. `useSettings` reads and writes it through the same revision-based API +as host settings. Device documents belong to the installation and settings ID, persist across +app launches, and are shared across that installation's client surfaces. They are not synced +to other devices. Use `scope: "host"` for data clients should share through the daemon. + +## Match the app's density + +Render with React Native primitives. The SDK exports no sidebar-view rows, sections, lists, +badges or drag helpers. The existing header/footer item kit is a separate surface. + +For a dense row use a minimum height of 28, horizontal padding of 12, a 12-point primary label +with 16-point line height and an 11-point secondary label. Use these supplied tokens: + +| Row element | Theme token | +| -------------------------------- | ------------------------ | +| Primary label | `colors.foreground` | +| Secondary label, section heading | `colors.foregroundMuted` | +| Selected row fill | `colors.surface2` | +| Separator | `colors.border` | +| Action or focus accent | `colors.accent` | +| Success indicator | `colors.statusSuccess` | +| Needs input indicator | `colors.statusWarning` | +| Failure indicator | `colors.statusDanger` | + +Give pressable rows an accessible label and show controls on touch layouts. The examples use +only plugin-owned components and the tokens passed to the view. diff --git a/public-docs/plugins/sidebar-views.zh-CN.md b/public-docs/plugins/sidebar-views.zh-CN.md new file mode 100644 index 0000000000..0c6ab89d33 --- /dev/null +++ b/public-docs/plugins/sidebar-views.zh-CN.md @@ -0,0 +1,135 @@ +# 构建侧边栏视图 {#build-a-sidebar-view} + +侧边栏视图替换 Paseo 页眉与页脚之间的内容。插件接收主题令牌、当前实体引用和导航方法,使用 React Native 绘制自己的界面。安装[侧边栏视图示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/sidebar-views),体验项目列表、按任务分组的 Agent、嵌套 Agent 和标签文件夹。 + +## 注册视图 {#register-a-view} + +```tsx +import type { + PluginClientContext, + PluginSidebarViewProps, + WorkspaceRef, +} from "@getpaseo/plugin/client"; +import { useWorkspaces, useWorkspace } from "@getpaseo/plugin/client"; +import { Pressable, ScrollView, Text } from "react-native"; + +function WorkspaceRow({ + reference, + navigation, + theme, +}: { + reference: WorkspaceRef; + navigation: PluginSidebarViewProps["navigation"]; + theme: PluginSidebarViewProps["theme"]; +}) { + const workspace = useWorkspace(reference); + if (!workspace) return null; + return ( + navigation.openWorkspace(reference)}> + {workspace.title ?? workspace.name} + + ); +} +function Workspaces({ navigation, theme }: PluginSidebarViewProps) { + const workspaces = useWorkspaces({ orderBy: "name" }); + return ( + + {workspaces.map((reference) => ( + + ))} + + ); +} +export default function contribute(client: PluginClientContext) { + return client.addSidebarView({ id: "workspaces", title: "My workspaces", Component: Workspaces }); +} +``` + +注册后,用户可以在**设置 → 侧边栏 → 侧边栏视图**中选择该视图。安装插件不会切换当前视图。页眉导航、插件页眉项、提示区域和页脚保持可见。所选视图缺失或被禁用时,显示默认视图。视图抛出异常时,显示带有**使用默认视图**按钮的占位界面。 + +视图同时展示所有已连接主机的数据。`active.workspace` 和 `active.agent` 是引用或 `null`。将引用传给 `navigation.openWorkspace`、`openAgent`、`openTerminal` 或 `openProject`;`openProject` 打开新建工作区流程。导航会关闭紧凑布局的侧边栏。如果插件自行执行导航,完成后调用 `dismiss()`。 + +## 查询引用,逐行读取 {#query-refs-read-each-row} + +`useWorkspaces`、`useAgents`、`useProjects` 和 `useTerminals` 返回不可变的引用数组。引用包含 `kind`、`hostId`、`id` 和跨主机唯一的 `key`。在一次应用会话中,同一实体使用同一个冻结引用。在行组件内将它传给 `useWorkspace`、`useAgent`、`useProject` 或 `useTerminal`。实体删除后返回 `null`。 + +```tsx +const projects = useProjects({ orderBy: "displayName" }); // Includes empty projects. +const agents = useAgents({ + hosts: [hostId], + where: (agent) => agent.workspace.key === workspaceRef.key, + orderBy: ["lastActivityAt", "desc"], +}); +const folders = useWorkspaces({ + groupBy: (workspace) => workspace.labels, // A workspace can appear in several folders. +}); +``` + +每个查询都支持 `hosts`(服务器 ID)、`where`、`orderBy` 和 `groupBy`。省略 `hosts` 时查询所有已连接主机。`orderBy` 接受快照字段、`[field, "asc" | "desc"]` 或比较函数。空值排在最后。比较函数会在数据变化时执行,应保持开销较小。 + +`groupBy` 返回字符串、`{ key, title?, color? }` 或这些值组成的数组。分组结果包含 `key`、`title`、`color` 和 `items`。分组按排序后首次出现的顺序排列;重复的键使用第一个标题。返回空数组表示该项不属于任何分组。 + +引用序列不变时,数组对象保持不变。分组的元数据和项序列不变时,分组对象保持不变。工作区状态变化只更新对应行,不会重新渲染整个列表。分组变化会更新受影响的分组。查询对象和函数可以内联编写,无需自行管理订阅。 + +实体钩子支持选择器和可选的相等比较函数: + +```tsx +const workspace = useWorkspace(ref); // Interned snapshot, compared by identity. +const name = useWorkspace(ref, (item) => ({ title: item.title })); // Shallow equality. +const selected = useWorkspace(ref, selector, equals); +``` + +没有选择器时,自定义相等比较函数接收快照;有选择器时接收选择结果。现有工作区面板仍可向 `useWorkspace` 和 `useAgent` 传入主机本地的字符串 ID。跨主机视图应使用引用。`useHosts()` 保留现有的 `serverId`、`label` 和连接 `status` 字段。 + +工作区快照包含 `project`、`pinnedAt`、`labels`、`background`、`branch`、`lastPromptAt`、`parentAgent` 和 `createdAt`(旧主机上为 null)。使用 `indicator` 和本地化的 `indicatorLabel` 显示 Paseo 的状态。`status` 保持 daemon 原始状态。Agent 快照包含工作区和父 Agent 引用。项目快照提供 `displayName`、`rootPath`、`kind` 和 `iconRevision`。 + +## 列出终端 {#list-terminals} + +终端示例支持选择主机、列出终端、聚焦终端标签页,以及通过句柄关闭终端。`useTerminals({ hosts: [hostId] })` 要求主机声明 `terminalCollection` 能力。查询不支持该能力的主机时,终端读取会抛出要求更新主机的错误;其他实体钩子继续工作。将主机选择器放在错误边界之外,方便用户切换主机。省略 `hosts` 时,所有已连接主机都必须支持该能力。 + +终端快照包含 `workspace`、`title`、`isScript` 和 `createdAt`。在时间戳跟踪引入前启动的 worker 所保留的终端,其 `createdAt` 为 null。脚本归属来自工作区脚本运行时条目。主机观察跟踪终端创建、关闭、标题变化和重新连接,并在最后一个终端读取订阅者离开时释放。 + +## 通过引用执行操作 {#act-on-a-ref} + +`usePaseo()` 将基于引用的操作发送到引用所属的主机: + +```tsx +const paseo = usePaseo(); +await paseo.workspaces.ref(workspaceRef).setPinned(true); +await paseo.workspaces.ref(workspaceRef).setLabel("Later", { assigned: true }); +await paseo.workspaces.ref(workspaceRef).setLabel("Inbox", { assigned: false }); +paseo.projects.ref(projectRef).open(); +await paseo.projects.ref(projectRef).createWorkspace(); +paseo.terminals.ref(terminalRef).focus(); +await paseo.terminals.ref(terminalRef).kill(); +``` + +工作区和 Agent 句柄保留现有方法。分配新标签会创建该标签;分配已有标签会保留其颜色。标签文件夹示例先添加目标标签,再移除源标签,因此分配失败时仍保留原文件夹。插件负责移动手势和错误显示。 + +## 在设备上保存布局选择 {#keep-layout-choices-on-the-device} + +定义 `scope: "device"` 的[设置文档](reference.md#settings-screens),保存折叠文件夹或手动排序。`useSettings` 通过与主机设置相同的修订版本 API 读取和写入。设备文档属于插件安装和设置 ID,在应用重启后保留,并由该安装的客户端界面共享。它们不会同步到其他设备。需要由客户端通过 daemon 共享的数据使用 `scope: "host"`。 + +## 匹配应用的信息密度 {#match-the-apps-density} + +使用 React Native 原语绘制界面。SDK 不导出侧边栏视图的行、分区、列表、徽标或拖拽辅助组件。现有页眉和页脚组件包属于独立界面。 + +紧凑行的最小高度为 28,水平内边距为 12;主标签字号为 12、行高为 16,次标签字号为 11。使用以下主题令牌: + +| 行元素 | 主题令牌 | +| ---------------- | ------------------------ | +| 主标签 | `colors.foreground` | +| 次标签、分区标题 | `colors.foregroundMuted` | +| 选中行背景 | `colors.surface2` | +| 分隔线 | `colors.border` | +| 操作或焦点强调色 | `colors.accent` | +| 成功指示 | `colors.statusSuccess` | +| 需要输入指示 | `colors.statusWarning` | +| 失败指示 | `colors.statusDanger` | + +为可点击行提供无障碍标签,并在触摸布局中显示操作控件。示例仅使用插件自己的组件和传给视图的主题令牌。