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` | + +为可点击行提供无障碍标签,并在触摸布局中显示操作控件。示例仅使用插件自己的组件和传给视图的主题令牌。