mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
feat(plugins): add Linear attachment source
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user