feat(plugin): add shared header buttons and composer controls (#4577)

* feat(plugin): add shared header buttons and composer controls

* fix(plugin): isolate button registration imports and settle captures

* test(plugin): express button journeys through domain helpers
This commit is contained in:
Mohamed Boudra
2026-09-09 15:39:12 +02:00
committed by GitHub
parent 509a3e17bd
commit 0b3d82a469
40 changed files with 2607 additions and 559 deletions
+9 -41
View File
@@ -1,7 +1,7 @@
# Plugins
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
client slash commands, timeline items, composer pills, app themes, composer attachment sources, and settings screens.
client slash commands, timeline items, header buttons, composer pills, app themes, composer attachment sources, and settings screens.
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
@@ -344,48 +344,16 @@ external `href` or `xlink:href` references are rejected. Fragment references suc
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
SVG or URL.
## Contribute composer pills
## Contribute buttons
Add and remove targeted pills from the client entry lifecycle. `index.client.tsx` runs once per
plugin installation in each connected app and never runs in the daemon subprocess. It can subscribe
to the client API, call plugin RPCs, and own arbitrary client state without mounting a panel or
surface.
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/v0.8/reference.md#header-buttons)
owns the author API and placement rules. Keep presentation policy in this module so another
placement can reuse behavior without copying registration or action state.
```tsx
export function contributeClient(client: PluginClientContext) {
const pills = new Map<string, () => void>();
const unsubscribe = client.paseo.agents.subscribe((update) => {
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
const { id: agentId, workspaceId } = update.agent;
pills.get(agentId)?.();
pills.set(
agentId,
client.addComposerPill({
id: "review",
title: "Open review",
workspaceId,
agentId,
Component: ReviewPill,
async onPress() {
await client.rpc(refreshReview, { agentId });
client.openPanel("review", { workspaceId, agentId });
},
}),
);
});
return () => {
unsubscribe();
for (const remove of pills.values()) remove();
};
}
```
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry.
`addComposerPill` returns an idempotent removal function. A pill appears only in the
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
the host, or unloading the app tears down the contribution.
Native sheets teleport their children. Button surfaces rebuild the installation's SDK, state,
query, and toast providers inside the surface content, including overflow pages from different
plugins. Providers only around the trigger do not reach those bodies.
## Contribute timeline items
@@ -0,0 +1,13 @@
import { test } from "../support/fixtures";
import { withButtonAuthorExample } from "../support/helpers/plugin-button-example";
test("the button author example shows each capability without a competing overflow button", async ({
page,
}, info) => {
test.setTimeout(120_000);
await withButtonAuthorExample(page, info, async (buttons) => {
await buttons.refreshFromHeaderAndComposer();
await buttons.useMenusAndToggleButtons();
await buttons.inspectLiveWorkspaceDetails();
});
});
@@ -0,0 +1,14 @@
import { test } from "../support/fixtures";
import { withButtonShowcase } from "../support/helpers/plugin-buttons";
test("plugin header and composer buttons share actions, menus, content, and updates", async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
await withButtonShowcase(page, testInfo, async (buttons) => {
await buttons.openWideMenusAndPopovers();
await buttons.runAndUpdateActions();
await buttons.openCompactSheets();
await buttons.hideAndUnloadOpenButtons();
});
});
@@ -61,12 +61,6 @@ function SidebarCollisionSurface() {
return <View><Text>Sidebar collision surface</Text></View>;
}
function ComposerPill({ theme, workspaceId, agentId }) {
const workspace = useWorkspace(workspaceId, (value) => ({ title: value.title }));
const agent = useAgent(agentId, (value) => ({ title: value.title }));
return <><Icon name="Scan" size={14} color={theme.colors.foregroundMuted} /><Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, flexShrink: 1 }}>Review {workspace?.title}:{agent?.title}</Text></>;
}
function contributeClient(client) {
const pills = new Map();
const remove = (agentId) => {
@@ -81,23 +75,25 @@ function contributeClient(client) {
const agent = update.agent;
if (agent.title !== "Plugin panel context agent" || !agent.workspaceId) return;
remove(agent.id);
let removePill = () => {};
removePill = client.addComposerPill({
const pill = client.addComposerPill({
id: "review",
title: "Open composer review",
workspaceId: agent.workspaceId,
agentId: agent.id,
Component: ComposerPill,
async onPress() {
button: {
title: "Open composer review",
icon: "Scan",
label: "Review",
behavior: { kind: "action", async onPress() {
await client.rpc(recordComposerOpen, { workspaceId: agent.workspaceId });
removePill();
pill.remove();
client.openPanel("agent", {
workspaceId: agent.workspaceId,
agentId: agent.id,
});
} },
},
});
pills.set(agent.id, removePill);
pills.set(agent.id, () => pill.remove());
});
return () => {
unsubscribe();
@@ -333,9 +329,7 @@ test.describe("plugin workspace panels and Command Center", () => {
timeout: 30_000,
});
const composerPill = page.getByRole("button", { name: "Open composer review" });
await expect(composerPill).toContainText(
"Review Unrelated title update:Plugin panel context agent",
);
await expect(composerPill).toContainText("Review");
await capture(page, testInfo, "plugin-composer-pill-wide");
await page.setViewportSize(COMPACT_VIEWPORT);
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
@@ -0,0 +1,195 @@
import type { Locator, TestInfo } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { copyPluginExample } from "./plugin-fixture";
import { gotoAppShell } from "./app";
import { buildAgentRoute } from "./mock-agent";
import { openCommandCenter } from "./command-center";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import { waitForSettledPosition } from "./sheet-layout";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
function headerButtons(page: Page) {
return page.getByRole("toolbar", { name: "Workspace actions", exact: true }).getByRole("button");
}
async function exampleCommand(page: Page, name: string) {
await page.setViewportSize(WIDE);
const panel = await openCommandCenter(page);
const title = `Button examples: ${name}`;
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
async function capture(
page: Page,
info: TestInfo,
name: string,
subject: Locator = headerButtons(page),
) {
await expect(headerButtons(page)).toHaveCount(1);
await expect(subject).toBeInViewport();
await waitForSettledPosition(subject);
const file = info.outputPath(`${name}.png`);
await page.screenshot({ path: file, animations: "disabled" });
await info.attach(name, { path: file, contentType: "image/png" });
}
async function closeDetails(page: Page) {
await page.getByRole("button", { name: "Close workspace details", exact: true }).click();
await expect(
page.getByRole("button", { name: "Close workspace details", exact: true }),
).toHaveCount(0);
}
export async function withButtonAuthorExample(
page: Page,
info: TestInfo,
run: (buttons: {
refreshFromHeaderAndComposer(): Promise<void>;
useMenusAndToggleButtons(): Promise<void>;
inspectLiveWorkspaceDetails(): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({
repoPrefix: "button-example-",
title: "Button examples",
});
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Example agent",
model: "ten-second-stream",
modeId: "load-test",
});
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const config = await client.getDaemonConfig();
const example = await copyPluginExample("buttons");
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
await run({
refreshFromHeaderAndComposer: () =>
test.step("icon-only header action and labeled composer action update in place", async () => {
await exampleCommand(page, "action");
await headerButtons(page).click();
await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)");
await page.getByRole("button", { name: "Refresh context", exact: true }).click();
await expect(
page.getByRole("button", { name: "Refresh context", exact: true }),
).toContainText("Refreshed · 2");
await capture(page, info, "01-actions");
await page.setViewportSize(COMPACT);
await capture(page, info, "02-compact-actions");
}),
useMenusAndToggleButtons: () =>
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => {
await exampleCommand(page, "menu");
await headerButtons(page).click();
await expect(
page.getByRole("menuitem", { name: "Publish (unavailable in example)", exact: true }),
).toBeDisabled();
await capture(
page,
info,
"03-header-menu",
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
);
await page.getByRole("menuitem", { name: "Workspace details", exact: true }).click();
await expect(page.getByText("Button examples", { exact: true }).last()).toBeVisible();
await closeDetails(page);
await page.getByRole("button", { name: "Composer tools", exact: true }).click();
await capture(
page,
info,
"04-composer-menu",
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
);
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await page.getByRole("menuitem", { name: "Hide example buttons", exact: true }).click();
await expect(headerButtons(page)).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Composer tools", exact: true }),
).toHaveCount(0);
await exampleCommand(page, "show");
await exampleCommand(page, "disable");
await expect(headerButtons(page)).toBeDisabled();
await expect(
page.getByRole("button", { name: "Composer tools", exact: true }),
).toBeDisabled();
await capture(page, info, "05-disabled");
await exampleCommand(page, "enable");
await page.setViewportSize(COMPACT);
await headerButtons(page).click();
await expect(
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
).toBeInViewport();
await capture(
page,
info,
"06-compact-menu",
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
);
await page.getByRole("menuitem", { name: "Refresh workspace", exact: true }).click();
}),
inspectLiveWorkspaceDetails: () =>
test.step("custom icons and content react to workspace changes and adapt to sheets", async () => {
await exampleCommand(page, "popover");
await headerButtons(page).click();
await expect(
page.getByRole("button", { name: "Close workspace details", exact: true }),
).toBeVisible();
await capture(
page,
info,
"07-header-popover",
page.getByRole("button", { name: "Close workspace details", exact: true }),
);
await client.setWorkspaceTitle(workspace.workspaceId, "Renamed from the daemon");
await expect(
page.getByText("Renamed from the daemon", { exact: true }).last(),
).toBeVisible();
await closeDetails(page);
await page.getByRole("button", { name: "Context details", exact: true }).click();
await capture(
page,
info,
"08-composer-popover",
page.getByRole("button", { name: "Close workspace details", exact: true }),
);
await closeDetails(page);
await page.setViewportSize(COMPACT);
await page.getByRole("button", { name: "Context details", exact: true }).click();
await expect(
page.getByRole("button", { name: "Close workspace details", exact: true }),
).toBeInViewport();
await capture(
page,
info,
"09-compact-popover",
page.getByRole("button", { name: "Close workspace details", exact: true }),
);
await closeDetails(page);
await client.disablePlugin("button-examples");
await expect(headerButtons(page)).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Context details", exact: true }),
).toHaveCount(0);
}),
});
} finally {
await client.removePlugin("button-examples");
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
await client.close();
await example.cleanup();
await workspace.cleanup();
}
}
@@ -0,0 +1,486 @@
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Locator, TestInfo } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { buildAgentRoute } from "./mock-agent";
import { openCommandCenter } from "./command-center";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { seedWorkspace } from "./seed-client";
import { pluginRequirements } from "./plugin-fixture";
import { waitForSettledPosition } from "./sheet-layout";
const PLUGIN_ID = "button-showcase";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
function clientSource(workspaceId: string, agentId: string): string {
return `import React from "react";
import { Text, View, Pressable } from "react-native";
import { useWorkspace, useRpc } from "@getpaseo/plugin/client";
import { useQuery } from "@tanstack/react-query";
import { summary } from "./shared/rpc";
function StatusIcon({ size, theme }) {
return <View style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: theme.colors.statusSuccess }} />;
}
function Details({ workspaceId, theme, layout, close }) {
const workspace = useWorkspace(workspaceId, (workspace) => workspace.name);
const rpc = useRpc(summary);
const query = useQuery({ queryKey: ["summary"], queryFn: () => rpc({}) });
return <View style={{ gap: 12 }}>
<Text style={{ color: theme.colors.foreground, fontSize: 18 }}>Deployment status</Text>
<Text style={{ color: theme.colors.foregroundMuted }}>{workspace}</Text>
<Text style={{ color: theme.colors.statusSuccess }}>{query.data?.message ?? "Loading deployment..."}</Text>
<Text style={{ color: theme.colors.foregroundMuted }}>3 checks passed · Production</Text>
<Text style={{ color: theme.colors.foregroundMuted }}>Presentation: {layout.compact ? "compact" : "wide"}</Text>
<Pressable accessibilityRole="button" accessibilityLabel="Close deployment details" onPress={close} style={{ padding: 8, borderRadius: 6, backgroundColor: theme.colors.surface2 }}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
</View>;
}
export default function contribute(client) {
const workspaceId = ${JSON.stringify(workspaceId)};
const agentId = ${JSON.stringify(agentId)};
const registrations = [];
let finish = () => {};
let finishReview = () => {};
let attempts = 0;
const action = { kind: "action", async onPress() {
if (++attempts === 1) throw new Error("Deployment failed. Try again.");
await new Promise((resolve) => { finish = resolve; });
deploy.update({ label: "Deployed" });
} };
const header = (id, button) => { const registration = client.addHeaderButton({ id, workspaceId, button }); registrations.push(registration); return registration; };
const pill = (id, button) => { const registration = client.addComposerPill({ id, workspaceId, agentId, button }); registrations.push(registration); return registration; };
const menu = { kind: "menu", items: [
{ kind: "item", id: "run", title: "Run checks", icon: "Play", behavior: { kind: "action", onPress() { checks.update({ label: "Checks passed" }); } } },
{ kind: "separator", id: "divider" },
{ kind: "item", id: "environment", title: "Environment", behavior: { kind: "menu", items: [
{ kind: "item", id: "staging", title: "Staging", icon: "FlaskConical", behavior: { kind: "action", onPress() {} } },
{ kind: "item", id: "production", title: "Production", icon: "Globe", disabled: true, behavior: { kind: "action", onPress() {} } },
] } },
{ kind: "item", id: "details", title: "Deployment details", icon: "Activity", behavior: { kind: "popover", Content: Details } },
] };
const deploy = header("deploy", { title: "Deploy application", icon: "Rocket", label: "Deploy", behavior: action });
const checks = header("checks", { title: "Header checks", icon: "ListChecks", label: "Checks", behavior: menu });
header("status", { title: "Header status", icon: StatusIcon, label: "Healthy", behavior: { kind: "popover", Content: Details } });
header("logs", { title: "Open deployment logs", icon: "ScrollText", behavior: { kind: "popover", Content: Details } });
header("locked", { title: "Production locked", icon: "Lock", disabled: true, behavior: { kind: "action", onPress() {} } });
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
command("finish", "Finish deployment", () => finish());
command("finish-review", "Finish review", () => finishReview());
command("hide-deploy", "Hide deployment button", () => deploy.update({ visible: false }));
command("show-deploy", "Show deployment button", () => deploy.update({ visible: true }));
command("hide", "Hide review button", () => review.update({ visible: false }));
command("show", "Show review button", () => review.update({ visible: true, label: "Review ready" }));
command("disable", "Disable example buttons", () => { deploy.update({ disabled: true }); review.update({ disabled: true }); });
command("enable", "Enable example buttons", () => { deploy.update({ disabled: false }); review.update({ disabled: false }); });
command("icon-only", "Use icon-only header", () => deploy.update({ label: undefined }));
command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false }));
command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true }));
const unsubscribe = client.paseo.workspaces.ref(workspaceId).subscribe((update) => {
if (update.kind !== "upsert") return;
deploy.update({ visible: update.workspace.name !== "Compact checks" });
pillMenu.update({ visible: update.workspace.name !== "Hide composer menu" });
});
return () => { unsubscribe(); finish(); finishReview(); for (const registration of registrations) registration.remove(); };
}`;
}
async function installShowcase(workspaceId: string, agentId: string) {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const config = await client.getDaemonConfig();
const directory = await mkdtemp(path.join(tmpdir(), "paseo-buttons-"));
await mkdir(path.join(directory, "shared"));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), clientSource(workspaceId, agentId));
await writeFile(
path.join(directory, "shared/rpc.ts"),
`import { defineRpc } from "@getpaseo/plugin"; import { z } from "zod"; export const summary = defineRpc({ name: "summary", input: z.object({}), output: z.object({ message: z.string() }) });`,
);
await writeFile(
path.join(directory, "index.server.ts"),
`import { summary } from "./shared/rpc"; export default function contribute(server) { server.handle(summary, async () => ({ message: "All systems operational" })); return () => {}; }`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
return {
disable: () => client.disablePlugin(PLUGIN_ID),
setTitle: (title: string) => client.setWorkspaceTitle(workspaceId, title),
async cleanup() {
await client.removePlugin(PLUGIN_ID);
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
},
};
}
async function press(page: Page, title: string) {
await page.getByRole("button", { name: title, exact: true }).click();
}
async function openHeaderOverflow(page: Page) {
await page
.getByRole("toolbar", { name: "Workspace actions", exact: true })
.getByRole("button", { name: "More actions", exact: true })
.click();
}
async function choose(page: Page, title: string) {
await page.getByRole("menuitem", { name: title, exact: true }).click();
}
async function command(page: Page, title: string) {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill(title);
await panel.getByRole("button", { name: title, exact: true }).click();
await expect(panel).not.toBeVisible();
}
async function capture(
page: Page,
testInfo: TestInfo,
name: string,
subject: Locator = page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
) {
await expect(subject).toBeInViewport();
await waitForSettledPosition(subject);
const file = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: file, animations: "disabled" });
await testInfo.attach(name, { path: file, contentType: "image/png" });
}
async function expectDetails(page: Page) {
await expect(page.getByText("All systems operational", { exact: true })).toBeInViewport();
}
async function expectHeaderTextTruncated(page: Page) {
const title = page.getByTestId("workspace-header-title");
const subtitle = page.getByTestId("workspace-header-subtitle");
await expect(title).toHaveCSS("text-overflow", "ellipsis");
await expect(subtitle).toHaveCSS("text-overflow", "ellipsis");
// The text itself must overflow its box, rather than being clipped by an ancestor.
await expect
.poll(() => title.evaluate((element) => element.scrollWidth > element.clientWidth))
.toBe(true);
await expect
.poll(() => subtitle.evaluate((element) => element.scrollWidth > element.clientWidth))
.toBe(true);
const titleBox = await title.boundingBox();
const subtitleBox = await subtitle.boundingBox();
const actionsBox = await page
.getByRole("button", { name: "Workspace actions", exact: true })
.boundingBox();
expect(titleBox).not.toBeNull();
expect(subtitleBox).not.toBeNull();
expect(actionsBox).not.toBeNull();
expect(titleBox!.x + titleBox!.width).toBeLessThanOrEqual(actionsBox!.x);
expect(subtitleBox!.x + subtitleBox!.width).toBeLessThanOrEqual(actionsBox!.x);
const hostIcon = page.getByLabel("localhost", { exact: true }).locator("svg");
await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12);
}
async function holdButton(page: Page, button: Locator) {
await button.hover();
await page.mouse.down();
}
async function openCompactOverflowWithHeaderChrome(page: Page, testInfo: TestInfo) {
const hamburger = page.getByRole("button", { name: "Open menu", exact: true });
const action = page.getByRole("button", { name: "Deploy application", exact: true });
const overflow = page
.getByRole("toolbar", { name: "Workspace actions", exact: true })
.getByRole("button", { name: "More actions", exact: true });
await expect(action).toHaveCSS("border-top-width", "0px");
await expect(overflow).toHaveCSS("border-top-width", "0px");
await hamburger.hover();
const highlight = await hamburger.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await action.hover();
await expect(action).toHaveCSS("background-color", highlight);
await holdButton(page, overflow);
await expect(overflow).toHaveCSS("background-color", highlight);
await capture(page, testInfo, "12c-compact-overflow-pressed");
await page.mouse.up();
}
export async function withButtonShowcase(
page: Page,
testInfo: TestInfo,
run: (buttons: {
openWideMenusAndPopovers(): Promise<void>;
runAndUpdateActions(): Promise<void>;
openCompactSheets(): Promise<void>;
hideAndUnloadOpenButtons(): Promise<void>;
}) => Promise<void>,
) {
const workspace = await seedWorkspace({ repoPrefix: "plugin-buttons-" });
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Button showcase",
model: "ten-second-stream",
modeId: "load-test",
});
const showcase = await installShowcase(workspace.workspaceId, agent.id);
try {
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
await expect(page.getByRole("button", { name: "Run review", exact: true })).toBeVisible();
await run({
openWideMenusAndPopovers: () =>
test.step("wide buttons have placement-owned labels and chevrons", async () => {
await expect(
page
.getByRole("button", { name: "Header checks", exact: true })
.getByTestId("plugin-button-chevron"),
).toHaveCount(1);
await expect(
page
.getByRole("button", { name: "Composer checks", exact: true })
.getByTestId("plugin-button-chevron"),
).toHaveCount(0);
await capture(page, testInfo, "01-wide-buttons");
await press(page, "Header checks");
await expect(
page.getByRole("menuitem", { name: "Run checks", exact: true }),
).toBeVisible();
await capture(
page,
testInfo,
"02-header-menu",
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Environment");
await expect(page.getByRole("menuitem", { name: "Staging", exact: true })).toBeVisible();
await capture(
page,
testInfo,
"03-header-submenu",
page.getByRole("menuitem", { name: "Staging", exact: true }),
);
await choose(page, "Staging");
await openHeaderOverflow(page);
await expect(
page.getByRole("menuitem", { name: "Production locked", exact: true }),
).toBeDisabled();
await capture(
page,
testInfo,
"03b-wide-overflow",
page.getByRole("menuitem", { name: "Production locked", exact: true }),
);
await choose(page, "Open deployment logs");
await expectDetails(page);
await press(page, "Close deployment details");
await press(page, "Header status");
await expectDetails(page);
await capture(
page,
testInfo,
"04-header-popover",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
await press(page, "Composer checks");
await capture(
page,
testInfo,
"05-composer-menu",
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Deployment details");
await expectDetails(page);
await capture(
page,
testInfo,
"06-composer-menu-content",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
await press(page, "Composer status");
await expectDetails(page);
await capture(
page,
testInfo,
"07-composer-popover",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
}),
runAndUpdateActions: () =>
test.step("actions fail visibly, retry, and remain busy until completion", async () => {
await press(page, "Deploy application");
await expect(
page.getByText("Deployment failed. Try again.", { exact: true }),
).toBeVisible();
await capture(
page,
testInfo,
"08-action-error",
page.getByText("Deployment failed. Try again.", { exact: true }),
);
await press(page, "Deploy application");
await expect(
page.getByRole("button", { name: "Deploy application", exact: true }),
).toBeDisabled();
await capture(page, testInfo, "09-action-pending");
await command(page, "Finish deployment");
await expect(
page.getByRole("button", { name: "Deploy application", exact: true }),
).toContainText("Deployed");
await press(page, "Run review");
await expect(
page.getByRole("button", { name: "Run review", exact: true }),
).toBeDisabled();
await capture(page, testInfo, "09b-composer-pending");
await command(page, "Finish review");
await expect(page.getByRole("button", { name: "Run review", exact: true })).toContainText(
"Reviewed",
);
await command(page, "Disable example buttons");
await expect(
page.getByRole("button", { name: "Run review", exact: true }),
).toBeDisabled();
await capture(page, testInfo, "10-disabled-buttons");
await command(page, "Enable example buttons");
await command(page, "Use icon-only header");
await capture(page, testInfo, "11-icon-only-header");
await command(page, "Hide review button");
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
0,
);
await command(page, "Show review button");
await expect(page.getByRole("button", { name: "Run review", exact: true })).toContainText(
"Review ready",
);
}),
openCompactSheets: () =>
test.step("compact triggers and overflow open sheets with working plugin context", async () => {
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Composer checks", exact: true }),
).toContainText("Checks");
await workspace.client.renameProject(
workspace.projectId,
"Plugin buttons with a long project name",
);
await expect(page.getByTestId("workspace-header-subtitle")).toHaveText(
"Plugin buttons with a long project name",
);
await showcase.setTitle("Button showcase with a long workspace title");
await expect(page.getByTestId("workspace-header-title")).toHaveText(
"Button showcase with a long workspace title",
);
await expectHeaderTextTruncated(page);
await capture(page, testInfo, "12-compact-buttons");
await openCompactOverflowWithHeaderChrome(page, testInfo);
await expect(
page.getByRole("menuitem", { name: "Production locked", exact: true }),
).toBeDisabled();
await capture(
page,
testInfo,
"13-compact-header-overflow",
page.getByRole("menuitem", { name: "Production locked", exact: true }),
);
await choose(page, "Header checks");
await capture(
page,
testInfo,
"14-compact-header-menu",
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Deployment details");
await expectDetails(page);
await capture(
page,
testInfo,
"15-compact-header-content",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
await showcase.setTitle("Compact checks");
await press(page, "Header checks");
await expect(
page
.getByRole("button", { name: "Header checks", exact: true })
.getByTestId("plugin-button-chevron"),
).toHaveCount(0);
await capture(
page,
testInfo,
"15b-compact-header-direct-menu",
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Run checks");
await showcase.setTitle("Button showcase");
await press(page, "Composer status");
await expectDetails(page);
await capture(
page,
testInfo,
"16-compact-composer-popover",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
await press(page, "Composer checks");
await capture(
page,
testInfo,
"17-compact-composer-menu",
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Run checks");
}),
hideAndUnloadOpenButtons: () =>
test.step("hiding an open button and unloading a plugin remove its surfaces", async () => {
await page.setViewportSize(WIDE);
await press(page, "Composer checks");
await showcase.setTitle("Hide composer menu");
await expect(page.getByRole("menuitem", { name: "Run checks", exact: true })).toHaveCount(
0,
);
await expect(
page.getByRole("button", { name: "Composer checks", exact: true }),
).toHaveCount(0);
await showcase.setTitle("Button showcase");
await page.emulateMedia({ colorScheme: "dark" });
await capture(page, testInfo, "18-dark-buttons");
await press(page, "Header status");
await expectDetails(page);
await capture(
page,
testInfo,
"19-dark-popover",
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await showcase.disable();
await expect(
page.getByRole("button", { name: "Header status", exact: true }),
).toHaveCount(0);
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
0,
);
await expect(page.getByText("All systems operational", { exact: true })).toHaveCount(0);
}),
});
} finally {
await showcase.cleanup();
await workspace.cleanup();
}
}
+9 -2
View File
@@ -47,7 +47,11 @@ export function HostBadge({ badge }: { badge: HostBadgeModel }) {
testID={`host-badge-${badge.serverId}`}
accessibilityLabel={badge.label}
>
<ThemedServer size={HOST_BADGE_ICON_SIZE} uniProps={HOST_ICON_MAPPINGS[badge.color]} />
<ThemedServer
size={HOST_BADGE_ICON_SIZE}
style={styles.icon}
uniProps={HOST_ICON_MAPPINGS[badge.color]}
/>
{badge.showLabel ? (
<Text style={[styles.label, labelColorStyle(badge.color)]} numberOfLines={1}>
{badge.label}
@@ -92,13 +96,16 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: 3,
minWidth: 0,
minWidth: HOST_BADGE_ICON_SIZE,
// Outweighs a sibling's `flexShrink: 1` by enough that the badge is effectively fully
// squeezed before that sibling gives up its first pixel. An equal factor would split the
// loss in proportion to length, which hands the most space to the longest hostname —
// exactly backwards. The icon has a fixed width, so the badge never disappears outright.
flexShrink: 100,
},
icon: {
flexShrink: 0,
},
label: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
@@ -0,0 +1 @@
export { pluginButtonStore } from "./store";
@@ -0,0 +1,169 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import type { PluginButton } from "@getpaseo/plugin/client";
import type { InstalledPlugin } from "../types";
import { PluginButtonStore, buttonMatches } from "./model";
function installation(): InstalledPlugin {
return {
id: "review",
serverId: "host-a",
clientBundle: "bundle",
queryClient: new QueryClient(),
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
attachmentSources: [],
themes: [],
timelineTransformers: [],
timelineRenderers: [],
};
}
function store() {
return new PluginButtonStore({
validateIconName(name) {
if (name !== "Scan") throw new Error("Unknown icon");
},
});
}
function button(onPress = () => {}): PluginButton {
return { title: "Review", icon: "Scan", behavior: { kind: "action", onPress } };
}
describe("plugin buttons", () => {
it("updates visibility in place, closes hidden content, and scopes each placement", () => {
const buttons = store();
const plugin = installation();
const header = buttons.addHeaderButton(plugin, {
id: "review",
workspaceId: "workspace",
button: button(),
});
buttons.addComposerPill(plugin, {
id: "review",
workspaceId: "workspace",
agentId: "agent",
button: button(),
});
const first = buttons.getSnapshot()[0];
expect(buttonMatches(first, "host-a", "workspace", null)).toBe(true);
expect(buttonMatches(first, "host-b", "workspace", null)).toBe(false);
expect(buttonMatches(first, "host-a", "another-workspace", null)).toBe(false);
expect(buttonMatches(first, "host-a", "workspace", "agent")).toBe(false);
expect(buttonMatches(buttons.getSnapshot()[1], "host-a", "workspace", "agent")).toBe(true);
expect(buttonMatches(buttons.getSnapshot()[1], "host-a", "workspace", "other-agent")).toBe(
false,
);
buttons.setOpen(first.key, true);
header.update({ visible: false, label: "3 reviews" });
expect(buttonMatches(buttons.getSnapshot()[0], "host-a", "workspace", null)).toBe(false);
expect(buttons.getSnapshot()[0].open).toBe(false);
header.update({ visible: true });
expect(buttons.getSnapshot().map((entry) => entry.key)).toEqual([first.key, first.key + 1]);
expect(buttons.getSnapshot()[0].button.label).toBe("3 reviews");
header.remove();
header.remove();
header.update({ visible: true });
expect(buttons.getSnapshot().map((entry) => entry.placement)).toEqual(["composer"]);
});
it("rejects duplicates within a target and validates updates atomically", () => {
const buttons = store();
const plugin = installation();
const contribution = { id: "review", workspaceId: "workspace", button: button() };
const header = buttons.addHeaderButton(plugin, contribution);
expect(() => buttons.addHeaderButton(plugin, contribution)).toThrow(
"Duplicate plugin button: review",
);
expect(() => buttons.addHeaderButton(installation(), contribution)).not.toThrow();
expect(() =>
buttons.addHeaderButton(plugin, { ...contribution, workspaceId: "other" }),
).not.toThrow();
expect(() => header.update({ title: "Changed", icon: "Invalid" })).toThrow("Unknown icon");
expect(buttons.getSnapshot()[0].button.title).toBe("Review");
});
it("prevents repeated presses and cannot resurrect a removed pending button", async () => {
const buttons = store();
let finish = () => {};
const pending = new Promise<void>((resolve) => {
finish = resolve;
});
let calls = 0;
const registration = buttons.addHeaderButton(installation(), {
id: "review",
workspaceId: "workspace",
button: button(() => {
calls++;
return pending;
}),
});
const key = buttons.getSnapshot()[0].key;
const action = buttons.run(key);
await buttons.run(key);
expect(calls).toBe(1);
expect(buttons.getSnapshot()[0].pending).toBe(true);
registration.remove();
finish();
await action;
expect(buttons.getSnapshot()).toEqual([]);
});
it("allows retry after failure and respects disabled actions and menu ancestors", async () => {
const buttons = store();
let calls = 0;
const registration = buttons.addHeaderButton(installation(), {
id: "review",
workspaceId: "workspace",
button: button(() => {
if (++calls === 1) throw new Error("Review unavailable");
}),
});
const key = buttons.getSnapshot()[0].key;
await expect(buttons.run(key)).rejects.toThrow("Review unavailable");
expect(buttons.getSnapshot()[0].pending).toBe(false);
await buttons.run(key);
expect(calls).toBe(2);
registration.update({ disabled: true });
await buttons.run(key);
expect(calls).toBe(2);
registration.update({
disabled: false,
behavior: {
kind: "menu",
items: [
{
kind: "item",
id: "nested",
title: "Nested",
disabled: true,
behavior: {
kind: "menu",
items: [
{
kind: "item",
id: "run",
title: "Run",
behavior: {
kind: "action",
onPress() {
calls++;
},
},
},
],
},
},
],
},
});
await buttons.run(key, ["nested", "run"]);
expect(calls).toBe(2);
});
});
+178
View File
@@ -0,0 +1,178 @@
import type {
PluginButtonBehavior,
PluginButtonContext,
PluginButtonRegistration,
PluginHeaderButtonContribution,
PluginComposerPillContribution,
} from "@getpaseo/plugin/client";
import type { InstalledPlugin } from "../types";
import {
requireButtonId,
validateButton,
type ButtonValidation,
type ResolvedPluginButton,
} from "./validation";
export type ButtonPlacement = "header" | "composer";
export interface RegisteredPluginButton {
key: number;
id: string;
installation: InstalledPlugin;
placement: ButtonPlacement;
context: PluginButtonContext;
button: ResolvedPluginButton;
pending: boolean;
open: boolean;
}
function resolveAction(
behavior: PluginButtonBehavior,
path: readonly string[],
): (() => void | Promise<void>) | null {
if (path.length === 0) return behavior.kind === "action" ? behavior.onPress : null;
if (behavior.kind !== "menu") return null;
const item = behavior.items.find((entry) => entry.id === path[0]);
if (!item || item.kind !== "item" || item.visible === false || item.disabled) return null;
return resolveAction(item.behavior, path.slice(1));
}
export class PluginButtonStore {
private entries: readonly RegisteredPluginButton[] = [];
private nextKey = 0;
private listeners = new Set<() => void>();
constructor(private validation: ButtonValidation) {}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
getSnapshot = (): readonly RegisteredPluginButton[] => this.entries;
addHeaderButton(
installation: InstalledPlugin,
input: PluginHeaderButtonContribution,
): PluginButtonRegistration {
return this.add(installation, input, "header", {
context: "workspace",
workspaceId: input.workspaceId.trim(),
});
}
addComposerPill(
installation: InstalledPlugin,
input: PluginComposerPillContribution,
): PluginButtonRegistration {
if (!input.agentId.trim()) throw new Error("Plugin composer pill needs an agent");
return this.add(installation, input, "composer", {
context: "agent",
workspaceId: input.workspaceId.trim(),
agentId: input.agentId.trim(),
});
}
private add(
installation: InstalledPlugin,
input: PluginHeaderButtonContribution,
placement: ButtonPlacement,
context: PluginButtonContext,
): PluginButtonRegistration {
const id = requireButtonId(input.id);
if (!context.workspaceId.trim()) throw new Error("Plugin button needs a workspace");
const duplicate = this.entries.some(
(entry) =>
entry.installation === installation &&
entry.placement === placement &&
entry.id === id &&
entry.context.workspaceId === context.workspaceId &&
(entry.context.context === "workspace" ||
(context.context === "agent" && entry.context.agentId === context.agentId)),
);
if (duplicate) throw new Error(`Duplicate plugin button: ${id}`);
const key = this.nextKey++;
const initialButton = validateButton(input.button, this.validation);
this.publish([
...this.entries,
{
key,
id,
installation,
placement,
context,
button: initialButton,
pending: false,
open: false,
},
]);
return {
update: (patch) => {
const entry = this.entries.find((candidate) => candidate.key === key);
if (!entry) return;
const button = validateButton({ ...entry.button, ...patch }, this.validation);
const close = !button.visible || button.disabled || patch.behavior !== undefined;
this.replace(key, { button, open: close ? false : entry.open });
},
remove: () => {
const next = this.entries.filter((entry) => entry.key !== key);
if (next.length !== this.entries.length) this.publish(next);
},
};
}
setOpen(key: number, open: boolean): void {
const entry = this.entries.find((candidate) => candidate.key === key);
if (!entry) return;
if (open && (!entry.button.visible || entry.button.disabled || entry.pending)) return;
this.replace(key, { open });
}
async run(key: number, path: readonly string[] = []): Promise<void> {
const entry = this.entries.find((candidate) => candidate.key === key);
if (!entry || !entry.button.visible || entry.button.disabled || entry.pending) return;
const action = resolveAction(entry.button.behavior, path);
if (!action) return;
this.replace(key, { pending: true });
try {
await action();
} finally {
this.replace(key, { pending: false });
}
}
private replace(
key: number,
patch: Partial<Pick<RegisteredPluginButton, "button" | "open" | "pending">>,
): void {
if (!this.entries.some((entry) => entry.key === key)) return;
this.publish(this.entries.map((entry) => (entry.key === key ? { ...entry, ...patch } : entry)));
}
private publish(entries: readonly RegisteredPluginButton[]): void {
this.entries = entries;
for (const listener of this.listeners) listener();
}
}
export function buttonMatches(
entry: RegisteredPluginButton,
serverId: string,
workspaceId: string,
agentId: string | null,
): boolean {
if (
entry.installation.serverId !== serverId ||
entry.context.workspaceId !== workspaceId ||
!entry.button.visible
)
return false;
if (agentId === null) return entry.placement === "header";
return (
entry.placement === "composer" &&
entry.context.context === "agent" &&
entry.context.agentId === agentId
);
}
@@ -0,0 +1,4 @@
import { resolvePluginIcon } from "../icons";
import { PluginButtonStore } from "./model";
export const pluginButtonStore = new PluginButtonStore({ validateIconName: resolvePluginIcon });
@@ -0,0 +1,95 @@
import type { PluginButton, PluginButtonBehavior, PluginButtonIcon } from "@getpaseo/plugin/client";
export interface ButtonValidation {
validateIconName(name: string): void;
}
export function requireButtonId(value: string): string {
if (typeof value !== "string" || !/^[a-z][a-z0-9-]*$/.test(value))
throw new Error(`Invalid plugin button id: ${value}`);
return value;
}
function optionalBoolean(value: boolean | undefined, fallback: boolean): boolean {
if (value === undefined) return fallback;
if (typeof value !== "boolean")
throw new Error("Plugin button visibility and disabled state must be booleans");
return value;
}
function requireText(value: string, field: string): string {
if (typeof value !== "string" || !value.trim()) throw new Error(`Plugin button needs ${field}`);
return value.trim();
}
function isComponent(value: unknown): boolean {
return (
typeof value === "function" ||
(typeof value === "object" && value !== null && "$$typeof" in value)
);
}
function validateIcon(icon: PluginButtonIcon, validation: ButtonValidation): void {
if (typeof icon === "string") validation.validateIconName(icon);
else if (!isComponent(icon))
throw new Error("Plugin button icon must be a Lucide name or component");
}
function validateBehavior(
behavior: PluginButtonBehavior,
validation: ButtonValidation,
): PluginButtonBehavior {
switch (behavior.kind) {
case "action":
if (typeof behavior.onPress !== "function")
throw new Error("Plugin button action needs onPress");
return { ...behavior };
case "popover":
if (!isComponent(behavior.Content)) throw new Error("Plugin button popover needs Content");
return { ...behavior };
case "menu": {
const ids = new Set<string>();
return {
kind: "menu",
items: behavior.items.map((item) => {
requireButtonId(item.id);
if (ids.has(item.id)) throw new Error(`Duplicate plugin button menu item: ${item.id}`);
ids.add(item.id);
if (item.kind === "separator") return { ...item };
if (item.kind !== "item") throw new Error("Invalid plugin button menu entry");
if (item.icon !== undefined) validateIcon(item.icon, validation);
return {
...item,
title: requireText(item.title, "menu item title"),
visible: optionalBoolean(item.visible, true),
disabled: optionalBoolean(item.disabled, false),
behavior: validateBehavior(item.behavior, validation),
};
}),
};
}
default:
throw new Error("Invalid plugin button behavior");
}
}
export type ResolvedPluginButton = PluginButton & {
visible: boolean;
disabled: boolean;
label: string | undefined;
};
export function validateButton(
button: PluginButton,
validation: ButtonValidation,
): ResolvedPluginButton {
validateIcon(button.icon, validation);
return {
...button,
title: requireText(button.title, "title"),
label: button.label === undefined ? undefined : requireText(button.label, "label"),
visible: optionalBoolean(button.visible, true),
disabled: optionalBoolean(button.disabled, false),
behavior: validateBehavior(button.behavior, validation),
};
}
+667
View File
@@ -0,0 +1,667 @@
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
import type {
PluginButtonBehavior,
PluginButtonIcon,
PluginButtonMenuEntry,
PluginHostProps,
} from "@getpaseo/plugin/client";
import type { PluginTheme } from "@getpaseo/plugin";
import { useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react";
import { Platform, Pressable, Text, View, useWindowDimensions } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { AlertCircle, ChevronDown, MoreHorizontal } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import {
MenuRoot,
MenuTrigger,
MenuSurface,
MenuItem,
MenuSeparator,
MenuSubTrigger,
useMenuContext,
type MenuPageDefinition,
} from "@/components/ui/menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import {
iconButtonChromeStyle,
type IconButtonChromeState,
} from "@/components/ui/icon-button-chrome";
import { composerPillStyles } from "@/composer/pill-styles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { ToastApiProvider, useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import { createPluginClientStateSource } from "../client-state/source";
import { Icon } from "../icons";
import { PluginRuntimeBoundary } from "../runtime-boundary";
import { createPluginSurfaceRuntime, type PluginSurfaceRuntime } from "../surface-runtime";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
import { toPluginTheme } from "../theme";
import { buttonMatches, type RegisteredPluginButton } from "./model";
import { pluginButtonStore } from "./store";
interface ButtonView {
entry: RegisteredPluginButton;
props: PluginHostProps & RegisteredPluginButton["context"];
runtime: PluginSurfaceRuntime;
state: ReturnType<typeof createPluginClientStateSource>;
toast: ReturnType<typeof useToast>;
}
const ROOT_PATH: readonly string[] = [];
const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const errorIconMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
const ThemedMoreIcon = withUnistyles(MoreHorizontal);
const ThemedErrorIcon = withUnistyles(AlertCircle);
function headerButtonStyle(compact: boolean, state: IconButtonChromeState, disabled = false) {
if (compact) return iconButtonChromeStyle({ size: "large", state, disabled });
return [
styles.headerButton,
styles.button,
(state.hovered || state.pressed || state.open) && styles.active,
disabled && styles.disabled,
];
}
function resolvePlatform(): PluginHostProps["layout"]["platform"] {
if (Platform.OS === "ios") return "ios";
if (Platform.OS === "android") return "android";
return "web";
}
// These providers live inside the surface content as well as around its trigger. Native sheets
// teleport their children, so providers around MenuRoot alone cannot reach the plugin body.
function ButtonEnvironment({ view, children }: { view: ButtonView; children: ReactNode }) {
return (
<ToastApiProvider api={view.toast}>
<PluginRuntimeBoundary plugin={view.entry.installation} runtime={view.runtime}>
<PluginClientStateProvider source={view.state}>{children}</PluginClientStateProvider>
</PluginRuntimeBoundary>
</ToastApiProvider>
);
}
function ButtonIcon({ view, icon }: { view: ButtonView; icon: PluginButtonIcon }) {
const color = view.props.theme.colors.foregroundMuted;
const size = view.entry.placement === "composer" ? 14 : 16;
return (
<View
style={styles.icon}
pointerEvents="none"
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
{typeof icon === "string" ? (
<Icon name={icon} size={size} color={color} />
) : (
renderCustomIcon(icon, view, size, color)
)}
</View>
);
}
function renderCustomIcon(
CustomIcon: Exclude<PluginButtonIcon, string>,
view: ButtonView,
size: number,
color: string,
) {
return <CustomIcon {...view.props} size={size} color={color} />;
}
function pressButton(view: ButtonView, path: readonly string[]) {
void pluginButtonStore.run(view.entry.key, path).catch((error: unknown) => {
view.toast.error(error instanceof Error ? error.message : String(error));
});
}
function visibleMenuItems(
items: readonly PluginButtonMenuEntry[],
): readonly PluginButtonMenuEntry[] {
const visible: PluginButtonMenuEntry[] = [];
for (const item of items) {
if (item.kind === "item" && item.visible === false) continue;
if (item.kind === "separator" && (visible.length === 0 || visible.at(-1)?.kind === "separator"))
continue;
visible.push(item);
}
if (visible.at(-1)?.kind === "separator") visible.pop();
return visible;
}
function ButtonMenuItem({
view,
title,
icon,
behavior,
disabled,
path,
pageId,
}: {
view: ButtonView;
title: string;
icon?: PluginButtonIcon;
behavior: PluginButtonBehavior;
disabled: boolean;
path: readonly string[];
pageId: string;
}) {
const select = useCallback(() => pressButton(view, path), [view, path]);
const leading = useMemo(
() => (icon ? <ButtonIcon view={view} icon={icon} /> : null),
[icon, view],
);
if (behavior.kind !== "action") {
return (
<MenuSubTrigger id={pageId} leading={leading} disabled={disabled}>
{title}
</MenuSubTrigger>
);
}
return (
<MenuItem onSelect={select} leading={leading} disabled={disabled}>
{title}
</MenuItem>
);
}
function ButtonBody({
view,
behavior,
path,
}: {
view: ButtonView;
behavior: PluginButtonBehavior;
path: readonly string[];
}) {
const menu = useMenuContext("PluginButton");
const close = useCallback(() => menu.setOpen(false), [menu]);
if (behavior.kind === "popover") {
const Content = behavior.Content;
return (
<View style={styles.content}>
<Content {...view.props} close={close} />
</View>
);
}
if (behavior.kind !== "menu") return null;
return visibleMenuItems(behavior.items).map((item) => {
if (item.kind === "separator") return <MenuSeparator key={item.id} />;
const itemPath = [...path, item.id];
return (
<ButtonMenuItem
key={item.id}
view={view}
title={item.title}
icon={item.icon}
behavior={item.behavior}
disabled={view.entry.pending || view.entry.button.disabled || item.disabled === true}
path={itemPath}
pageId={buttonPageId(view, itemPath)}
/>
);
});
}
function ButtonSurfaceBody({
view,
behavior,
path,
}: {
view: ButtonView;
behavior: PluginButtonBehavior;
path: readonly string[];
}) {
return (
<ButtonEnvironment view={view}>
<SurfaceErrorBoundary installation={view.entry.installation} Surface={behavior}>
<ButtonBody view={view} behavior={behavior} path={path} />
</SurfaceErrorBoundary>
</ButtonEnvironment>
);
}
function buttonPageId(view: ButtonView, path: readonly string[]): string {
return [view.entry.key, ...path].join("/");
}
function buttonPages(
view: ButtonView,
behavior: PluginButtonBehavior,
path: readonly string[] = [],
): MenuPageDefinition[] {
if (behavior.kind !== "menu") return [];
return behavior.items.flatMap((item) => {
if (item.kind === "separator" || item.visible === false || item.behavior.kind === "action")
return [];
const itemPath = [...path, item.id];
return [
{
id: buttonPageId(view, itemPath),
title: item.title,
hoverIntent: item.behavior.kind === "menu",
content: <ButtonSurfaceBody view={view} behavior={item.behavior} path={itemPath} />,
},
...buttonPages(view, item.behavior, itemPath),
];
});
}
function ButtonControl({ view }: { view: ButtonView }) {
const { entry, props } = view;
const { button } = entry;
const composer = entry.placement === "composer";
const disabled = button.disabled || entry.pending;
const expanded = button.behavior.kind !== "action";
const chevron = !composer && !props.layout.compact && expanded;
let label = button.label;
if (composer) label = button.label ?? button.title;
else if (props.layout.compact) label = undefined;
const press = useCallback(() => pressButton(view, []), [view]);
const setOpen = useCallback(
(open: boolean) => pluginButtonStore.setOpen(entry.key, open),
[entry.key],
);
const buttonStyle = useCallback(
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) =>
composer
? [
composerPillStyles.body,
styles.button,
(hovered || pressed || entry.open) && styles.active,
disabled && styles.disabled,
]
: headerButtonStyle(props.layout.compact, { hovered, pressed, open: entry.open }, disabled),
[composer, disabled, entry.open, props.layout.compact],
);
const contents = (
<>
{entry.pending ? (
<View style={styles.icon}>
<LoadingSpinner size={14} color={props.theme.colors.foregroundMuted} />
</View>
) : (
<ButtonIcon view={view} icon={button.icon} />
)}
{label ? (
<Text numberOfLines={1} style={composer ? styles.composerLabel : styles.label}>
{label}
</Text>
) : null}
{chevron ? (
<View testID="plugin-button-chevron">
<ChevronDown size={12} color={props.theme.colors.foregroundMuted} />
</View>
) : null}
</>
);
const accessibilityState = useMemo(
() => ({ busy: entry.pending, disabled }),
[entry.pending, disabled],
);
const trigger = expanded ? (
<MenuTrigger
accessibilityRole="button"
accessibilityLabel={button.title}
accessibilityState={accessibilityState}
disabled={disabled}
style={buttonStyle}
>
{contents}
</MenuTrigger>
) : (
<Pressable
accessibilityRole="button"
accessibilityLabel={button.title}
accessibilityState={accessibilityState}
disabled={disabled}
onPress={press}
style={buttonStyle}
>
{contents}
</Pressable>
);
const pages = useMemo(() => buttonPages(view, button.behavior), [view, button.behavior]);
return (
<MenuRoot compactMode="sheet" open={entry.open} onOpenChange={setOpen}>
<Tooltip enabledOnMobile={false}>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent>
<Text style={styles.tooltipLabel}>{button.title}</Text>
</TooltipContent>
</Tooltip>
{expanded ? (
<MenuSurface
sheetTitle={button.title}
side={composer ? "top" : "bottom"}
align={composer ? "start" : "end"}
offset={composer ? 12 : 4}
minWidth={280}
maxWidth={420}
maxHeight={440}
scrollable
pages={pages}
>
<ButtonSurfaceBody view={view} behavior={button.behavior} path={ROOT_PATH} />
</MenuSurface>
) : null}
</MenuRoot>
);
}
function BrokenButton({
title,
error,
compact,
}: {
title: string;
error: string;
compact: boolean;
}) {
return (
<Tooltip>
<TooltipTrigger
accessibilityRole="button"
accessibilityLabel={title}
disabled
style={headerButtonStyle(compact, {}, true)}
>
<ThemedErrorIcon size={16} uniProps={errorIconMapping} />
</TooltipTrigger>
<TooltipContent>
<Text style={styles.tooltipLabel}>{error}</Text>
</TooltipContent>
</Tooltip>
);
}
function createButtonView({
entry,
client,
toast,
compact,
hostLabel,
theme,
}: {
entry: RegisteredPluginButton;
client: ReturnType<typeof useHostRuntimeClient>;
toast: ReturnType<typeof useToast>;
compact: boolean;
hostLabel: string;
theme: PluginTheme;
}): ButtonView | null {
const runtime = createPluginSurfaceRuntime(client, entry.installation.id);
if (!runtime) return null;
return {
entry,
runtime,
toast,
state: createPluginClientStateSource(entry.installation.serverId),
props: {
...entry.context,
theme,
host: { id: entry.installation.serverId, label: hostLabel },
layout: { compact, platform: resolvePlatform() },
},
};
}
function PluginButtonHost({
entry,
compact,
hostLabel,
theme,
}: {
entry: RegisteredPluginButton;
compact: boolean;
hostLabel: string;
theme: PluginTheme;
}) {
const client = useHostRuntimeClient(entry.installation.serverId);
const toast = useToast();
const view = useMemo(
() => createButtonView({ entry, client, toast, compact, hostLabel, theme }),
[entry, client, toast, compact, hostLabel, theme],
);
const renderError = useCallback(
(error: string) => <BrokenButton title={entry.button.title} error={error} compact={compact} />,
[entry.button.title, compact],
);
if (!view) return null;
return (
<SurfaceErrorBoundary
installation={entry.installation}
Surface={entry.button.icon}
renderError={renderError}
>
<ButtonEnvironment view={view}>
<ButtonControl view={view} />
</ButtonEnvironment>
</SurfaceErrorBoundary>
);
}
const ThemedPluginButton = withUnistyles(PluginButtonHost);
// Overflow rows keep their own plugin runtime; a menu can contain several installations.
function OverflowButton({ view }: { view: ButtonView }) {
const { button } = view.entry;
return (
<ButtonMenuItem
view={view}
title={button.title}
icon={button.icon}
behavior={button.behavior}
disabled={button.disabled || view.entry.pending}
path={ROOT_PATH}
pageId={buttonPageId(view, [])}
/>
);
}
function OverflowPages({
entries,
compact,
hostLabel,
theme,
}: {
entries: readonly RegisteredPluginButton[];
compact: boolean;
hostLabel: string;
theme: PluginTheme;
}) {
// The header belongs to one host, but each button keeps its installation's query cache and RPCs.
const client = useHostRuntimeClient(entries[0].installation.serverId);
const toast = useToast();
const menuContent = useMemo(() => {
const pages: MenuPageDefinition[] = [];
const rows: ReactNode[] = [];
for (const entry of entries) {
const view = createButtonView({ entry, client, toast, compact, hostLabel, theme });
if (!view) continue;
rows.push(
<ButtonEnvironment key={entry.key} view={view}>
<SurfaceErrorBoundary installation={entry.installation} Surface={entry.button.icon}>
<OverflowButton view={view} />
</SurfaceErrorBoundary>
</ButtonEnvironment>,
);
if (entry.button.behavior.kind === "action") continue;
pages.push(
{
id: buttonPageId(view, []),
title: entry.button.title,
hoverIntent: false,
content: (
<ButtonSurfaceBody view={view} behavior={entry.button.behavior} path={ROOT_PATH} />
),
},
...buttonPages(view, entry.button.behavior),
);
}
return { pages, rows };
}, [entries, client, toast, theme, hostLabel, compact]);
const { t } = useTranslation();
return (
<MenuSurface
sheetTitle={t("workspace.git.actions.moreActions")}
align="end"
minWidth={280}
maxWidth={420}
maxHeight={440}
scrollable
pages={menuContent.pages}
>
{menuContent.rows}
</MenuSurface>
);
}
const ThemedOverflowPages = withUnistyles(OverflowPages);
function useButtons(serverId: string, workspaceId: string, agentId: string | null) {
const entries = useSyncExternalStore(
pluginButtonStore.subscribe,
pluginButtonStore.getSnapshot,
pluginButtonStore.getSnapshot,
);
return useMemo(
() => entries.filter((entry) => buttonMatches(entry, serverId, workspaceId, agentId)),
[entries, serverId, workspaceId, agentId],
);
}
export function useHasPluginComposerPills(
serverId: string,
workspaceId: string,
agentId: string,
): boolean {
return useButtons(serverId, workspaceId, agentId).length > 0;
}
export function PluginComposerPills({
serverId,
workspaceId,
agentId,
compact,
}: {
serverId: string;
workspaceId: string;
agentId: string;
compact: boolean;
}) {
const entries = useButtons(serverId, workspaceId, agentId);
const hosts = useHosts();
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
return entries.map((entry) => (
<ThemedPluginButton
key={entry.key}
entry={entry}
compact={compact}
hostLabel={hostLabel}
uniProps={pluginThemeMapping}
/>
));
}
export function PluginHeaderButtons({
serverId,
workspaceId,
}: {
serverId: string;
workspaceId: string;
}) {
const entries = useButtons(serverId, workspaceId, null);
const compact = useIsCompactFormFactor();
const { width } = useWindowDimensions();
const hosts = useHosts();
const { t } = useTranslation();
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const limit = compact || width < 1100 ? 1 : 3;
const visible = entries.slice(0, limit);
const overflow = entries.slice(limit);
const overflowStyle = useCallback(
(state: IconButtonChromeState) => headerButtonStyle(compact, state),
[compact],
);
if (entries.length === 0) return null;
return (
<View
accessibilityRole="toolbar"
accessibilityLabel={t("workspace.header.actions.workspaceActions")}
style={styles.headerButtons}
>
{visible.map((entry) => (
<ThemedPluginButton
key={entry.key}
entry={entry}
compact={compact}
hostLabel={hostLabel}
uniProps={pluginThemeMapping}
/>
))}
{overflow.length > 0 ? (
<MenuRoot compactMode="sheet">
<MenuTrigger
accessibilityRole="button"
accessibilityLabel={t("workspace.git.actions.moreActions")}
style={overflowStyle}
>
<ThemedMoreIcon size={16} uniProps={mutedIconMapping} />
</MenuTrigger>
<ThemedOverflowPages
entries={overflow}
compact={compact}
hostLabel={hostLabel}
uniProps={pluginThemeMapping}
/>
</MenuRoot>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
headerButtons: {
flexDirection: "row",
alignItems: "center",
gap: { xs: theme.spacing[1], md: theme.spacing[2] },
},
headerButton: {
height: { xs: 32, md: HEADER_CONTROL_HEIGHT },
minWidth: { xs: 32, md: HEADER_CONTROL_HEIGHT },
paddingHorizontal: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
},
button: { flexShrink: 1, minWidth: 0, maxWidth: 160 },
active: { backgroundColor: theme.colors.surface2 },
disabled: { opacity: theme.opacity[50] },
tooltipLabel: { fontSize: theme.fontSize.sm, color: theme.colors.foreground },
composerLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
label: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, flexShrink: 1 },
icon: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
overflow: "hidden",
},
content: { padding: theme.spacing[3], gap: theme.spacing[2] },
}));
@@ -1,90 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { PluginComposerPillProps } from "@getpaseo/plugin/client";
import { afterEach, describe, expect, it } from "vitest";
import type { InstalledPlugin } from "./types";
import { createPluginClientRuntime } from "./client-runtime";
import { pluginComposerPillStore } from "./composer-pills/store";
function Pill(_props: PluginComposerPillProps) {
return null;
}
function installation(): InstalledPlugin {
return {
id: "review",
serverId: "host-a",
clientBundle: "bundle",
queryClient: new QueryClient(),
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
clientSlashCommands: [],
attachmentSources: [],
themes: [],
timelineTransformers: [],
timelineRenderers: [],
};
}
const daemonClient = {} as DaemonClient;
const installations: InstalledPlugin[] = [];
afterEach(() => {
for (const plugin of installations.splice(0)) {
pluginComposerPillStore.removeInstallation(plugin);
}
});
describe("plugin client-side lifecycle", () => {
it("lets the client runtime add and remove a targeted composer pill", () => {
const plugin = installation();
installations.push(plugin);
const client = createPluginClientRuntime(plugin, daemonClient);
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
const remove = client.addComposerPill({
id: "review-ready",
title: "Open review",
workspaceId: "workspace-a",
agentId: "agent-a",
Component: Pill,
onPress() {},
});
expect(
pluginComposerPillStore.getSnapshot().map(({ contribution }) => ({
id: contribution.id,
workspaceId: contribution.workspaceId,
agentId: contribution.agentId,
})),
).toEqual([{ id: "review-ready", workspaceId: "workspace-a", agentId: "agent-a" }]);
remove();
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
});
it("rejects duplicate pills only within the same plugin target", () => {
const plugin = installation();
installations.push(plugin);
const contribution = {
id: "review-ready",
title: "Open review",
workspaceId: "workspace-a",
agentId: "agent-a",
Component: Pill,
onPress() {},
};
pluginComposerPillStore.add(plugin, contribution);
expect(() => pluginComposerPillStore.add(plugin, contribution)).toThrow(
"Duplicate composer pill: review-ready",
);
expect(() =>
pluginComposerPillStore.add(plugin, { ...contribution, agentId: "agent-b" }),
).not.toThrow();
});
});
+5 -2
View File
@@ -8,7 +8,7 @@ import {
import { createPluginClientStateSource } from "./client-state/source";
import type { PluginClientRuntime } from "./evaluate";
import { createPluginNavigation } from "./navigation";
import { pluginComposerPillStore } from "./composer-pills/store";
import { pluginButtonStore } from "./buttons";
import { createPluginSurfaceRuntime } from "./surface-runtime";
import type { InstalledPlugin } from "./types";
@@ -27,7 +27,10 @@ export function createPluginClientRuntime(
return {
...capabilities,
addComposerPill(contribution) {
return pluginComposerPillStore.add(installation, contribution);
return pluginButtonStore.addComposerPill(installation, contribution);
},
addHeaderButton(contribution) {
return pluginButtonStore.addHeaderButton(installation, contribution);
},
openPanel(panelId, options) {
openClientPanel({ installation, runtime, state, panelId, options });
@@ -1,181 +0,0 @@
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
import type { PluginComposerPillProps } from "@getpaseo/plugin/client";
import type { PluginTheme } from "@getpaseo/plugin";
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
import { Platform, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { composerPillStyles } from "@/composer/pill-styles";
import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import { createPluginClientStateSource } from "../client-state/source";
import { PluginRuntimeBoundary } from "../runtime-boundary";
import { createPluginSurfaceRuntime } from "../surface-runtime";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
import { toPluginTheme } from "../theme";
import type { InstalledPlugin, PluginComposerPillContribution } from "../types";
import { pluginComposerPillStore } from "./store";
const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
function resolvePlatform(): PluginComposerPillProps["layout"]["platform"] {
if (Platform.OS === "ios") return "ios";
if (Platform.OS === "android") return "android";
return "web";
}
function PluginComposerPill({
plugin,
contribution,
serverId,
workspaceId,
agentId,
compact,
hostLabel,
theme,
}: {
plugin: InstalledPlugin;
contribution: PluginComposerPillContribution;
serverId: string;
workspaceId: string;
agentId: string;
compact: boolean;
hostLabel: string;
theme: PluginTheme;
}) {
const client = useHostRuntimeClient(serverId);
const toast = useToast();
const [pending, setPending] = useState(false);
const runtime = useMemo(() => createPluginSurfaceRuntime(client, plugin.id), [client, plugin.id]);
const state = useMemo(() => createPluginClientStateSource(serverId), [serverId]);
const props = useMemo<PluginComposerPillProps>(
() => ({
theme,
host: { id: serverId, label: hostLabel },
layout: { compact, platform: resolvePlatform() },
workspaceId,
agentId,
}),
[agentId, compact, hostLabel, serverId, theme, workspaceId],
);
const press = useCallback(async () => {
if (!runtime || pending) return;
setPending(true);
try {
await contribution.onPress();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setPending(false);
}
}, [contribution, pending, runtime, toast]);
const pillStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
composerPillStyles.body,
styles.pill,
(hovered || pressed) && composerPillStyles.bodyActive,
pending && styles.disabled,
],
[pending],
);
const accessibilityState = useMemo(() => ({ busy: pending, disabled: pending }), [pending]);
if (!runtime) return null;
const Component = contribution.Component;
return (
<SurfaceErrorBoundary installation={plugin} Surface={Component}>
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
<PluginClientStateProvider source={state}>
<Pressable
accessibilityRole="button"
accessibilityLabel={contribution.title}
accessibilityState={accessibilityState}
disabled={pending}
onPress={press}
style={pillStyle}
>
<Component {...props} />
{pending ? <LoadingSpinner size="small" color={theme.colors.foregroundMuted} /> : null}
</Pressable>
</PluginClientStateProvider>
</PluginRuntimeBoundary>
</SurfaceErrorBoundary>
);
}
const ThemedPluginComposerPill = withUnistyles(PluginComposerPill);
export function PluginComposerPills({
serverId,
workspaceId,
agentId,
compact,
}: {
serverId: string;
workspaceId: string;
agentId: string;
compact: boolean;
}) {
const registrations = useSyncExternalStore(
pluginComposerPillStore.subscribe,
pluginComposerPillStore.getSnapshot,
pluginComposerPillStore.getSnapshot,
);
const hosts = useHosts();
const visible = useMemo(
() =>
registrations.filter(
({ installation, contribution }) =>
installation.serverId === serverId &&
contribution.workspaceId === workspaceId &&
contribution.agentId === agentId,
),
[agentId, registrations, serverId, workspaceId],
);
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
return (
<>
{visible.map(({ installation, contribution }) => (
<ThemedPluginComposerPill
key={`${installation.id}/${contribution.id}`}
plugin={installation}
contribution={contribution}
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
compact={compact}
hostLabel={hostLabel}
uniProps={pluginThemeMapping}
/>
))}
</>
);
}
export function useHasPluginComposerPills(
serverId: string,
workspaceId: string,
agentId: string,
): boolean {
const registrations = useSyncExternalStore(
pluginComposerPillStore.subscribe,
pluginComposerPillStore.getSnapshot,
pluginComposerPillStore.getSnapshot,
);
return registrations.some(
({ installation, contribution }) =>
installation.serverId === serverId &&
contribution.workspaceId === workspaceId &&
contribution.agentId === agentId,
);
}
const styles = StyleSheet.create(() => ({
pill: {
flexShrink: 1,
minWidth: 0,
},
disabled: {
opacity: 0.5,
},
}));
@@ -1,78 +0,0 @@
import type { PluginComposerPillContribution } from "@getpaseo/plugin/client";
import type { InstalledPlugin } from "../types";
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
export interface RegisteredPluginComposerPill {
installation: InstalledPlugin;
contribution: PluginComposerPillContribution;
}
class PluginComposerPillStore {
private entries: RegisteredPluginComposerPill[] = [];
private readonly listeners = new Set<() => void>();
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): readonly RegisteredPluginComposerPill[] => this.entries;
add(installation: InstalledPlugin, input: PluginComposerPillContribution): () => void {
const contribution = validateContribution(input);
const duplicate = this.entries.some(
(entry) =>
entry.installation === installation &&
entry.contribution.workspaceId === contribution.workspaceId &&
entry.contribution.agentId === contribution.agentId &&
entry.contribution.id === contribution.id,
);
if (duplicate) {
throw new Error(`Duplicate composer pill: ${contribution.id}`);
}
const entry = { installation, contribution };
this.entries = [...this.entries, entry];
this.publish();
let removed = false;
return () => {
if (removed) return;
removed = true;
this.entries = this.entries.filter((candidate) => candidate !== entry);
this.publish();
};
}
removeInstallation(installation: InstalledPlugin): void {
const next = this.entries.filter((entry) => entry.installation !== installation);
if (next.length === this.entries.length) return;
this.entries = next;
this.publish();
}
private publish(): void {
for (const listener of this.listeners) listener();
}
}
function validateContribution(
contribution: PluginComposerPillContribution,
): PluginComposerPillContribution {
const id = contribution.id.trim();
const title = contribution.title.trim();
const workspaceId = contribution.workspaceId.trim();
const agentId = contribution.agentId.trim();
if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid composer pill id: ${contribution.id}`);
if (!title) throw new Error(`Composer pill ${id} has no title`);
if (!workspaceId) throw new Error(`Composer pill ${id} has no workspace`);
if (!agentId) throw new Error(`Composer pill ${id} has no agent`);
if (typeof contribution.Component !== "function") {
throw new Error(`Composer pill ${id} is not a component`);
}
if (typeof contribution.onPress !== "function") {
throw new Error(`Composer pill ${id} has no callback`);
}
return { ...contribution, id, title, workspaceId, agentId };
}
export const pluginComposerPillStore = new PluginComposerPillStore();
+35 -4
View File
@@ -7,8 +7,11 @@ const runtime = {
openSettings() {},
openSurface() {},
openPanel() {},
addHeaderButton() {
return { update() {}, remove() {} };
},
addComposerPill() {
return () => undefined;
return { update() {}, remove() {} };
},
} as unknown as PluginClientRuntime;
@@ -25,6 +28,31 @@ function bundle(body: string): string {
}
describe("evaluatePluginClientBundle", () => {
it("releases button registrations when client setup throws", () => {
let active = 0;
function addButton() {
active++;
return {
update() {},
remove() {
active--;
},
};
}
expect(() =>
runPluginClientBundle(
"failed-buttons",
bundle(`
plugin.addHeaderButton({ id: "header", workspaceId: "workspace", button: { title: "Header", icon: "Scan", behavior: { kind: "action", onPress() {} } } });
plugin.addComposerPill({ id: "pill", workspaceId: "workspace", agentId: "agent", button: { title: "Pill", icon: "Scan", behavior: { kind: "action", onPress() {} } } });
throw new Error("Setup failed");
`),
{ ...runtime, addHeaderButton: addButton, addComposerPill: addButton },
),
).toThrow("Setup failed");
expect(active).toBe(0);
});
it("accepts memoized settings screens", () => {
const plugin = evaluatePluginClientBundle(
"settings",
@@ -50,7 +78,7 @@ describe("evaluatePluginClientBundle", () => {
plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }),
plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }),
plugin.addSlashCommand({ name: "review", description: "Review", argumentHint: "", context: "workspace", onSubmit() {} }),
plugin.addComposerPill({ id: "pill", title: "Pill", workspaceId: "workspace", agentId: "agent", Component, onPress() {} }),
plugin.addComposerPill({ id: "pill", workspaceId: "workspace", agentId: "agent", button: { title: "Pill", icon: "Scan", behavior: { kind: "action", onPress() {} } } }).remove,
plugin.addAttachmentSource({ id: "issues", title: "Issues", icon: "Blocks", pickerTitle: "Attach issue", searchPlaceholder: "Search", search: { name: "issues.search", input: {}, output: {} } }),
plugin.addTheme({ id: "night", name: "Night", appearance: "dark", colors: { background: "#000", foreground: "#fff", raised: "#111", control: "#222", border: "#333", mutedForeground: "#aaa", ring: "#555" } }),
plugin.addTimelineTransformer({ id: "transformer", query: { itemType: "tool_call" }, transform() { return { items: [] }; } }),
@@ -61,8 +89,11 @@ describe("evaluatePluginClientBundle", () => {
...runtime,
addComposerPill() {
pillCount += 1;
return () => {
pillCount -= 1;
return {
update() {},
remove() {
pillCount -= 1;
},
};
},
},
+43 -15
View File
@@ -23,6 +23,7 @@ import {
type PluginTimelineRendererContribution,
type PluginTimelineTransformerContribution,
type PluginWorkspacePanelContribution,
type PluginButtonRegistration,
} from "@getpaseo/plugin/client";
import type { EvaluatedPlugin } from "./types";
import type { ComponentType } from "react";
@@ -70,7 +71,13 @@ function requireId(value: string, label: string): string {
export type PluginClientRuntime = Pick<
PluginClientContext,
"paseo" | "rpc" | "openSettings" | "openSurface" | "openPanel" | "addComposerPill"
| "paseo"
| "rpc"
| "openSettings"
| "openSurface"
| "openPanel"
| "addComposerPill"
| "addHeaderButton"
>;
export function runPluginClientBundle(
@@ -103,6 +110,23 @@ export function runPluginClientBundle(
const timelineRendererIds = new Set<string>();
const removals = new Set<PluginCleanup>();
let setupComplete = false;
let stopped = false;
function trackButton(registration: PluginButtonRegistration): PluginButtonRegistration {
let active = true;
const remove = () => {
if (!active) return;
active = false;
registration.remove();
removals.delete(remove);
};
removals.add(remove);
return {
update: (patch) => {
if (active && !stopped) registration.update(patch);
},
remove,
};
}
const notifyChange = () => {
if (setupComplete) onChange();
};
@@ -336,16 +360,12 @@ export function runPluginClientBundle(
);
},
addComposerPill(contribution) {
const removePill = runtime.addComposerPill(contribution);
let active = true;
const remove = () => {
if (!active) return;
active = false;
removePill();
removals.delete(remove);
};
removals.add(remove);
return remove;
if (stopped) throw new Error("Plugin has stopped");
return trackButton(runtime.addComposerPill(contribution));
},
addHeaderButton(contribution) {
if (stopped) throw new Error("Plugin has stopped");
return trackButton(runtime.addHeaderButton(contribution));
},
};
const runtimeRequire = (name: string): unknown => {
@@ -372,9 +392,16 @@ export function runPluginClientBundle(
if (typeof setup !== "function") {
throw new Error(`Plugin ${id} must default export a function`);
}
const entryCleanup = setup(pluginContext);
if (typeof entryCleanup !== "function") {
throw new Error(`Plugin ${id} contribution must return a cleanup function`);
let entryCleanup: PluginCleanup;
try {
entryCleanup = setup(pluginContext);
if (typeof entryCleanup !== "function") {
throw new Error(`Plugin ${id} contribution must return a cleanup function`);
}
} catch (error) {
stopped = true;
for (const remove of removals) remove();
throw error;
}
try {
@@ -384,6 +411,8 @@ export function runPluginClientBundle(
}
}
} catch (error) {
stopped = true;
for (const remove of removals) remove();
try {
void Promise.resolve(entryCleanup()).catch((cleanupError) => {
console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError);
@@ -394,7 +423,6 @@ export function runPluginClientBundle(
throw error;
}
setupComplete = true;
let stopped = false;
const cleanup = async () => {
if (stopped) return;
stopped = true;
+5 -1
View File
@@ -3,4 +3,8 @@ export { buildLegacyPluginSurfaceRedirectRoute } from "./routes";
export { PluginSurfaceScreen } from "./surface-screen";
export { usePluginAttachmentPicker } from "./attachments/picker";
export { PluginResourceAttachmentPill } from "./attachments/pill";
export { PluginComposerPills, useHasPluginComposerPills } from "./composer-pills";
export {
PluginComposerPills,
PluginHeaderButtons,
useHasPluginComposerPills,
} from "./buttons/view";
@@ -22,7 +22,8 @@ function registry(version: string) {
},
openSurface() {},
openPanel() {},
addComposerPill: () => () => {},
addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }),
};
},
});
+2 -1
View File
@@ -12,7 +12,8 @@ vi.mock("./client-runtime", () => ({
rpc: async () => undefined,
openSurface: () => undefined,
openPanel: () => undefined,
addComposerPill: () => () => undefined,
addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }),
}),
}));
@@ -24,7 +24,8 @@ vi.mock("../client-runtime", () => ({
rpc: async () => undefined,
openSurface: () => undefined,
openPanel: () => undefined,
addComposerPill: () => () => undefined,
addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }),
}),
}));
vi.mock("../icons", () => ({
@@ -13,7 +13,8 @@ vi.mock("../client-runtime", () => ({
rpc: async () => undefined,
openSurface: () => undefined,
openPanel: () => undefined,
addComposerPill: () => () => undefined,
addComposerPill: () => ({ update() {}, remove() {} }),
addHeaderButton: () => ({ update() {}, remove() {} }),
}),
}));
@@ -197,6 +197,7 @@ import {
WorkspaceHeaderMenuDesktop,
WorkspaceHeaderMenuMobile,
} from "@/screens/workspace/workspace-header-menu";
import { PluginHeaderButtons } from "@/plugins";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
@@ -3757,6 +3758,7 @@ function WorkspaceScreenContent({
const headerRight = useMemo(
() => (
<View style={styles.headerRight}>
<PluginHeaderButtons serverId={normalizedServerId} workspaceId={normalizedWorkspaceId} />
{!isMobile && workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? (
<WorkspaceScriptsButton
serverId={normalizedServerId}
@@ -4179,7 +4181,7 @@ const styles = StyleSheet.create((theme) => ({
md: "row",
},
alignItems: {
xs: "flex-start",
xs: "stretch",
md: "center",
},
justifyContent: "flex-start",
@@ -142,7 +142,7 @@ export async function inspectConfig(
`import React from "react";
import { Text } from "react-native";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
import { type PluginAgentPanelProps, type PluginClientContext, type PluginComposerPillProps, type PluginSurfaceProps, useAgent, usePaseo, useWorkspace } from "@getpaseo/plugin/client";
import { type PluginAgentPanelProps, type PluginClientContext, type PluginSurfaceProps, useAgent, usePaseo, useWorkspace } from "@getpaseo/plugin/client";
import { inspect } from "../shared/inspect";
export function Surface({ navigation }: PluginSurfaceProps) {
@@ -171,22 +171,41 @@ export function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) {
return <Text>{workspaceName}: {agentTitle}</Text>;
}
export function ComposerPill({ workspaceId, agentId }: PluginComposerPillProps) {
return <Text>{workspaceId}: {agentId}</Text>;
}
export function contributeClient(client: PluginClientContext) {
return client.addComposerPill({
id: "open-review",
title: "Open review",
const header = client.addHeaderButton({
id: "review-tools",
workspaceId: "workspace-a",
agentId: "agent-a",
Component: ComposerPill,
async onPress() {
await client.rpc(inspect, {});
client.openPanel("review", { workspaceId: "workspace-a", agentId: "agent-a" });
button: {
title: "Review tools",
icon: "Scan",
label: "Review",
behavior: {
kind: "menu",
items: [{ kind: "item", id: "refresh", title: "Refresh review", behavior: {
kind: "action", async onPress() { await client.rpc(inspect, {}); },
} }],
},
},
});
const pill = client.addComposerPill({
id: "open-review",
workspaceId: "workspace-a",
agentId: "agent-a",
button: {
title: "Open review",
icon: "Scan",
behavior: {
kind: "action",
async onPress() {
await client.rpc(inspect, {});
client.openPanel("review", { workspaceId: "workspace-a", agentId: "agent-a" });
},
},
},
});
header.update({ visible: false });
pill.update({ disabled: true });
return () => { header.remove(); pill.remove(); };
}
`,
),
@@ -237,6 +256,37 @@ export default function contribute(server: PluginServerContext) {
await expect(typecheckPlugin(directory)).resolves.toBeUndefined();
}, 20_000);
it("reports TypeScript errors for the old composer pill shape and cleanup", async () => {
const parent = await mkdtemp(path.join(process.cwd(), ".plugin-scaffold-"));
directories.push(parent);
const directory = path.join(parent, "old-composer-pill");
await scaffoldPluginDirectory(directory);
await writeFile(
path.join(directory, "index.client.tsx"),
`
import type { PluginClientContext, PluginComposerPillProps } from "@getpaseo/plugin/client";
export default function contribute(client: PluginClientContext) {
const oldPill = {
id: "review", workspaceId: "workspace-a", agentId: "agent-a",
title: "Review", Component: () => null, onPress() {},
};
client.addComposerPill(oldPill);
const pill = client.addComposerPill({
id: "review", workspaceId: "workspace-a", agentId: "agent-a",
button: { title: "Review", icon: "Scan", behavior: { kind: "action", onPress() {} } },
});
pill();
return () => pill.remove();
}
`,
);
const check = typecheckPlugin(directory);
await expect(check).rejects.toThrow("has no exported member 'PluginComposerPillProps'");
await expect(check).rejects.toThrow("Property 'button' is missing");
await expect(check).rejects.toThrow("has no call signatures");
}, 20_000);
it("refuses to write into a non-empty directory", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
directories.push(directory);
+57
View File
@@ -0,0 +1,57 @@
import type { ComponentType } from "react";
import type { PluginHostProps } from "./contracts.js";
export type PluginButtonContext =
| { context: "workspace"; workspaceId: string }
| { context: "agent"; workspaceId: string; agentId: string };
export type PluginButtonIconProps = PluginHostProps &
PluginButtonContext & { size: number; color: string };
export type PluginButtonContentProps = PluginHostProps & PluginButtonContext & { close(): void };
export type PluginButtonIcon = string | ComponentType<PluginButtonIconProps>;
export type PluginButtonBehavior =
| { kind: "action"; onPress(): void | Promise<void> }
| { kind: "menu"; items: readonly PluginButtonMenuEntry[] }
| { kind: "popover"; Content: ComponentType<PluginButtonContentProps> };
export type PluginButtonMenuEntry =
| { kind: "separator"; id: string }
| {
kind: "item";
id: string;
title: string;
icon?: PluginButtonIcon;
visible?: boolean;
disabled?: boolean;
behavior: PluginButtonBehavior;
};
export interface PluginButton {
title: string;
icon: PluginButtonIcon;
/** Omit for an icon-only header button. Composer pills use title when omitted. */
label?: string;
visible?: boolean;
disabled?: boolean;
behavior: PluginButtonBehavior;
}
export interface PluginButtonRegistration {
/** Updates presentation in place. Supply a complete behavior to replace it. */
update(patch: Partial<PluginButton>): void;
/** Idempotent. Updates after removal do nothing. */
remove(): void;
}
export interface PluginHeaderButtonContribution {
id: string;
workspaceId: string;
button: PluginButton;
}
export interface PluginComposerPillContribution extends PluginHeaderButtonContribution {
agentId: string;
}
+7 -15
View File
@@ -3,6 +3,11 @@ import type { PaseoApi } from "@getpaseo/client";
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginRpcContract } from "../rpc.js";
import type {
PluginButtonRegistration,
PluginHeaderButtonContribution,
PluginComposerPillContribution,
} from "./buttons.js";
import type {
PluginTheme,
PluginWorkspaceSnapshot,
@@ -65,20 +70,6 @@ export interface PluginAgentPanelProps extends PluginNavigableHostProps {
agentId: string;
}
export interface PluginComposerPillProps extends PluginHostProps {
workspaceId: string;
agentId: string;
}
export interface PluginComposerPillContribution {
id: string;
title: string;
workspaceId: string;
agentId: string;
Component: ComponentType<PluginComposerPillProps>;
onPress(): void | Promise<void>;
}
export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
workspaceId: string;
agentId?: string;
@@ -91,7 +82,8 @@ export interface PluginClientContext extends PluginCommandCapabilities {
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup;
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup;
addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup;
addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup;
addHeaderButton(contribution: PluginHeaderButtonContribution): PluginButtonRegistration;
addComposerPill(contribution: PluginComposerPillContribution): PluginButtonRegistration;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup;
addTheme(contribution: PluginThemeContribution): PluginCleanup;
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
+12 -2
View File
@@ -6,8 +6,6 @@ export type {
PluginOpenPanelOptions,
PluginWorkspacePanelProps,
PluginAgentPanelProps,
PluginComposerPillProps,
PluginComposerPillContribution,
PluginClientOpenPanelOptions,
PluginClientContext,
PluginClientContribution,
@@ -26,6 +24,18 @@ export type {
PluginClientSlashCommandContribution,
SettingsState,
} from "./contracts.js";
export type {
PluginButton,
PluginButtonBehavior,
PluginButtonContentProps,
PluginButtonContext,
PluginButtonIcon,
PluginButtonIconProps,
PluginButtonMenuEntry,
PluginButtonRegistration,
PluginComposerPillContribution,
PluginHeaderButtonContribution,
} from "./buttons.js";
export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js";
export { useRpc } from "./rpc-context.js";
+41
View File
@@ -0,0 +1,41 @@
# Header buttons and composer pills
Install this directory as a plugin, open an agent, and choose **Button examples: action** in the
Command Center. One header button and one composer pill appear. Choose another mode to update
those same registrations. Switching agents moves the example to that agent's workspace and composer.
| Command Center command | Header | Composer | Try |
| ------------------------------------- | ------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| **Button examples: action** | Refresh icon only | Refresh icon and label | Refresh reads the workspace from the daemon and updates the label and tooltip. Paseo owns pending and error feedback. |
| **Button examples: menu** | Wrench, Tools, chevron | Wrench and Tools | Refresh, a separator, custom details, a Display submenu, and a disabled item. |
| **Button examples: popover** | Reactive status dot, Details, chevron | Reactive status dot and Details | Workspace data updates through `useWorkspace`; Done closes the surface. |
| **Button examples: hide / show** | Hide or restore the button | Hide or restore the pill | Visibility changes through `registration.update({ visible })`. |
| **Button examples: disable / enable** | Disable or enable the button | Disable or enable the pill | Disabled state changes through `registration.update({ disabled })`. |
On compact layouts the header button is borderless and icon-only. Menus and custom content open
bottom sheets. Composer pills keep their icon and label, without a chevron, on every layout.
The example contributes one header button at a time. Additional actions live under the named
**Tools** button; the example does not add a three-dot control or trigger plugin overflow.
| Action | Menu | Custom content | Compact sheet |
| ------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------- |
| ![Action in the header and composer](screenshots/actions.png) | ![Named Tools menu](screenshots/menu.png) | ![Custom status icon and popover](screenshots/popover.png) | ![Composer content as a compact sheet](screenshots/compact.png) |
The client entry owns the active example. [client/examples.tsx](client/examples.tsx) contains the
descriptors, updates, and React content. Return `.remove()` cleanup when the example moves or stops.
See the [button reference](../../public-docs/plugins/v0.8/reference.md#button-descriptor) and
[composer migration](../../public-docs/plugins/v0.8/migration.md#composer-pills).
From the repository root, `npm run typecheck --workspace=@getpaseo/plugin` checks this example
against the SDK. Existing plugin projects must update `@getpaseo/plugin` before running their own
`npm run typecheck` to detect the old `Component`/`onPress` pill shape and callable cleanup handle.
The browser regression installs this exact directory in an isolated daemon and exercises both
layouts, updates, and cleanup:
```sh
npm run test:e2e --workspace=@getpaseo/app -- e2e/browser/plugin-button-example.spec.ts
```
The captures use Chromium at desktop and phone widths; native iOS and Android were not exercised.
+210
View File
@@ -0,0 +1,210 @@
import {
type PluginButton,
type PluginButtonContentProps,
type PluginButtonIconProps,
type PluginClientContext,
useWorkspace,
} from "@getpaseo/plugin/client";
import { useMemo } from "react";
import { Pressable, Text, View } from "react-native";
export type ButtonMode = "action" | "menu" | "popover";
const composerPresentation = {
action: { title: "Refresh context", label: "Refresh" },
menu: { title: "Composer tools", label: "Tools" },
popover: { title: "Context details", label: "Details" },
};
function WorkspaceStatusIcon({ workspaceId, size, color, theme }: PluginButtonIconProps) {
const status = useWorkspace(workspaceId, (workspace) => workspace.status);
let fill = color;
if (status === "failed") fill = theme.colors.statusDanger;
else if (status === "needs_input") fill = theme.colors.statusWarning;
else if (status === "done") fill = theme.colors.statusSuccess;
const style = useMemo(
() => ({ width: size, height: size, borderRadius: size / 2, backgroundColor: fill }),
[size, fill],
);
return <View style={style} />;
}
function WorkspaceDetails({ workspaceId, theme, close }: PluginButtonContentProps) {
const workspace = useWorkspace(workspaceId, ({ name, projectDisplayName, status, diffStat }) => ({
name,
projectDisplayName,
status,
diffStat,
}));
const styles = useMemo(
() => ({
body: { gap: 12 },
title: { color: theme.colors.foreground, fontSize: 18 },
text: { color: theme.colors.foreground },
detail: { color: theme.colors.foregroundMuted },
button: { padding: 8, borderRadius: 6, backgroundColor: theme.colors.surface2 },
}),
[theme],
);
return (
<View style={styles.body}>
<Text style={styles.title}>Workspace details</Text>
<Text style={styles.text}>{workspace?.name}</Text>
<Text style={styles.detail}>{workspace?.projectDisplayName}</Text>
<Text style={styles.detail}>Status: {workspace?.status}</Text>
{workspace?.diffStat ? (
<Text style={styles.detail}>
+{workspace.diffStat.additions} / −{workspace.diffStat.deletions} lines
</Text>
) : null}
<Pressable
accessibilityRole="button"
accessibilityLabel="Close workspace details"
onPress={close}
style={styles.button}
>
<Text style={styles.text}>Done</Text>
</Pressable>
</View>
);
}
export function createButtonExamples(
client: PluginClientContext,
workspaceId: string,
agentId: string,
) {
let mode: ButtonMode = "action";
let refreshes = 0;
async function refreshWorkspace() {
// Return the real operation's promise: Paseo supplies pending, double-press prevention, and errors.
await client.paseo.workspaces.ref(workspaceId).refresh();
refreshes += 1;
if (mode === "action") {
header.update({ title: `Refresh workspace (${refreshes})` });
pill.update({ label: `Refreshed · ${refreshes}` });
}
}
function button(next: ButtonMode): PluginButton {
if (next === "action")
return {
title: "Refresh workspace",
icon: "RefreshCw",
label: undefined, // The header is icon-only. The composer supplies its own label below.
behavior: { kind: "action", onPress: refreshWorkspace },
};
if (next === "popover")
return {
title: "Workspace details",
icon: WorkspaceStatusIcon,
label: "Details",
behavior: { kind: "popover", Content: WorkspaceDetails },
};
return {
title: "Workspace tools",
icon: "Wrench",
label: "Tools",
behavior: {
kind: "menu",
items: [
{
kind: "item",
id: "refresh",
title: "Refresh workspace",
icon: "RefreshCw",
behavior: { kind: "action", onPress: refreshWorkspace },
},
{ kind: "separator", id: "details-divider" },
{
kind: "item",
id: "details",
title: "Workspace details",
icon: WorkspaceStatusIcon,
behavior: { kind: "popover", Content: WorkspaceDetails },
},
{
kind: "item",
id: "display",
title: "Display",
icon: "Eye",
behavior: {
kind: "menu",
items: [
{
kind: "item",
id: "hide",
title: "Hide example buttons",
icon: "EyeOff",
behavior: {
kind: "action",
onPress() {
setVisible(false);
},
},
},
{
kind: "item",
id: "disable",
title: "Disable example buttons",
behavior: {
kind: "action",
onPress() {
setDisabled(true);
},
},
},
],
},
},
{
kind: "item",
id: "publish",
title: "Publish (unavailable in example)",
icon: "Upload",
disabled: true,
behavior: { kind: "action", onPress() {} },
},
],
},
};
}
function composerButton(next: ButtonMode): PluginButton {
return {
...button(next),
...composerPresentation[next],
};
}
const header = client.addHeaderButton({ id: "example", workspaceId, button: button(mode) });
const pill = client.addComposerPill({
id: "example",
workspaceId,
agentId,
button: composerButton(mode),
});
function setVisible(visible: boolean) {
header.update({ visible });
pill.update({ visible });
}
function setDisabled(disabled: boolean) {
header.update({ disabled });
pill.update({ disabled });
}
return {
setMode(next: ButtonMode) {
mode = next;
header.update({ ...button(next), visible: true, disabled: false });
pill.update({ ...composerButton(next), visible: true, disabled: false });
},
setVisible,
setDisabled,
remove() {
header.remove();
pill.remove();
},
};
}
+61
View File
@@ -0,0 +1,61 @@
import type { PluginAgentCommandContext, PluginClientContext } from "@getpaseo/plugin/client";
import { createButtonExamples, type ButtonMode } from "./client/examples";
export default function contribute(client: PluginClientContext) {
// Keep one example active at a time, including when switching agents or workspaces.
let current:
| {
workspaceId: string;
agentId: string;
buttons: ReturnType<typeof createButtonExamples>;
}
| undefined;
function buttonsFor({ workspace, agent }: PluginAgentCommandContext) {
if (current?.workspaceId !== workspace.id || current.agentId !== agent.id) {
current?.buttons.remove();
current = {
workspaceId: workspace.id,
agentId: agent.id,
buttons: createButtonExamples(client, workspace.id, agent.id),
};
}
return current.buttons;
}
for (const mode of ["action", "menu", "popover"] satisfies ButtonMode[]) {
client.addCommandCenterItem({
id: `show-${mode}`,
title: `Button examples: ${mode}`,
icon: "MousePointerClick",
context: "agent",
onSelect(context) {
buttonsFor(context).setMode(mode);
},
});
}
for (const visible of [false, true]) {
client.addCommandCenterItem({
id: visible ? "show" : "hide",
title: `Button examples: ${visible ? "show" : "hide"}`,
icon: visible ? "Eye" : "EyeOff",
context: "agent",
onSelect(context) {
buttonsFor(context).setVisible(visible);
},
});
}
for (const disabled of [true, false]) {
client.addCommandCenterItem({
id: disabled ? "disable" : "enable",
title: `Button examples: ${disabled ? "disable" : "enable"}`,
icon: "MousePointerClick",
context: "agent",
onSelect(context) {
buttonsFor(context).setDisabled(disabled);
},
});
}
return () => current?.buttons.remove();
}
@@ -0,0 +1,4 @@
{
"id": "button-examples",
"requirements": { "paseo": ">=0.8.0" }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+15 -25
View File
@@ -1,10 +1,8 @@
import { useMutation } from "@tanstack/react-query";
import { Icon } from "@getpaseo/plugin/client/react-native";
import {
type PluginClientContext,
type PluginComposerPillProps,
type PluginButtonRegistration,
type PluginWorkspacePanelProps,
useAgent,
useRpc,
useWorkspace,
} from "@getpaseo/plugin/client";
@@ -12,41 +10,33 @@ import { useCallback, useMemo } from "react";
import { Pressable, Text, View } from "react-native";
import { incrementRpc } from "../shared/increment";
export function OpenCounterPill({ theme, workspaceId, agentId }: PluginComposerPillProps) {
const workspace = useWorkspace(workspaceId, ({ name }) => ({ name }));
const agent = useAgent(agentId, ({ title }) => ({ title }));
const textStyle = useMemo(() => ({ color: theme.colors.foregroundMuted }), [theme]);
return (
<>
<Icon name="Blocks" size={14} color={theme.colors.foregroundMuted} />
<Text style={textStyle} numberOfLines={1}>
{agent?.title ?? workspace?.name ?? "Counter"}
</Text>
</>
);
}
export function contributeClient(client: PluginClientContext) {
const pills = new Map<string, () => void>();
const pills = new Map<string, PluginButtonRegistration>();
let stopped = false;
const register = (agent: { id: string; workspaceId?: string | null }) => {
if (stopped || !agent.workspaceId) return;
pills.get(agent.id)?.();
pills.get(agent.id)?.remove();
const workspaceId = agent.workspaceId;
const remove = client.addComposerPill({
id: "open-counter",
title: "Open plugin counter",
workspaceId,
agentId: agent.id,
Component: OpenCounterPill,
onPress() {
client.openPanel("counter", { workspaceId });
button: {
title: "Open plugin counter",
icon: "Blocks",
label: "Counter",
behavior: {
kind: "action",
onPress() {
client.openPanel("counter", { workspaceId });
},
},
},
});
pills.set(agent.id, remove);
};
const remove = (agentId: string) => {
pills.get(agentId)?.();
pills.get(agentId)?.remove();
pills.delete(agentId);
};
const unsubscribe = client.paseo.agents.subscribe((update) => {
@@ -63,7 +53,7 @@ export function contributeClient(client: PluginClientContext) {
return () => {
stopped = true;
unsubscribe();
for (const dispose of pills.values()) dispose();
for (const pill of pills.values()) pill.remove();
pills.clear();
};
}
+36 -1
View File
@@ -77,6 +77,7 @@ Use this table as the complete registration checklist.
| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` |
| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function |
| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function |
| New header contribution | `client.addHeaderButton({ id, workspaceId, button })` |
| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` |
| New settings screen contribution | `client.addSettingsScreen(screen)` in `index.client.tsx`; see [settings screens](reference#settings-screens) |
| `plugin.addTheme(theme)` in the old root entry | `client.addTheme(theme)` in `index.client.tsx` |
@@ -86,9 +87,43 @@ Use this table as the complete registration checklist.
| `ZodOutput<typeof contract.input>` handler parameter types | `RpcInput<typeof contract>` from `@getpaseo/plugin`; `RpcOutput` for return types |
Import `PluginClientContext` from `@getpaseo/plugin/client` and `PluginServerContext` from
`@getpaseo/plugin/server`. Remove imports of the old context type. Every client `add*` now returns an idempotent removal function. Preserve any remover the plugin calls before teardown; Paseo removes outstanding
`@getpaseo/plugin/server`. Remove imports of the old context type. Client registrations return idempotent removal functions, except header buttons and composer pills, which return `{ update, remove }` handles. Preserve any remover the plugin calls before teardown; Paseo removes outstanding
registrations after the entry cleanup runs.
### Composer pills
Update the plugin project's `@getpaseo/plugin` dependency, then run `npm run typecheck`.
The old contribution is missing the required `button` field, `PluginComposerPillProps` is no longer
exported, and calling the new registration as a function is a TypeScript error. A project pinned
to the old SDK still checks against the old contract; installing or reloading a plugin does not
run TypeScript's type checker.
Replace the pill's `Component` with `button.icon` and `button.label`, move `title` into `button`,
and move `onPress` into `button.behavior`. Cleanup changes from calling the returned function to
calling its `.remove()` method.
```tsx
const pill = client.addComposerPill({
id: "review",
workspaceId,
agentId,
button: {
title: "Open review",
icon: "Scan",
label: "Review",
behavior: { kind: "action", onPress: openReview },
},
});
pill.update({ label: "Review · 3", visible: true });
// Client entry cleanup:
pill.remove();
```
Move dynamic text from the former component into updates from your model or SDK subscription.
Custom icon components may still use hooks. Composer pills always show icon and label and never
show chevrons. See [buttons](./reference.md#button-descriptor) for menus, popovers, and visibility.
## 4. Separate imports
Move hooks (`usePaseo`, `useRpc`, `useSettings`, `useAgent`, `useWorkspace`) and client contribution
+152 -68
View File
@@ -223,8 +223,8 @@ SVG or URL.
## Entry point and cleanup
Each present entry default-exports one contribution function and returns cleanup. Client entries
receive `PluginClientContext`; server entries receive `PluginServerContext`. Every client `add*`
returns an idempotent remover. The entry cleanup runs before Paseo removes remaining registrations.
receive `PluginClientContext`; server entries receive `PluginServerContext`. Client registration methods return idempotent removers, except header buttons and composer pills,
which return `{ update, remove }` handles. The entry cleanup runs before Paseo removes remaining registrations.
```ts
import type { PluginClientContext } from "@getpaseo/plugin/client";
@@ -1295,81 +1295,165 @@ Precedence is built-in client commands, plugin commands, then provider commands.
collision is omitted. Built-in aliases also reserve their names. The first plugin in stable catalog
order wins a collision between plugins. Commands do not run while the composer has attachments.
## Composer pills
## Header buttons
The client entry owns pill creation and removal. This can live directly in `index.client.tsx` or in
a function it imports from `client/`:
Try the [button example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/buttons) for
actions, menus, custom icons and content, and visibility updates in both the header and composer.
It switches one header button between modes; additional actions use a named Tools menu.
`client.addHeaderButton({ id, workspaceId, button })` adds a button before the built-in actions on
the workspace header's right side. It returns a registration with `update(patch)` and `remove()`.
```tsx
import { Icon } from "@getpaseo/plugin/client/react-native";
import {
type PluginClientContext,
type PluginComposerPillProps,
useAgent,
} from "@getpaseo/plugin/client";
import { Text } from "react-native";
const review = client.addHeaderButton({
id: "review",
workspaceId,
button: {
title: "Open review",
icon: "Scan",
label: "Review",
behavior: {
kind: "action",
onPress() {
client.openPanel("review", { workspaceId });
},
},
},
});
function ReviewPill({ theme, agentId }: PluginComposerPillProps) {
const agent = useAgent(agentId, ({ title }) => ({ title }));
return (
<>
<Icon name="Scan" size={14} color={theme.colors.foregroundMuted} />
<Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, flexShrink: 1 }}>
{agent?.title ?? "Review"}
</Text>
</>
);
}
export default function contribute(client: PluginClientContext) {
const pills = new Map<string, () => void>();
const unsubscribe = client.paseo.agents.subscribe((update) => {
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
const { id: agentId, workspaceId } = update.agent;
pills.get(agentId)?.();
pills.set(
agentId,
client.addComposerPill({
id: "review",
title: "Open review",
workspaceId,
agentId,
Component: ReviewPill,
async onPress() {
await client.rpc(refreshReview, { agentId });
client.openPanel("review", { workspaceId, agentId });
},
}),
);
});
return () => {
unsubscribe();
for (const remove of pills.values()) remove();
};
}
review.update({ label: "Review · 3" });
review.update({ visible: false });
review.update({ visible: true });
review.remove();
```
`addComposerPill` fields:
Omit `label` for an icon-only header button. Menus and popovers show a chevron on wide layouts.
Compact header buttons use icons without labels or chevrons. Paseo moves excess contributions
into a shared overflow menu. Placement and overflow are host decisions.
| Field | Required | Meaning |
| ------------- | -------- | ---------------------------------------------------------- |
| `id` | Yes | Plugin-local ID within the target agent. |
| `title` | Yes | Accessible button label. |
| `workspaceId` | Yes | Workspace whose composer track owns the pill. |
| `agentId` | Yes | Agent whose composer track owns the pill. |
| `Component` | Yes | React Native component rendering the pill's icon and text. |
| `onPress` | Yes | Client-side callback. |
## Composer pills
The client entry runs once per plugin installation in each connected app. Its context exposes
`paseo`, typed `rpc`, `openSurface`, explicit-context `openPanel`, and every client registration.
`addComposerPill` returns an idempotent removal function. Paseo also removes every outstanding pill
when the plugin installation or host connection is torn down.
`client.addComposerPill({ id, workspaceId, agentId, button })` uses the same [button descriptor](#button-descriptor)
and returns the same registration. It targets one agent's composer track alongside Tasks and
Subagents. Composer pills always show the icon and `label` (or `title` when `label` is omitted).
They never show a chevron, including for menus and popovers.
Paseo owns the pressable, shared pill chrome, pending state, error reporting, and track-bar
placement. The component receives `theme`, `host`, `layout`, `workspaceId`, and `agentId`. Read
current values with `useWorkspace` and `useAgent`. The plugin owns when the pill exists, its icon
and text, and the callback. `openPanel(id, { workspaceId, agentId? })` opens or focuses a panel
registered by the same plugin.
```tsx
const pill = client.addComposerPill({
id: "review",
workspaceId,
agentId,
button: {
title: "Open review",
icon: "Scan",
label: "Review",
behavior: {
kind: "action",
onPress() {
client.openPanel("review", { workspaceId, agentId });
},
},
},
});
```
## Button descriptor
These contracts are exported from `@getpaseo/plugin/client`.
| Field | Required | Meaning |
| ---------- | -------- | ----------------------------------------------------------------------- |
| `title` | Yes | Non-empty accessible label, tooltip, and sheet title. |
| `icon` | Yes | Lucide name or `ComponentType<PluginButtonIconProps>`. |
| `label` | No | Non-empty display text. Omit to use the placement's default. |
| `visible` | No | Defaults to `true`. False removes the trigger and its layout space. |
| `disabled` | No | Defaults to `false`. Keeps the button visible and prevents interaction. |
| `behavior` | Yes | One of the three shapes below. |
```tsx
type PluginButtonBehavior =
| { kind: "action"; onPress(): void | Promise<void> }
| { kind: "menu"; items: readonly PluginButtonMenuEntry[] }
| { kind: "popover"; Content: React.ComponentType<PluginButtonContentProps> };
```
An action runs on the client. Paseo marks the button busy until its promise settles, blocks repeated
presses, and shows failures in a toast. A failed action can be retried. Use the client's `paseo` for
ordinary operations and `rpc` for plugin-specific backend work.
Menus and popovers open anchored surfaces on wide layouts and bottom sheets on compact layouts.
The whole trigger opens the surface; there is no split-button behavior.
### Menu entries
A menu contains items and separators. IDs use lowercase letters, digits, and hyphens, start with
a letter, and are unique within that menu.
```tsx
const behavior: PluginButtonBehavior = {
kind: "menu",
items: [
{
kind: "item",
id: "refresh",
title: "Refresh review",
icon: "RefreshCw",
behavior: { kind: "action", onPress: refreshReview },
},
{ kind: "separator", id: "details-divider" },
{
kind: "item",
id: "details",
title: "Review details",
behavior: { kind: "popover", Content: ReviewDetails },
},
],
};
```
An item requires `kind: "item"`, `id`, `title`, and `behavior`. Its optional `icon`, `visible`, and
`disabled` follow the button rules. A separator contains only `kind: "separator"` and `id`.
Paseo removes leading, trailing, and consecutive separators after filtering hidden items.
Items can use all three behaviors. Nested menus open flyouts on wide layouts and pages with back
navigation within the same compact sheet. Custom content pages open on selection, never hover.
Choosing an action closes the menu; opening another page keeps it open.
### Custom icons and popover content
`PluginButtonIconProps` contains `theme`, `host`, `layout`, `size`, `color`, and the target context.
Render a React Native icon or indicator within the supplied size. Paseo bounds the icon slot and
owns all pointer interaction. The icon component can use plugin hooks.
`PluginButtonContentProps` contains `theme`, `host`, `layout`, the target context, and `close()`.
Render the body only; Paseo owns anchoring, scrolling, padding, and sheet presentation. Content can
use `usePaseo`, `useRpc`, `useWorkspace`, `useAgent`, and the installation's React Query cache.
The target context is one of:
```ts
{ context: "workspace", workspaceId: string } // Header button
{ context: "agent", workspaceId: string, agentId: string } // Composer pill
```
### Updates and lifecycle
Each registration belongs to one plugin installation, placement, workspace, and (for pills) agent.
`id` is plugin-local within that target and uses the same format as menu IDs. The same ID may be
used in different targets or placements. Duplicate registration in the same target throws.
`update(patch: Partial<PluginButton>)` changes the descriptor in place, preserving identity and
order. When changing `behavior`, supply the complete new behavior object. Invalid updates throw
without changing the existing button. Subscribe to your own model or the client API and call
`update` to publish reactive changes; mutating the original descriptor does not update the UI.
Hiding or disabling a button closes its surface. Updating its behavior also closes the surface.
Hiding preserves the registration, so showing it again restores its position. It does not cancel
an action already in progress.
`remove()` is idempotent. Updates after removal do nothing. Paseo removes outstanding buttons when
the plugin installation or host connection is torn down. Return cleanup from the client entry for
your subscriptions, timers, and other resources.
## Use the Paseo SDK