fix(skills): make the paseo-plugin composer pill example match the plugin SDK (#5591)

The example passed title, Component, and onPress straight to
addComposerPill and treated its return value as a remover function, so it
failed to typecheck against @getpaseo/plugin. It also followed agents with
agents.subscribe, a local listener that never delivers agents that exist
before the plugin loads. The example now uses the button descriptor and an
owned list subscription, like the plugin reference and local-plugin
example, and a test typechecks it against the SDK.

Closes #5517

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-09-29 02:51:54 +02:00
committed by GitHub
parent 560e478ef5
commit 68938b668e
2 changed files with 89 additions and 13 deletions
@@ -0,0 +1,40 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { describe, expect, it } from "vitest";
const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const skill = readFileSync(path.join(repository, "skills/paseo-plugin/SKILL.md"), "utf8");
function sectionCode(heading: string): string {
const section = skill.slice(skill.indexOf(`\n## ${heading}\n`));
const code = /```tsx\n([\s\S]*?)\n```/.exec(section)?.[1];
if (!code) throw new Error(`No tsx example under "${heading}"`);
return code;
}
// Checks the example as a plugin client file, with the same options as the plugin examples.
function typeErrors(code: string): string[] {
const configPath = path.join(repository, "packages/plugin/tsconfig.examples.json");
const { config } = ts.readConfigFile(configPath, ts.sys.readFile);
const { options } = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(configPath));
const file = path.join(repository, "plugin-examples/skill-example/client/example.tsx");
const host = ts.createCompilerHost(options);
const getSourceFile = host.getSourceFile;
host.getSourceFile = (name, language, ...rest) =>
path.resolve(name) === file
? ts.createSourceFile(name, code, language)
: getSourceFile.call(host, name, language, ...rest);
host.fileExists = (name) => path.resolve(name) === file || ts.sys.fileExists(name);
const program = ts.createProgram([file], options, host);
return ts
.getPreEmitDiagnostics(program)
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
}
describe("paseo-plugin skill examples", () => {
it("composer pill example typechecks against the plugin SDK", () => {
expect(typeErrors(sectionCode("Add a composer pill"))).toEqual([]);
});
});
+49 -13
View File
@@ -468,34 +468,70 @@ The callback receives the same context as the matching Command Center item plus
A pill is a per-agent button in the composer track bar next to Tasks and Subagents. Add and remove pills from the client entry lifecycle. `addComposerPill` exists on `PluginClientContext`.
```tsx
import type { PluginButtonRegistration, PluginClientContext } from "@getpaseo/plugin/client";
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)?.();
const pills = new Map<string, PluginButtonRegistration>();
const lifetime = new AbortController();
const register = (agent: { id: string; workspaceId?: string | null }) => {
if (lifetime.signal.aborted || !agent.workspaceId) return;
const agentId = agent.id;
const workspaceId = agent.workspaceId;
pills.get(agentId)?.remove();
pills.set(
agentId,
client.addComposerPill({
id: "review",
title: "Open review",
workspaceId,
agentId,
Component: ReviewPill,
async onPress() {
client.openPanel("review", { workspaceId, agentId });
button: {
title: "Open review",
icon: "Scan",
label: "Review",
behavior: {
kind: "action",
onPress() {
client.openPanel("review", { workspaceId, agentId });
},
},
},
}),
);
});
};
const removeAll = () => {
for (const pill of pills.values()) pill.remove();
pills.clear();
};
// An owned list subscription delivers existing agents, then updates, and a fresh snapshot after a reconnect.
void client.paseo.agents
.list({ subscribe: {}, signal: lifetime.signal })
.then(({ subscription }) => {
subscription.subscribe({
snapshot: ({ entries }) => {
removeAll();
for (const { agent } of entries) register(agent);
},
update: (message) => {
if (message.type !== "agent_update") return;
const update = message.payload;
if (update.kind === "upsert") return register(update.agent);
pills.get(update.agentId)?.remove();
pills.delete(update.agentId);
},
});
return undefined;
})
.catch((error) => {
if (!lifetime.signal.aborted) console.error("Agent observation failed", error);
});
return () => {
unsubscribe();
for (const remove of pills.values()) remove();
lifetime.abort();
removeAll();
};
}
```
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry. The component owns its icon and text; Paseo owns the pressable, chrome, pending state, error reporting, and placement. Removal functions are idempotent, and Paseo removes every pill when the plugin, client entrypoint, or host connection is torn down.
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry. The `button` descriptor sets the icon, label, and behavior; Paseo owns the pressable, chrome, pending state, error reporting, and placement. `addComposerPill` returns a registration with `update()` and `remove()`. `remove()` is idempotent, and Paseo removes every pill when the plugin, client entrypoint, or host connection is torn down.
## Transform and render timeline items