mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -34,6 +34,7 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "removePlugin"
|
||||
| "reloadPlugin"
|
||||
| "setWorkspaceTitle"
|
||||
| "setWorkspaceLabel"
|
||||
>;
|
||||
|
||||
type CreateWorkspacePayload = Awaited<ReturnType<NewWorkspaceDaemonClient["createWorkspace"]>>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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<typeof createPluginReferenceApi>;
|
||||
hosts: ReturnType<typeof createPluginHosts>;
|
||||
}
|
||||
// Registry presentation clones share the installation lifetime and its client pool.
|
||||
const apis = new WeakMap<AbortSignal, InstallationApi>();
|
||||
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;
|
||||
}
|
||||
@@ -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<string, Agent>([
|
||||
[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(<Nested {...view} />);
|
||||
expect(source.getAgent(parent.id)?.title).toBe(parent.title);
|
||||
expect(container?.textContent).not.toContain(child.title);
|
||||
act(() =>
|
||||
store.setAgents(
|
||||
"host-1",
|
||||
new Map<string, Agent>([
|
||||
[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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<QueryClientProvider client={plugin.queryClient}>
|
||||
<PaseoApiProvider paseo={plugin.paseo}>
|
||||
<PaseoApiProvider paseo={getPluginClientApi(plugin).paseo}>
|
||||
<PluginRpcProvider invoke={plugin.invoke} subscribe={plugin.subscribe}>
|
||||
<PluginTranslationProvider runtime={plugin.i18n}>{children}</PluginTranslationProvider>
|
||||
</PluginRpcProvider>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -3,8 +3,7 @@ import { defineSettings } from "@getpaseo/plugin";
|
||||
import { z } from "zod";
|
||||
import { createDeviceSettings } from "./device";
|
||||
|
||||
function device() {
|
||||
const records = new Map<string, string>();
|
||||
function device(records = new Map<string, string>()) {
|
||||
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<string, string>();
|
||||
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" });
|
||||
});
|
||||
|
||||
@@ -27,9 +27,10 @@ export function createDeviceSettings(storage: DeviceStorage) {
|
||||
const key = `@paseo:plugin-device-settings:${installationId}/${definition.id}`;
|
||||
async function read(): Promise<ReadResult> {
|
||||
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",
|
||||
|
||||
@@ -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<PaseoWorkspace | null>;
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<AgentRef>[] = 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) {
|
||||
|
||||
@@ -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<Schema extends ZodType = Zod
|
||||
}
|
||||
|
||||
export interface PluginCommandCapabilities {
|
||||
paseo: PaseoApi;
|
||||
paseo: PluginPaseoApi;
|
||||
rpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
input: PluginWireInput<ZodInput<InputSchema>>,
|
||||
|
||||
@@ -38,3 +38,5 @@ export {
|
||||
export type { PluginLocaleSource } from "./translations.js";
|
||||
|
||||
export { createEntityRefs } from "./refs.js";
|
||||
|
||||
export { createPluginReferenceApi } from "./reference-api.js";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<PaseoApi | null>(null);
|
||||
const PaseoApiContext = createContext<PluginPaseoApi | null>(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 <PaseoApiContext.Provider value={paseo}>{children}</PaseoApiContext.Provider>;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<PaseoWorkspaceCreateOptions, "source"> & {
|
||||
source?: PaseoWorkspaceCreateOptions["source"];
|
||||
},
|
||||
): Promise<PaseoWorkspaceHandle>;
|
||||
}
|
||||
export interface PluginTerminalHandle extends PaseoTerminalHandle {
|
||||
focus(): void;
|
||||
}
|
||||
export interface PluginPaseoApi extends PaseoApi {
|
||||
workspaces: Omit<PaseoApi["workspaces"], "ref"> & {
|
||||
ref(workspace: string | PaseoWorkspace | WorkspaceRef): PaseoWorkspaceHandle;
|
||||
};
|
||||
agents: Omit<PaseoApi["agents"], "ref"> & {
|
||||
ref(agent: string | PaseoAgent | AgentRef): PaseoAgentHandle;
|
||||
};
|
||||
projects: PaseoApi["projects"] & { ref(project: ProjectRef): PluginProjectHandle };
|
||||
terminals: Omit<PaseoApi["terminals"], "ref"> & {
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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 (
|
||||
<View>
|
||||
<Heading title={title} view={view} />
|
||||
{agents.map((reference) => (
|
||||
<AgentRow key={reference.key} reference={reference} view={view} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
export function Agents(view: PluginSidebarViewProps) {
|
||||
return (
|
||||
<ScrollView>
|
||||
<Task title="Respond" view={view} />
|
||||
<Task title="Review" view={view} />
|
||||
<Task title="Wait" view={view} />
|
||||
<Task title="Start" view={view} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<View>
|
||||
<WorkspaceRow reference={reference} view={view} onLongPress={pick} />
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Move workspace ${reference.id}`}
|
||||
onPress={pick}
|
||||
>
|
||||
<Heading title="Move…" view={view} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
export function Labels(view: PluginSidebarViewProps) {
|
||||
const paseo = usePaseo();
|
||||
const settings = useSettings(folders);
|
||||
const [selected, select] = useState<Move | null>(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 <Text style={style}>Loading folders…</Text>;
|
||||
if (settings.status !== "ready")
|
||||
return (
|
||||
<View>
|
||||
<Text style={style}>{settings.error}</Text>
|
||||
<Pressable accessibilityRole="button" onPress={settings.reload}>
|
||||
<Heading title="Reload folders" view={view} />
|
||||
</Pressable>
|
||||
{settings.status === "invalid" ? (
|
||||
<Pressable accessibilityRole="button" onPress={settings.reset}>
|
||||
<Heading title="Reset folders" view={view} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<ScrollView>
|
||||
{selected ? (
|
||||
<View>
|
||||
<Text style={style}>Choose a destination folder</Text>
|
||||
<Pressable accessibilityRole="button" onPress={cancel}>
|
||||
<Heading title="Cancel move" view={view} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={style}>Long press a workspace or choose Move…</Text>
|
||||
)}
|
||||
{move.error ? <Text style={style}>{move.error.message}</Text> : null}
|
||||
{Array.from(
|
||||
new Set(["label:Inbox", "label:Later", "unlabelled", ...groups.map((group) => group.key)]),
|
||||
).map((folder) => (
|
||||
<Folder
|
||||
key={folder}
|
||||
folder={folder}
|
||||
view={view}
|
||||
selected={!!selected}
|
||||
pending={move.isPending}
|
||||
move={move.mutate}
|
||||
collapsed={settings.values.collapsed.includes(folder)}
|
||||
toggle={toggle}
|
||||
>
|
||||
{groups
|
||||
.find((group) => group.key === folder)
|
||||
?.items.map((reference) => (
|
||||
<Movable
|
||||
key={reference.key}
|
||||
reference={reference}
|
||||
folder={folderLabel(folder)}
|
||||
view={view}
|
||||
select={select}
|
||||
/>
|
||||
))}
|
||||
</Folder>
|
||||
))}
|
||||
{settings.saveError ? <Text style={style}>{settings.saveError}</Text> : null}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<View>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Toggle ${title}`}
|
||||
onPress={toggleFolder}
|
||||
>
|
||||
<Heading title={`${collapsed ? "▸" : "▾"} ${title}`} view={view} />
|
||||
</Pressable>
|
||||
{selected ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label === null ? "Remove current label" : `Move to ${title}`}
|
||||
disabled={pending}
|
||||
onPress={destination}
|
||||
>
|
||||
<Heading
|
||||
title={label === null ? "Remove current label" : `Move to ${title}`}
|
||||
view={view}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{collapsed ? null : children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<View>
|
||||
<AgentRow reference={reference} view={view} />
|
||||
<View style={indent}>
|
||||
{children.map((child) => (
|
||||
<AgentTree key={child.key} reference={child} view={view} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
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 : <AgentTree reference={reference} view={view} />;
|
||||
}
|
||||
function Root({
|
||||
reference,
|
||||
workspace,
|
||||
view,
|
||||
}: {
|
||||
reference: AgentRef;
|
||||
workspace: WorkspaceRef;
|
||||
view: PluginSidebarViewProps;
|
||||
}) {
|
||||
const agent = useAgent(reference);
|
||||
if (!agent) return null;
|
||||
return agent.parentAgent ? (
|
||||
<RootWithParent
|
||||
reference={reference}
|
||||
parent={agent.parentAgent}
|
||||
workspace={workspace}
|
||||
view={view}
|
||||
/>
|
||||
) : (
|
||||
<AgentTree reference={reference} view={view} />
|
||||
);
|
||||
}
|
||||
function Workspace({ reference, view }: { reference: WorkspaceRef; view: PluginSidebarViewProps }) {
|
||||
const agents = useAgents({
|
||||
where: (item) => item.workspace.key === reference.key,
|
||||
orderBy: "createdAt",
|
||||
});
|
||||
return (
|
||||
<View>
|
||||
<WorkspaceRow reference={reference} view={view} />
|
||||
<View style={indent}>
|
||||
{agents.map((agent) => (
|
||||
<Root key={agent.key} reference={agent} workspace={reference} view={view} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
function Project({ reference, view }: { reference: ProjectRef; view: PluginSidebarViewProps }) {
|
||||
const workspaces = useWorkspaces({
|
||||
where: (item) => item.project.key === reference.key,
|
||||
orderBy: "name",
|
||||
});
|
||||
return (
|
||||
<View>
|
||||
<ProjectHeading reference={reference} view={view} />
|
||||
{workspaces.map((workspace) => (
|
||||
<Workspace key={workspace.key} reference={workspace} view={view} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
export function Nested(view: PluginSidebarViewProps) {
|
||||
const projects = useProjects({ orderBy: "displayName" });
|
||||
return (
|
||||
<ScrollView>
|
||||
{projects.map((reference) => (
|
||||
<Project key={reference.key} reference={reference} view={view} />
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Text accessibilityRole="header" style={style}>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${workspace.title ?? workspace.name}`}
|
||||
onPress={open}
|
||||
onLongPress={onLongPress}
|
||||
style={background}
|
||||
>
|
||||
<Text numberOfLines={1} style={style}>
|
||||
{workspace.title ?? workspace.name}
|
||||
</Text>
|
||||
<Text numberOfLines={1} style={detail}>
|
||||
{workspace.indicatorLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open agent ${agent.title ?? agent.provider}`}
|
||||
onPress={open}
|
||||
style={rowStyle}
|
||||
>
|
||||
<Text numberOfLines={1} style={style}>
|
||||
{agent.title ?? agent.provider}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<View>
|
||||
<Heading title={project.displayName} view={view} />
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`New workspace in ${project.displayName}`}
|
||||
onPress={open}
|
||||
>
|
||||
<Heading title="+ New workspace" view={view} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const rowStyle = {
|
||||
minHeight: 28,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
justifyContent: "center",
|
||||
} as const;
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${workspace.name}`}
|
||||
onPress={open}
|
||||
style={rowStyle}
|
||||
>
|
||||
<Text style={textStyle}>{workspace.title ?? workspace.name}</Text>
|
||||
<Text style={labelStyle}>{workspace.indicatorLabel}</Text>
|
||||
</Pressable>
|
||||
<View>
|
||||
<ProjectHeading reference={reference} view={view} />
|
||||
{workspaces.map((workspace) => (
|
||||
<WorkspaceRow key={workspace.key} reference={workspace} view={view} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<ScrollView>
|
||||
{workspaces.map((reference) => (
|
||||
{pinned.length ? <Heading title="Pinned" view={view} /> : null}
|
||||
{pinned.map((reference) => (
|
||||
<WorkspaceRow key={reference.key} reference={reference} view={view} />
|
||||
))}
|
||||
{projects.map((reference) => (
|
||||
<Project key={reference.key} reference={reference} view={view} />
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const rowStyle = { minHeight: 28, paddingHorizontal: 12, justifyContent: "center" } as const;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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([]) }),
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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)`。它返回一个可区分的状态:
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Pressable onPress={() => navigation.openWorkspace(reference)}>
|
||||
<Text style={{ color: theme.colors.foreground }}>{workspace.title ?? workspace.name}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
function Workspaces({ navigation, theme }: PluginSidebarViewProps) {
|
||||
const workspaces = useWorkspaces({ orderBy: "name" });
|
||||
return (
|
||||
<ScrollView>
|
||||
{workspaces.map((reference) => (
|
||||
<WorkspaceRow
|
||||
key={reference.key}
|
||||
reference={reference}
|
||||
navigation={navigation}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
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.
|
||||
@@ -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 (
|
||||
<Pressable onPress={() => navigation.openWorkspace(reference)}>
|
||||
<Text style={{ color: theme.colors.foreground }}>{workspace.title ?? workspace.name}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
function Workspaces({ navigation, theme }: PluginSidebarViewProps) {
|
||||
const workspaces = useWorkspaces({ orderBy: "name" });
|
||||
return (
|
||||
<ScrollView>
|
||||
{workspaces.map((reference) => (
|
||||
<WorkspaceRow
|
||||
key={reference.key}
|
||||
reference={reference}
|
||||
navigation={navigation}
|
||||
theme={theme}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
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` |
|
||||
|
||||
为可点击行提供无障碍标签,并在触摸布局中显示操作控件。示例仅使用插件自己的组件和传给视图的主题令牌。
|
||||
Reference in New Issue
Block a user