feat(plugins): add Linear attachment source

This commit is contained in:
Mohamed Boudra
2026-08-09 01:53:55 +02:00
parent 1c41ba0bee
commit be523e09e9
31 changed files with 1291 additions and 15 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, and native surfaces |
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
+50 -4
View File
@@ -1,8 +1,8 @@
# Local plugins
Local plugins contribute daemon RPCs and native app surfaces from one `index.tsx`. Paseo executes
the server contribution in a subprocess and evaluates the client contribution in the app runtime.
Plugin code is trusted code; this first slice does not sandbox it.
Local plugins contribute daemon RPCs, native app surfaces, and composer attachment sources from one
`index.tsx`. Paseo executes the server contribution in a subprocess and evaluates the client
contribution in the app runtime. Plugin code is trusted code; this first slice does not sandbox it.
## Configure a directory source
@@ -86,4 +86,50 @@ When the same plugin contribution exists on multiple hosts, Paseo shows it once
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
query cache. Plugin code cannot address another host.
See `examples/local-plugin` for a runnable plugin.
## Contribute composer attachments
Register a declarative attachment source backed by a plugin RPC. Paseo owns the attachment menu,
search picker, drafts, selected pill, and submission. The plugin returns complete text snapshots;
credentials and vendor API calls stay in the daemon handler.
```tsx
const searchIssues = defineRpc({
name: "issues.search",
input: z.object({ query: z.string() }),
output: z.object({
items: z.array(
z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
subtitle: z.string().optional(),
url: z.string().url(),
text: z.string(),
resourceType: z.string(),
}),
),
}),
});
const issues = defineAttachmentSource({
id: "issues",
title: "Acme issue",
icon: "CircleDot",
pickerTitle: "Attach Acme issue",
searchPlaceholder: "Search by identifier or title",
search: searchIssues,
});
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, ({ query }) => searchAcmeIssues(query));
plugin.addAttachmentSource(issues);
}
```
Attachment sources stay scoped to the composer's host. Unlike sidebar contributions, equal sources
on several hosts are not coalesced. The selected snapshot submits as a text attachment with neutral
external-resource presentation, so it remains readable if the plugin is removed or an older peer
drops the optional presentation fields.
See `examples/local-plugin` for a native surface and `extensions/linear` for a complete official
attachment-source extension.
+32
View File
@@ -0,0 +1,32 @@
# Linear
The official Linear extension adds `Attach Linear issue` to the composer. Search by issue identifier
or title, then send a stable issue snapshot to the agent.
Set a personal API key in the daemon environment:
```bash
export LINEAR_API_KEY="lin_api_..."
```
Register the extension in `$PASEO_HOME/config.json`:
```json
{
"plugins": {
"linear": {
"source": "directory",
"path": "/absolute/path/to/paseo/extensions/linear"
}
}
}
```
Restart the development daemon after changing its environment or plugin configuration.
The entry point demonstrates the complete attachment-source pattern:
- define one validated search RPC;
- handle it in the daemon subprocess;
- register a declarative attachment source for the client;
- keep credentials and vendor API calls out of the client bundle.
+45
View File
@@ -0,0 +1,45 @@
import { z } from "zod";
import {
defineAttachmentSource,
defineRpc,
type PluginAttachmentSearchPayload,
type PluginContext,
} from "@paseo/plugin";
import { createLinearIssueSearch } from "./linear";
const SearchPayloadSchema = z.object({
items: z.array(
z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
subtitle: z.string().optional(),
url: z.string().url(),
text: z.string(),
resourceType: z.string(),
}),
),
});
const searchIssues = defineRpc({
name: "issues.search",
input: z.object({ query: z.string() }),
output: SearchPayloadSchema,
});
const issueAttachments = defineAttachmentSource({
id: "issues",
title: "Linear issue",
icon: "CircleDot",
pickerTitle: "Attach Linear issue",
searchPlaceholder: "Search by identifier or title",
search: searchIssues,
});
export default function contribute(plugin: PluginContext) {
plugin.handle(searchIssues, async ({ query }): Promise<PluginAttachmentSearchPayload> => {
const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" });
return linear.search(query);
});
plugin.addAttachmentSource(issueAttachments);
}
+135
View File
@@ -0,0 +1,135 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { describe, expect, it } from "vitest";
import { createLinearIssueSearch } from "./linear";
const issue = {
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
description: "Let extensions attach external context.",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
priorityLabel: "High",
state: { name: "In Progress" },
assignee: { name: "Mohamed" },
project: { name: "Paseo" },
labels: { nodes: [{ name: "Feature" }] },
};
interface CapturedRequest {
authorization: string | undefined;
body: { query: string; variables: Record<string, unknown> };
}
async function readRequest(request: IncomingMessage): Promise<CapturedRequest> {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
return {
authorization: request.headers.authorization,
body: JSON.parse(Buffer.concat(chunks).toString("utf8")) as CapturedRequest["body"],
};
}
async function withLinearServer<T>(
respond: (request: CapturedRequest, response: ServerResponse) => void,
run: (endpoint: string) => Promise<T>,
): Promise<T> {
const server = createServer((request, response) => {
void readRequest(request).then((captured) => respond(captured, response));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
try {
return await run(`http://127.0.0.1:${address.port}/graphql`);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
}
function sendJson(response: ServerResponse, body: unknown): void {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify(body));
}
describe("Linear issue search", () => {
it("resolves an exact issue identifier and formats its agent snapshot", async () => {
const result = await withLinearServer(
(request, response) => {
expect(request.authorization).toBe("lin_api_test");
expect(request.body.variables).toEqual({ id: "ENG-123" });
sendJson(response, { data: { issue } });
},
async (endpoint) => {
const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint });
return linear.search("eng-123");
},
);
expect(result).toEqual({
items: [
{
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
subtitle: "In Progress · Mohamed",
url: issue.url,
resourceType: "issue",
text: [
"Linear issue ENG-123: Plugin attachments",
`URL: ${issue.url}`,
"Status: In Progress",
"Priority: High",
"Assignee: Mohamed",
"Project: Paseo",
"Labels: Feature",
"",
"Let extensions attach external context.",
].join("\n"),
},
],
});
});
it("searches issue titles through a case-insensitive filter", async () => {
const result = await withLinearServer(
(request, response) => {
expect(request.body.variables).toEqual({
filter: { title: { containsIgnoreCase: "plugin" } },
});
sendJson(response, { data: { issues: { nodes: [issue] } } });
},
async (endpoint) => {
const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint });
return linear.search(" plugin ");
},
);
expect(result.items.map((item) => item.identifier)).toEqual(["ENG-123"]);
});
it("surfaces GraphQL errors even when HTTP succeeds", async () => {
await expect(
withLinearServer(
(_request, response) => sendJson(response, { errors: [{ message: "Token expired" }] }),
async (endpoint) => {
const linear = createLinearIssueSearch({ apiKey: "lin_api_test", endpoint });
return linear.search("ENG-123");
},
),
).rejects.toThrow("Token expired");
});
it("requires a daemon-side API key", () => {
expect(() => createLinearIssueSearch({ apiKey: "" })).toThrow(
"Set LINEAR_API_KEY in the daemon environment",
);
});
});
+183
View File
@@ -0,0 +1,183 @@
import { z } from "zod";
const LinearIssueSchema = z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
description: z.string().nullable(),
url: z.string(),
priorityLabel: z.string(),
state: z.object({ name: z.string() }),
assignee: z.object({ name: z.string() }).nullable(),
project: z.object({ name: z.string() }).nullable(),
labels: z.object({ nodes: z.array(z.object({ name: z.string() })) }),
});
const LinearGraphqlErrorSchema = z.object({ message: z.string() }).passthrough();
const ExactIssueResponseSchema = z.object({
data: z.object({ issue: LinearIssueSchema.nullable() }).nullable().optional(),
errors: z.array(LinearGraphqlErrorSchema).optional(),
});
const IssueSearchResponseSchema = z.object({
data: z
.object({ issues: z.object({ nodes: z.array(LinearIssueSchema) }) })
.nullable()
.optional(),
errors: z.array(LinearGraphqlErrorSchema).optional(),
});
const ISSUE_FIELDS = `
id
identifier
title
description
url
priorityLabel
state { name }
assignee { name }
project { name }
labels { nodes { name } }
`;
const EXACT_ISSUE_QUERY = `
query PaseoLinearIssue($id: String!) {
issue(id: $id) { ${ISSUE_FIELDS} }
}
`;
const SEARCH_ISSUES_QUERY = `
query PaseoLinearIssues($filter: IssueFilter) {
issues(first: 20, filter: $filter, orderBy: updatedAt) {
nodes { ${ISSUE_FIELDS} }
}
}
`;
const LINEAR_IDENTIFIER = /^[A-Z][A-Z0-9]+-\d+$/i;
export interface LinearAttachmentItem {
id: string;
identifier: string;
title: string;
subtitle?: string;
url: string;
text: string;
resourceType: string;
}
export interface LinearIssueSearch {
search(query: string): Promise<{ items: LinearAttachmentItem[] }>;
}
interface LinearIssueSearchOptions {
apiKey: string;
endpoint?: string;
request?: typeof fetch;
}
interface GraphqlRequest {
query: string;
variables: Record<string, unknown>;
}
class LinearApiError extends Error {
constructor(message: string) {
super(message);
this.name = "LinearApiError";
}
}
function issueSubtitle(issue: z.infer<typeof LinearIssueSchema>): string | undefined {
const parts = [issue.state.name, issue.assignee?.name].filter(
(part): part is string => typeof part === "string" && part.length > 0,
);
return parts.length > 0 ? parts.join(" · ") : undefined;
}
function issueText(issue: z.infer<typeof LinearIssueSchema>): string {
const labels = issue.labels.nodes.map((label) => label.name).join(", ");
const lines = [
`Linear issue ${issue.identifier}: ${issue.title}`,
`URL: ${issue.url}`,
`Status: ${issue.state.name}`,
`Priority: ${issue.priorityLabel}`,
];
if (issue.assignee) lines.push(`Assignee: ${issue.assignee.name}`);
if (issue.project) lines.push(`Project: ${issue.project.name}`);
if (labels) lines.push(`Labels: ${labels}`);
lines.push("", issue.description ?? "No description.");
return lines.join("\n");
}
function toAttachmentItem(issue: z.infer<typeof LinearIssueSchema>): LinearAttachmentItem {
const subtitle = issueSubtitle(issue);
return {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
...(subtitle ? { subtitle } : {}),
url: issue.url,
text: issueText(issue),
resourceType: "issue",
};
}
function describeHttpFailure(status: number): string {
if (status === 401 || status === 403) return "Linear rejected LINEAR_API_KEY";
if (status === 429) return "Linear rate limit reached. Try again shortly";
return `Linear API request failed with HTTP ${status}`;
}
function throwGraphqlErrors(errors: Array<{ message: string }> | undefined): void {
if (!errors || errors.length === 0) return;
throw new LinearApiError(errors.map((error) => error.message).join("; "));
}
export function createLinearIssueSearch(options: LinearIssueSearchOptions): LinearIssueSearch {
const apiKey = options.apiKey.trim();
if (!apiKey) throw new LinearApiError("Set LINEAR_API_KEY in the daemon environment");
const endpoint = options.endpoint ?? "https://api.linear.app/graphql";
const request = options.request ?? fetch;
async function graphql(body: GraphqlRequest): Promise<unknown> {
const response = await request(endpoint, {
method: "POST",
headers: {
Authorization: apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) throw new LinearApiError(describeHttpFailure(response.status));
return response.json();
}
async function findExact(identifier: string): Promise<LinearAttachmentItem[]> {
const response = ExactIssueResponseSchema.parse(
await graphql({ query: EXACT_ISSUE_QUERY, variables: { id: identifier } }),
);
throwGraphqlErrors(response.errors);
return response.data?.issue ? [toAttachmentItem(response.data.issue)] : [];
}
async function searchTitles(query: string): Promise<LinearAttachmentItem[]> {
const filter = query ? { title: { containsIgnoreCase: query } } : null;
const response = IssueSearchResponseSchema.parse(
await graphql({ query: SEARCH_ISSUES_QUERY, variables: { filter } }),
);
throwGraphqlErrors(response.errors);
if (!response.data) throw new LinearApiError("Linear returned no issue data");
return response.data.issues.nodes.map(toAttachmentItem);
}
return {
async search(query: string) {
const normalized = query.trim();
if (LINEAR_IDENTIFIER.test(normalized)) {
return { items: await findExact(normalized.toUpperCase()) };
}
const items = await searchTitles(normalized);
return { items };
},
};
}
+3
View File
@@ -0,0 +1,3 @@
{
"id": "linear"
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import { getAgentAttachmentPillContent } from "./attachment-pill-content";
describe("agent attachment pill content", () => {
it("presents external resources with their provider identity", () => {
const content = getAgentAttachmentPillContent(
{
type: "text",
mimeType: "text/plain",
title: "ENG-123 Plugin attachments",
text: "Linear issue ENG-123: Plugin attachments",
externalResource: {
provider: "linear",
providerLabel: "Linear issue",
resourceType: "issue",
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
},
},
i18n.t,
);
expect(content.title).toBe("Plugin attachments");
expect(content.subtitle).toBe("Linear issue ENG-123");
});
});
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import type { TFunction } from "i18next";
import React from "react";
import {
CircleDot,
FileText,
@@ -86,6 +87,13 @@ export function getAgentAttachmentPillContent(
subtitle: `Issue #${attachment.number}`,
};
case "text":
if (attachment.externalResource) {
return {
icon: attachmentGithubIssueIcon,
title: attachment.externalResource.title,
subtitle: `${attachment.externalResource.providerLabel} ${attachment.externalResource.identifier}`,
};
}
return {
icon: attachmentFileIcon,
title: attachment.title ?? t("message.attachments.textAttachment"),
+2
View File
@@ -3,6 +3,7 @@ import type {
ForgeSearchItem,
UploadedFileAttachment,
} from "@getpaseo/protocol/messages";
import type { PluginResourceComposerAttachment } from "@/plugins/attachments/model";
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
@@ -106,6 +107,7 @@ export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| WorkspaceFileComposerAttachment
| PluginResourceComposerAttachment
| { kind: "forge_issue"; item: ForgeSearchItem }
| { kind: "forge_change_request"; item: ForgeSearchItem }
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
+29
View File
@@ -863,6 +863,35 @@ describe("openComposerAttachment", () => {
});
expect(externalUrlCalls).toEqual([issueItem.url]);
});
it("opens plugin resource URLs through the external url opener", () => {
const externalUrlCalls: string[] = [];
openComposerAttachment({
attachment: {
kind: "plugin_resource",
pluginId: "linear",
sourceId: "issues",
sourceTitle: "Linear issue",
sourceIcon: "CircleDot",
item: {
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
text: "Linear issue ENG-123: Plugin attachments",
resourceType: "issue",
},
},
setLightboxMetadata: () => {
throw new Error("unexpected lightbox call");
},
openWorkspaceAttachment: () => false,
openExternalUrl: (url) => {
externalUrlCalls.push(url);
},
});
expect(externalUrlCalls).toEqual(["https://linear.app/acme/issue/ENG-123/plugin-attachments"]);
});
});
describe("toggleGithubAttachment", () => {
@@ -10,6 +10,7 @@ import {
buildLegacyGitHubAttachmentFromSearchItem,
} from "@/utils/review-attachments";
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
import { pluginResourceAttachmentToAgentAttachment } from "@/plugins/attachments/model";
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
@@ -55,6 +56,11 @@ export function splitComposerAttachmentsForSubmit(
continue;
}
if (attachment.kind === "plugin_resource") {
agentAttachments.push(pluginResourceAttachmentToAgentAttachment(attachment));
continue;
}
if (isWorkspaceAttachment(attachment)) {
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
images.push(attachment.attachment.screenshot);
+34 -1
View File
@@ -125,6 +125,8 @@ import { getForgePresentation } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon";
import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { readClipboardImage } from "./clipboard-image";
import { usePluginAttachmentPicker } from "@/plugins/attachments/picker";
import { PluginResourceAttachmentPill } from "@/plugins/attachments/pill";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
import {
appendWorkspaceFileAttachment,
@@ -440,6 +442,20 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
onRemove,
});
}
if (attachment.kind === "plugin_resource") {
return (
<PluginResourceAttachmentPill
key={`${attachment.pluginId}:${attachment.sourceId}:${attachment.item.id}`}
attachment={attachment}
index={index}
disabled={disabled}
onOpen={onOpen}
onRemove={onRemove}
openLabel={labels.openGithub}
removeLabel={labels.removeGithub}
/>
);
}
return (
<GithubAttachmentPill
key={`${attachment.item.kind}:${attachment.item.number}`}
@@ -1158,6 +1174,14 @@ export function Composer({
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
const attachButtonRef = useRef<View | null>(null);
const messageInputRef = useRef<MessageInputRef>(null);
const pluginAttachments = usePluginAttachmentPicker({
serverId,
client,
connected: isConnected,
attachments,
onChangeAttachments: setSelectedAttachments,
anchorRef: attachButtonRef,
});
const isComposerLocked = resolveIsComposerLocked(submitBehavior, isSubmitLoading);
const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
@@ -1922,6 +1946,7 @@ export function Composer({
setIsGithubPickerOpen(true);
},
},
...pluginAttachments.menuItems,
{
id: "file",
label: t("composer.attachments.addFile"),
@@ -1932,7 +1957,14 @@ export function Composer({
},
);
return items;
}, [forgePresentation, handlePasteImage, handlePickFile, handlePickImage, t]);
}, [
forgePresentation,
handlePasteImage,
handlePickFile,
handlePickImage,
pluginAttachments.menuItems,
t,
]);
const handleToggleGithubItem = useCallback(
(item: ForgeSearchItem) => {
@@ -2189,6 +2221,7 @@ export function Composer({
emptyText={githubEmptyText}
renderOption={renderGithubPickerOption}
/>
{pluginAttachments.picker}
</View>
</View>
</View>
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
createPluginResourceAttachment,
PluginResourceComposerAttachmentSchema,
pluginResourceAttachmentToAgentAttachment,
togglePluginResourceAttachment,
} from "./model";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
const source = {
pluginId: "linear",
sourceId: "issues",
sourceTitle: "Linear issue",
sourceIcon: "CircleDot",
};
const item = {
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
subtitle: "In progress · Mohamed",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
text: "Linear issue ENG-123: Plugin attachments\nStatus: In progress",
resourceType: "issue",
};
describe("plugin resource attachments", () => {
it("creates a draft-safe composer attachment", () => {
const attachment = createPluginResourceAttachment(source, item);
expect(PluginResourceComposerAttachmentSchema.parse(attachment)).toEqual({
kind: "plugin_resource",
...source,
item,
});
});
it("toggles one resource by plugin, source, and stable item id", () => {
const attachment = createPluginResourceAttachment(source, item);
expect(togglePluginResourceAttachment([], attachment)).toEqual([attachment]);
expect(togglePluginResourceAttachment([attachment], attachment)).toEqual([]);
});
it("submits through the backward-compatible text attachment", () => {
const attachment = createPluginResourceAttachment(source, item);
const agentAttachment = pluginResourceAttachmentToAgentAttachment(attachment);
expect(agentAttachment).toEqual({
type: "text",
mimeType: "text/plain",
title: "ENG-123 Plugin attachments",
text: item.text,
externalResource: {
provider: "linear",
providerLabel: "Linear issue",
resourceType: "issue",
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: item.url,
},
});
expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({
images: [],
attachments: [agentAttachment],
});
});
});
@@ -0,0 +1,70 @@
import { z } from "zod";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { UserComposerAttachment } from "@/attachments/types";
import { PluginAttachmentItemSchema, type PluginAttachmentItem } from "./schema";
export const PluginResourceComposerAttachmentSchema = z.object({
kind: z.literal("plugin_resource"),
pluginId: z.string().min(1),
sourceId: z.string().min(1),
sourceTitle: z.string().min(1),
sourceIcon: z.string().min(1),
item: PluginAttachmentItemSchema,
});
export type PluginResourceComposerAttachment = z.infer<
typeof PluginResourceComposerAttachmentSchema
>;
export interface PluginResourceSourceIdentity {
pluginId: string;
sourceId: string;
sourceTitle: string;
sourceIcon: string;
}
export function createPluginResourceAttachment(
source: PluginResourceSourceIdentity,
item: PluginAttachmentItem,
): PluginResourceComposerAttachment {
return {
kind: "plugin_resource",
...source,
item,
};
}
export function togglePluginResourceAttachment(
current: UserComposerAttachment[],
attachment: PluginResourceComposerAttachment,
): UserComposerAttachment[] {
const matches = (candidate: UserComposerAttachment) =>
candidate.kind === "plugin_resource" &&
candidate.pluginId === attachment.pluginId &&
candidate.sourceId === attachment.sourceId &&
candidate.item.id === attachment.item.id;
if (current.some(matches)) {
return current.filter((candidate) => !matches(candidate));
}
return [...current, attachment];
}
export function pluginResourceAttachmentToAgentAttachment(
attachment: PluginResourceComposerAttachment,
): AgentAttachment {
return {
type: "text",
mimeType: "text/plain",
title: `${attachment.item.identifier} ${attachment.item.title}`,
text: attachment.item.text,
externalResource: {
provider: attachment.pluginId,
providerLabel: attachment.sourceTitle,
resourceType: attachment.item.resourceType,
id: attachment.item.id,
identifier: attachment.item.identifier,
title: attachment.item.title,
url: attachment.item.url,
},
};
}
@@ -0,0 +1,176 @@
import { useCallback, useMemo, useState, type ReactElement, type RefObject } from "react";
import { View } from "react-native";
import { withUnistyles } from "react-native-unistyles";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { LucideIcon } from "lucide-react-native";
import type { UserComposerAttachment } from "@/attachments/types";
import type { AttachmentMenuItem } from "@/composer/input/input";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { useFetchQuery } from "@/data/query";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { resolvePluginIcon } from "../icons";
import { useInstalledPlugins } from "../registry";
import { searchPluginAttachments } from "../sdk";
import type { InstalledPlugin, PluginAttachmentSourceContribution } from "../types";
import { createPluginResourceAttachment, togglePluginResourceAttachment } from "./model";
import type { PluginAttachmentItem } from "./schema";
const SEARCH_STALE_TIME_MS = 30_000;
const EMPTY_ATTACHMENT_ITEMS: PluginAttachmentItem[] = [];
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function SourceIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
return <Icon size={ICON_SIZE.md} color={color} />;
}
const ThemedSourceIcon = withUnistyles(SourceIcon);
interface InstalledAttachmentSource {
plugin: InstalledPlugin;
source: PluginAttachmentSourceContribution;
key: string;
}
interface PluginAttachmentPickerInput {
serverId: string;
client: DaemonClient | null;
connected: boolean;
attachments: UserComposerAttachment[];
onChangeAttachments: (attachments: UserComposerAttachment[]) => void;
anchorRef: RefObject<View | null>;
}
interface PluginAttachmentPickerBinding {
menuItems: AttachmentMenuItem[];
picker: ReactElement | null;
}
function searchEmptyText(error: unknown, isFetching: boolean): string {
if (error instanceof Error) return error.message;
if (error) return String(error);
return isFetching ? "Searching..." : "No results";
}
function installedAttachmentSources(
plugins: InstalledPlugin[],
serverId: string,
): InstalledAttachmentSource[] {
return plugins
.filter((plugin) => plugin.serverId === serverId)
.flatMap((plugin) =>
plugin.attachmentSources.map((source) => ({
plugin,
source,
key: `${plugin.id}/${source.id}`,
})),
);
}
function attachmentOptions(items: PluginAttachmentItem[]): ComboboxOption[] {
return items.map((item) => ({
id: item.id,
label: `${item.identifier} ${item.title}`,
description: item.subtitle,
}));
}
export function usePluginAttachmentPicker(
input: PluginAttachmentPickerInput,
): PluginAttachmentPickerBinding {
const plugins = useInstalledPlugins();
const sources = useMemo(
() => installedAttachmentSources(plugins, input.serverId),
[input.serverId, plugins],
);
const [activeKey, setActiveKey] = useState<string | null>(null);
const [query, setQuery] = useState("");
const active = sources.find((candidate) => candidate.key === activeKey) ?? null;
const trimmedQuery = query.trim();
const search = useFetchQuery({
queryKey: [
"plugin-attachment-search",
input.serverId,
active?.plugin.id ?? "",
active?.source.id ?? "",
trimmedQuery,
],
queryFn: async () => {
if (!input.client || !active) throw new Error("Plugin host is offline");
const client = input.client;
return searchPluginAttachments(
active.source,
(method, rpcInput) => client.invokePluginRpc(active.plugin.id, method, rpcInput),
trimmedQuery,
);
},
enabled: input.connected && active !== null,
dataShape: "list",
staleTimeMs: SEARCH_STALE_TIME_MS,
});
const items = search.error
? EMPTY_ATTACHMENT_ITEMS
: (search.data?.items ?? EMPTY_ATTACHMENT_ITEMS);
const options = useMemo(() => attachmentOptions(items), [items]);
const close = useCallback(() => {
setActiveKey(null);
setQuery("");
}, []);
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) close();
},
[close],
);
const handleSelect = useCallback(
(itemId: string) => {
if (!active) return;
const item = items.find((candidate) => candidate.id === itemId);
if (!item) return;
const attachment = createPluginResourceAttachment(
{
pluginId: active.plugin.id,
sourceId: active.source.id,
sourceTitle: active.source.title,
sourceIcon: active.source.icon,
},
item,
);
input.onChangeAttachments(togglePluginResourceAttachment(input.attachments, attachment));
close();
},
[active, close, input, items],
);
const menuItems = useMemo(
() =>
sources.map(({ key, source }) => {
const Icon = resolvePluginIcon(source.icon);
return {
id: `plugin:${key}`,
label: `Attach ${source.title}`,
icon: <ThemedSourceIcon Icon={Icon} uniProps={iconColorMapping} />,
onSelect: () => setActiveKey(key),
};
}),
[sources],
);
if (!active) return { menuItems, picker: null };
return {
menuItems,
picker: (
<Combobox
options={options}
value=""
onSelect={handleSelect}
searchable
searchPlaceholder={active.source.searchPlaceholder}
title={active.source.pickerTitle}
open
onOpenChange={handleOpenChange}
onSearchQueryChange={setQuery}
desktopPlacement="top-start"
anchorRef={input.anchorRef}
emptyText={searchEmptyText(search.error, search.isFetching)}
/>
),
};
}
@@ -0,0 +1,65 @@
import { useCallback, useMemo } from "react";
import { withUnistyles } from "react-native-unistyles";
import { AttachmentLabel, AttachmentPill } from "@/components/attachment-pill";
import type { ComposerAttachment } from "@/attachments/types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { resolvePluginIcon } from "../icons";
import type { PluginResourceComposerAttachment } from "./model";
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
function ResourceIcon({
Icon,
color = "",
}: {
Icon: ReturnType<typeof resolvePluginIcon>;
color?: string;
}) {
return <Icon size={ICON_SIZE.sm} color={color} />;
}
const ThemedResourceIcon = withUnistyles(ResourceIcon);
interface PluginResourceAttachmentPillProps {
attachment: PluginResourceComposerAttachment;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
openLabel: (kind: string, identifier: string) => string;
removeLabel: (kind: string, identifier: string) => string;
}
export function PluginResourceAttachmentPill({
attachment,
index,
disabled,
onOpen,
onRemove,
openLabel,
removeLabel,
}: PluginResourceAttachmentPillProps) {
const Icon = resolvePluginIcon(attachment.sourceIcon);
const handleOpen = useCallback(() => onOpen(attachment), [attachment, onOpen]);
const handleRemove = useCallback(() => onRemove(index), [index, onRemove]);
const icon = useMemo(
() => <ThemedResourceIcon Icon={Icon} uniProps={iconColorMapping} />,
[Icon],
);
return (
<AttachmentPill
testID="composer-plugin-resource-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={openLabel(attachment.sourceTitle, attachment.item.identifier)}
removeAccessibilityLabel={removeLabel(attachment.sourceTitle, attachment.item.identifier)}
disabled={disabled}
>
<AttachmentLabel
icon={icon}
title={attachment.item.title}
subtitle={`${attachment.sourceTitle} ${attachment.item.identifier}`}
/>
</AttachmentPill>
);
}
@@ -0,0 +1,18 @@
import { z } from "zod";
export const PluginAttachmentItemSchema = z.object({
id: z.string().min(1),
identifier: z.string().min(1),
title: z.string().min(1),
subtitle: z.string().min(1).optional(),
url: z.string().url(),
text: z.string().min(1),
resourceType: z.string().min(1),
});
export const PluginAttachmentSearchPayloadSchema = z.object({
items: z.array(PluginAttachmentItemSchema),
});
export type PluginAttachmentItem = z.infer<typeof PluginAttachmentItemSchema>;
export type PluginAttachmentSearchPayload = z.infer<typeof PluginAttachmentSearchPayloadSchema>;
+47
View File
@@ -27,6 +27,53 @@ describe("evaluatePluginClientBundle", () => {
]);
});
it("collects a declarative attachment source", () => {
const plugin = evaluatePluginClientBundle(
"linear",
bundle(`
plugin.addAttachmentSource({
id: "issues",
title: "Linear issue",
icon: "CircleDot",
pickerTitle: "Attach Linear issue",
searchPlaceholder: "Search by identifier or title",
search: { name: "issues.search", input: {}, output: {} },
});
`),
);
expect(plugin.attachmentSources).toEqual([
{
id: "issues",
title: "Linear issue",
icon: "CircleDot",
pickerTitle: "Attach Linear issue",
searchPlaceholder: "Search by identifier or title",
search: { name: "issues.search", input: {}, output: {} },
},
]);
});
it("rejects duplicate attachment source ids", () => {
expect(() =>
evaluatePluginClientBundle(
"linear",
bundle(`
const source = {
id: "issues",
title: "Linear issue",
icon: "CircleDot",
pickerTitle: "Attach Linear issue",
searchPlaceholder: "Search",
search: { name: "issues.search", input: {}, output: {} },
};
plugin.addAttachmentSource(source);
plugin.addAttachmentSource(source);
`),
),
).toThrow("Duplicate attachment source: issues");
});
it("rejects a sidebar placement whose surface does not exist", () => {
expect(() =>
evaluatePluginClientBundle(
+54 -5
View File
@@ -4,8 +4,19 @@ import * as ReactNative from "react-native";
// eslint-disable-next-line no-restricted-imports -- plugin bundles receive TanStack's real runtime, not Paseo's query wrappers.
import * as ReactQuery from "@tanstack/react-query";
import * as Zod from "zod";
import { createPluginContext, defineRpc, type PluginRegistrationCollector, useRpc } from "./sdk";
import type { EvaluatedPlugin, PluginSidebarContribution, PluginSurfaceProps } from "./types";
import {
createPluginContext,
defineAttachmentSource,
defineRpc,
type PluginRegistrationCollector,
useRpc,
} from "./sdk";
import type {
EvaluatedPlugin,
PluginAttachmentSourceContribution,
PluginSidebarContribution,
PluginSurfaceProps,
} from "./types";
import type { ComponentType } from "react";
import { resolvePluginIcon } from "./icons";
@@ -18,9 +29,14 @@ function requireId(value: string, label: string): string {
}
export function evaluatePluginClientBundle(id: string, bundle: string): EvaluatedPlugin {
const collector: PluginRegistrationCollector = { surfaces: [], sidebarItems: [] };
const collector: PluginRegistrationCollector = {
surfaces: [],
sidebarItems: [],
attachmentSources: [],
};
const surfaceIds = new Set<string>();
const sidebarItemIds = new Set<string>();
const attachmentSourceIds = new Set<string>();
const pluginContext = createPluginContext({
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
const normalizedId = requireId(surfaceId, "surface id");
@@ -45,12 +61,40 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
surface: requireId(contribution.surface, "sidebar surface id"),
});
},
addAttachmentSource(contribution: PluginAttachmentSourceContribution) {
const normalizedId = requireId(contribution.id, "attachment source id");
if (attachmentSourceIds.has(normalizedId)) {
throw new Error(`Duplicate attachment source: ${normalizedId}`);
}
const title = contribution.title.trim();
const icon = contribution.icon.trim();
const pickerTitle = contribution.pickerTitle.trim();
const searchPlaceholder = contribution.searchPlaceholder.trim();
const method = contribution.search.name.trim();
if (!title) throw new Error(`Attachment source ${normalizedId} has no title`);
if (!icon) throw new Error(`Attachment source ${normalizedId} has no icon`);
if (!pickerTitle) throw new Error(`Attachment source ${normalizedId} has no picker title`);
if (!searchPlaceholder) {
throw new Error(`Attachment source ${normalizedId} has no search placeholder`);
}
if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`);
resolvePluginIcon(icon);
attachmentSourceIds.add(normalizedId);
collector.attachmentSources.push({
id: normalizedId,
title,
icon,
pickerTitle,
searchPlaceholder,
search: { ...contribution.search, name: method },
});
},
});
const runtimeRequire = (name: string): unknown => {
if (name === "react") return React;
if (name === "react/jsx-runtime") return ReactJsxRuntime;
if (name === "react-native") return ReactNative;
if (name === "@paseo/plugin") return { defineRpc, useRpc };
if (name === "@paseo/plugin") return { defineAttachmentSource, defineRpc, useRpc };
if (name === "@tanstack/react-query") return ReactQuery;
if (name === "zod") return Zod;
throw new Error(`Module "${name}" is not available in plugin client code`);
@@ -72,5 +116,10 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
throw new Error(`Sidebar item ${item.id} references missing surface ${item.surface}`);
}
}
return { id, surfaces: collector.surfaces, sidebarItems: collector.sidebarItems };
return {
id,
surfaces: collector.surfaces,
sidebarItems: collector.sidebarItems,
attachmentSources: collector.attachmentSources,
};
}
+73 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { callPluginRpc, defineRpc } from "./sdk";
import { callPluginRpc, defineAttachmentSource, defineRpc, searchPluginAttachments } from "./sdk";
const contract = defineRpc({
name: "increment",
@@ -36,3 +36,75 @@ describe("plugin RPC contracts", () => {
await expect(callPluginRpc(contract, invoke, { value: 1 })).resolves.toEqual({ value: 2 });
});
});
describe("plugin attachment sources", () => {
const search = defineRpc({
name: "issues.search",
input: z.object({ query: z.string() }),
output: z.object({
items: z.array(
z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
subtitle: z.string().optional(),
url: z.string(),
text: z.string(),
resourceType: z.string(),
}),
),
}),
});
it("searches through the declared RPC and validates the standard result", async () => {
const source = defineAttachmentSource({
id: "issues",
title: "Linear issue",
icon: "CircleDot",
pickerTitle: "Attach Linear issue",
searchPlaceholder: "Search",
search,
});
const payload = await searchPluginAttachments(
source,
async (method, input) => ({
items: [
{
id: "issue-1",
identifier: `${method}:${String(Reflect.get(input as object, "query"))}`,
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
text: "Linear issue ENG-123: Plugin attachments",
resourceType: "issue",
},
],
}),
"ENG-123",
);
expect(payload.items[0]).toEqual({
id: "issue-1",
identifier: "issues.search:ENG-123",
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
text: "Linear issue ENG-123: Plugin attachments",
resourceType: "issue",
});
});
it("rejects a malformed standard search result", async () => {
const source = defineAttachmentSource({
id: "issues",
title: "Linear issue",
icon: "CircleDot",
pickerTitle: "Attach Linear issue",
searchPlaceholder: "Search",
search,
});
await expect(
searchPluginAttachments(source, async () => ({ items: [{ id: "issue-1" }] }), "issue"),
).rejects.toBeInstanceOf(z.ZodError);
});
});
+28
View File
@@ -7,7 +7,12 @@ import {
type ReactNode,
} from "react";
import type { ZodType, output as ZodOutput, input as ZodInput } from "zod";
import {
PluginAttachmentSearchPayloadSchema,
type PluginAttachmentSearchPayload,
} from "./attachments/schema";
import type {
PluginAttachmentSourceContribution,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSurfaceProps,
@@ -16,6 +21,7 @@ import type {
interface PluginCollector {
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
addSidebarItem(contribution: PluginSidebarContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
}
interface PluginRpcContextValue {
@@ -40,6 +46,7 @@ export interface PluginContext {
): void;
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
addSidebarItem(contribution: PluginSidebarContribution): void;
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
}
interface RpcDefinition<InputSchema extends ZodType, OutputSchema extends ZodType> {
@@ -58,6 +65,14 @@ export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodT
return { ...definition, name };
}
export type { PluginAttachmentItem, PluginAttachmentSearchPayload } from "./attachments/schema";
export function defineAttachmentSource(
definition: PluginAttachmentSourceContribution,
): PluginAttachmentSourceContribution {
return definition;
}
const PluginRpcContext = createContext<PluginRpcContextValue | null>(null);
export function PluginRpcProvider({
@@ -79,9 +94,21 @@ export function createPluginContext(collector: PluginCollector) {
addSidebarItem(contribution: PluginSidebarContribution) {
collector.addSidebarItem(contribution);
},
addAttachmentSource(contribution: PluginAttachmentSourceContribution) {
collector.addAttachmentSource(contribution);
},
};
}
export async function searchPluginAttachments(
source: PluginAttachmentSourceContribution,
invoke: (method: string, input: unknown) => Promise<unknown>,
query: string,
): Promise<PluginAttachmentSearchPayload> {
const output = await callPluginRpc(source.search, invoke, { query });
return PluginAttachmentSearchPayloadSchema.parseAsync(output);
}
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>> {
@@ -107,4 +134,5 @@ export async function callPluginRpc<InputSchema extends ZodType, OutputSchema ex
export interface PluginRegistrationCollector {
surfaces: PluginSurfaceContribution[];
sidebarItems: PluginSidebarContribution[];
attachmentSources: PluginAttachmentSourceContribution[];
}
@@ -18,6 +18,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin {
surface: "surface",
},
],
attachmentSources: [],
};
}
+11
View File
@@ -1,5 +1,6 @@
import type { ComponentType } from "react";
import type { QueryClient } from "@tanstack/react-query";
import type { PluginRpcContract } from "./sdk";
export interface PluginSurfaceProps {
theme: Record<string, unknown>;
@@ -25,10 +26,20 @@ export interface PluginSidebarContribution {
surface: string;
}
export interface PluginAttachmentSourceContribution {
id: string;
title: string;
icon: string;
pickerTitle: string;
searchPlaceholder: string;
search: PluginRpcContract;
}
export interface EvaluatedPlugin {
id: string;
surfaces: PluginSurfaceContribution[];
sidebarItems: PluginSidebarContribution[];
attachmentSources: PluginAttachmentSourceContribution[];
}
export interface InstalledPlugin extends EvaluatedPlugin {
@@ -71,6 +71,34 @@ describe("draft-store lifecycle", () => {
});
describe("draft-store normalization", () => {
it("preserves plugin resources when hydrating a draft", () => {
const attachment = {
kind: "plugin_resource" as const,
pluginId: "linear",
sourceId: "issues",
sourceTitle: "Linear issue",
sourceIcon: "CircleDot",
item: {
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
subtitle: "In progress",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
text: "Linear issue ENG-123: Plugin attachments",
resourceType: "issue",
},
};
expect(
toDraftInputIfReady({
input: { text: "Implement this", attachments: [attachment] },
lifecycle: "active",
updatedAt: 1,
version: 1,
}),
).toEqual({ text: "Implement this", attachments: [attachment] });
});
it("preserves uploaded file attachments when hydrating a draft", () => {
const attachment = {
kind: "file" as const,
@@ -9,6 +9,7 @@ import {
GitHubSearchItemSchema,
UploadedFileAttachmentSchema,
} from "@getpaseo/protocol/messages";
import { PluginResourceComposerAttachmentSchema } from "@/plugins/attachments/model";
export const DRAFT_STORE_VERSION = 5;
export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
@@ -94,6 +95,9 @@ export function isUserComposerAttachment(value: unknown): value is UserComposerA
if (record.kind === "file") {
return UploadedFileAttachmentSchema.safeParse(record.attachment).success;
}
if (record.kind === "plugin_resource") {
return PluginResourceComposerAttachmentSchema.safeParse(record).success;
}
if (
record.kind !== "forge_issue" &&
record.kind !== "forge_change_request" &&
@@ -310,6 +310,50 @@ describe("shared messages attachments", () => {
]);
});
it("preserves neutral external-resource presentation on text attachments", () => {
const parsed = SendAgentMessageRequestSchema.parse({
type: "send_agent_message_request",
requestId: "req-external-resource",
agentId: "agent-1",
text: "Implement this",
attachments: [
{
type: "text",
mimeType: "text/plain",
title: "ENG-123 Plugin attachments",
text: "Linear issue ENG-123: Plugin attachments",
externalResource: {
provider: "linear",
providerLabel: "Linear issue",
resourceType: "issue",
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
},
},
],
});
expect(parsed.attachments).toEqual([
{
type: "text",
mimeType: "text/plain",
title: "ENG-123 Plugin attachments",
text: "Linear issue ENG-123: Plugin attachments",
externalResource: {
provider: "linear",
providerLabel: "Linear issue",
resourceType: "issue",
id: "issue-uuid",
identifier: "ENG-123",
title: "Plugin attachments",
url: "https://linear.app/acme/issue/ENG-123/plugin-attachments",
},
},
]);
});
it("keeps known firstAgentContext attachments and drops unknown ones", () => {
const parsed = CreatePaseoWorktreeRequestSchema.parse({
type: "create_paseo_worktree_request",
+11
View File
@@ -985,6 +985,16 @@ export const ForgeIssueAttachmentSchema = z.object({
projectPath: z.string().optional(),
});
export const ExternalResourceAttachmentMetadataSchema = z.object({
provider: z.string(),
providerLabel: z.string(),
resourceType: z.string(),
id: z.string(),
identifier: z.string(),
title: z.string(),
url: z.string(),
});
export const TextAttachmentSchema = z
.object({
type: z.literal("text"),
@@ -992,6 +1002,7 @@ export const TextAttachmentSchema = z
contextKind: z.string().optional(),
title: z.string().nullable().optional(),
text: z.string(),
externalResource: ExternalResourceAttachmentMetadataSchema.optional(),
})
.transform(({ contextKind, ...attachment }) => ({
...attachment,
@@ -7,7 +7,7 @@ type PluginBuildTarget = "client" | "server";
const REGISTRATIONS_REMOVED_BY_TARGET: Record<PluginBuildTarget, ReadonlySet<string>> = {
client: new Set(["handle"]),
server: new Set(["addSurface", "addSidebarItem"]),
server: new Set(["addSurface", "addSidebarItem", "addAttachmentSource"]),
};
function registrationName(statement: unknown, contextName: string): string | null {
@@ -41,6 +41,10 @@ function defineRpc(contract: RpcContract): RpcContract {
return { ...contract, name: contract.name.trim() };
}
function defineAttachmentSource<T>(definition: T): T {
return definition;
}
function register(contract: RpcContract, handler: RpcHandler): void {
if (typeof handler !== "function") {
throw new Error(`Plugin RPC ${contract.name} must provide a handler`);
@@ -50,7 +54,7 @@ function register(contract: RpcContract, handler: RpcHandler): void {
}
function runtimeRequire(name: string): unknown {
if (name === "@paseo/plugin") return { defineRpc };
if (name === "@paseo/plugin") return { defineAttachmentSource, defineRpc };
return nodeRequire(name);
}
@@ -1,6 +1,7 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import pino from "pino";
import { afterEach, describe, expect, it } from "vitest";
import { PluginRuntime } from "./runtime.js";
@@ -22,6 +23,22 @@ afterEach(async () => {
});
describe("PluginRuntime", () => {
it("loads the official Linear attachment extension", async () => {
const directory = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../extensions/linear",
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
await runtime.start({ linear: { source: "directory", path: directory } });
expect(runtime.catalog().map((plugin) => plugin.id)).toEqual(["linear"]);
expect(runtime.catalog()[0]?.clientBundle).toContain("Attach Linear issue");
expect(runtime.catalog()[0]?.clientBundle).not.toContain("LINEAR_API_KEY");
expect(runtime.catalog()[0]?.clientBundle).not.toContain("api.linear.app");
await runtime.stop();
});
it("loads one index.tsx, exposes its client bundle, and invokes its server RPC", async () => {
const directory = await createPlugin(
"hello",
@@ -29,7 +46,7 @@ describe("PluginRuntime", () => {
import { platform } from "node:os";
import { Text } from "react-native";
import { z } from "zod";
import { defineRpc } from "@paseo/plugin";
import { defineAttachmentSource, defineRpc } from "@paseo/plugin";
const greetRpc = defineRpc({
name: "greet",
@@ -37,6 +54,15 @@ const greetRpc = defineRpc({
output: z.object({ message: z.string(), platform: z.string() }),
});
const attachments = defineAttachmentSource({
id: "issues",
title: "Example issue",
icon: "CircleDot",
pickerTitle: "Attach example issue",
searchPlaceholder: "Search issues",
search: greetRpc,
});
function HelloSurface() {
return <Text>Hello from native UI</Text>;
}
@@ -48,6 +74,7 @@ export default function contribute(plugin: any) {
}));
plugin.addSurface("main", HelloSurface);
plugin.addSidebarItem({ id: "hello", title: "Hello", icon: "Sparkles", surface: "main" });
plugin.addAttachmentSource(attachments);
}`,
);
const runtime = new PluginRuntime(pino({ level: "silent" }));
@@ -58,6 +85,7 @@ export default function contribute(plugin: any) {
expect(catalog).toHaveLength(1);
expect(catalog[0]?.id).toBe("hello");
expect(catalog[0]?.clientBundle).toContain("Hello from native UI");
expect(catalog[0]?.clientBundle).toContain("Attach example issue");
expect(catalog[0]?.clientBundle).not.toContain("node:os");
expect(catalog[0]?.clientBundle).not.toContain("get: () => from[key]");
await expect(runtime.invoke("hello", "greet", { name: "Paseo" })).resolves.toMatchObject({