diff --git a/packages/plugin/src/skill-examples.test.ts b/packages/plugin/src/skill-examples.test.ts new file mode 100644 index 0000000000..3a21f58c33 --- /dev/null +++ b/packages/plugin/src/skill-examples.test.ts @@ -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([]); + }); +}); diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index 3595b8fd75..f9a3086180 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -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 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(); + 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